This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/cayenne.git
The following commit(s) were added to refs/heads/master by this push:
new 1384d2b71 CAY-2980 Improve model name generation
1384d2b71 is described below
commit 1384d2b719422645ed65bd894e570a8f9419b664
Author: Andrus Adamchik <[email protected]>
AuthorDate: Sun Jul 19 22:27:43 2026 +0200
CAY-2980 Improve model name generation
* guess reverse relationship name based on FK
---
ai-plugin/references/model-naming-conventions.md | 35 +++++++------
ai-plugin/skills/cayenne-model-naming/SKILL.md | 14 ++---
.../dbsync/naming/BaseObjectNameGenerator.java | 59 +++++++++++++++++++---
.../naming/DefaultObjectNameGeneratorTest.java | 58 +++++++++++++++++++++
.../naming/PatternObjectNameGeneratorTest.java | 31 ++++++++++++
.../tools/dbimport/testOneToOne.map.xml-result | 4 +-
6 files changed, 172 insertions(+), 29 deletions(-)
diff --git a/ai-plugin/references/model-naming-conventions.md
b/ai-plugin/references/model-naming-conventions.md
index d867f3104..315a4b210 100644
--- a/ai-plugin/references/model-naming-conventions.md
+++ b/ai-plugin/references/model-naming-conventions.md
@@ -41,7 +41,7 @@ Concretely, the generator produces:
| `db-entity` name | stem, split on `_`, capitalize each token |
`ARTIST_GROUP` → `ArtistGroup` |
| `db-attribute` name | split on `_`, camelCase | `FIRST_NAME` → `firstName` |
| to-one relationship | FK column minus trailing `_ID`/`ID`; else target
entity name | `MANAGER_ID` → `manager` |
-| to-many relationship | English plural of the target entity name | `PAINTING`
→ `paintings` |
+| to-many relationship | English plural of the target entity name, prefixed
with the FK role qualifier when the FK column embeds the source entity name |
`PAINTING` → `paintings`; `HOME_TEAM_ID` → `homeGames` |
| name collision within an entity | append a numeric suffix | `team`, `team1`,
`team2` … |
Generation also collapses all-upper tokens to lowercase and preserves
already-mixed
@@ -77,15 +77,19 @@ exists — never invent one (`status` is not `sta` + `tus`;
`metadata` is one wo
### 2. More than one relationship between the same two tables
-When two FKs point at the same target table (or two relationships otherwise
share a target),
-generation can't invent a role, so it disambiguates with numbers. But **the two
+When two FKs point at the same target table (or two relationships otherwise
share a target), and a
+role can't be derived from the FK column, generation disambiguates with
numbers. **The two
directions are not equally affected** — the to-one and to-many sides are named
by different rules:
-- **to-many side always collides.** To-many naming ignores the FK column
entirely and always uses
- the pluralized target entity name, so two relationships to the same target
both become e.g.
- `games` / `games1` (or `people` / `people1`) no matter how well the FKs are
named. This is the
- common case and the main reason this rule exists. Name each collection by
the **logically opposite**
- role instead: on `Team`, the two reverse collections of `Game` become
`homeGames` / `awayGames`.
+- **to-many side collides when the FK doesn't embed the source entity name.**
When the FK column is
+ `<ROLE>_<SOURCE_ENTITY>[_ID]`, the generator prepends the role qualifier to
the pluralized target
+ (`HOME_TEAM_ID` / `AWAY_TEAM_ID` on FKs to `TEAM` → `homeGames` /
`awayGames`) — those are already
+ correct, leave them. The entity-name part may drop a common table prefix:
`home_team_id`
+ referencing `aa_team` still yields `homeAaGames`. It still collides when the
FK carries a role
+ unrelated to the source entity name:
+ `MANAGER_ID` / `AUDITOR_ID` FKs to `EMPLOYEE` both produce `projects` /
`projects1` on
+ `Employee`, because the role can't be mechanically tied to the entity. Name
each collection by its
+ role yourself: `managedProjects` / `auditedProjects`.
- **to-one side usually does NOT collide.** To-one naming is FK-column-based —
it strips a trailing
`_ID`/`ID`, so distinct `*_ID` FKs already yield distinct, good names
(`HOME_TEAM_ID` → `homeTeam`,
@@ -95,10 +99,10 @@ directions are not equally affected** — the to-one and
to-many sides are named
role from the FK column yourself even though it lacks the `_ID` suffix
(`MANAGER` → `manager`,
`SUPERVISOR` → `supervisor`).
-So in the typical "two well-named `*_ID` FKs" model you'll rename **only the
to-many collections**
-(`games`/`games1`), and the to-one ends are already fine. When you do rename
both ends, give them
-matching opposite-role names so the pair is legible from either side
(`homeTeam` ↔ `homeGames`,
-`awayTeam` ↔ `awayGames`).
+So in the typical "two `<ROLE>_<ENTITY>_ID` FKs" model **both directions are
already fine**
+(`homeTeam` ↔ `homeGames`, `awayTeam` ↔ `awayGames`) — nothing to rename. When
you do rename, give
+the two ends matching opposite-role names so the pair is legible from either
side
+(`manager` ↔ `managedProjects`).
### 3. Genuinely cryptic abbreviations (secondary, be conservative)
@@ -164,9 +168,10 @@ guessing.
A DbRelationship `name` is **arbitrary** (not derived from a real table/column
like a DbEntity/
DbAttribute), but it is **not** exempt from cleanup — treat it as the
first-class citizen here, since
every FK produces a DbRelationship whether or not an ObjRelationship was
generated on it. Reverse
-engineering names it with the **same generator** as ObjRelationships
(`relationshipName()`: to-one =
-FK column minus a trailing `_ID`/`ID`, else target entity name; to-many =
English plural of the
-target entity, all `underscoredToJava`-cased), so it inherits the same gaps —
run-together names,
+engineering names it with the **same generator** as ObjRelationships (to-one =
FK column minus a
+trailing `_ID`/`ID`, else target entity name; to-many = English plural of the
target entity with a
+FK role qualifier when the FK embeds the source entity name, all
`underscoredToJava`-cased), so it
+inherits the same gaps — run-together names,
numbered collisions (`toArtist` / `toArtist1`), a leaked common prefix. Apply
§1–§5 to DbRelationship
names directly, deriving the fix from the DbRelationship's own DB metadata
(its FK column for to-one,
its target DbEntity pluralized for to-many).
diff --git a/ai-plugin/skills/cayenne-model-naming/SKILL.md
b/ai-plugin/skills/cayenne-model-naming/SKILL.md
index 998d62957..d4a85b2b3 100644
--- a/ai-plugin/skills/cayenne-model-naming/SKILL.md
+++ b/ai-plugin/skills/cayenne-model-naming/SKILL.md
@@ -77,12 +77,14 @@ correct — leave them.** Flag only the cases the
deterministic generator can't
1. **Run-together names with no separators** — `gametype` → `GameType`,
`dateofbirth` → `dateOfBirth`.
Split on a real word boundary you're confident about; never invent one.
-2. **Numbered collision names** (`games` / `games1`, `people` / `people1`)
from more than one
- relationship between the same two tables. This almost always hits the
**to-many** side, which
- ignores FK columns and just pluralizes the target — rename those by the
logical inverse role
- (`homeGames` / `awayGames`). The **to-one** side is usually already fine
(it's FK-based:
- `HOME_TEAM_ID` → `homeTeam`); only fix it in the fallback case where FK
columns lack an `_ID`
- suffix and collapse to `employee` / `employee1`.
+2. **Numbered collision names** (`projects` / `projects1`, `people` /
`people1`) from more than one
+ relationship between the same two tables. The generator resolves the common
cases itself: to-one
+ names are FK-based (`HOME_TEAM_ID` → `homeTeam`), and to-many names pick up
a FK role qualifier
+ when the FK embeds the source entity name, even one sans a common table
prefix (`HOME_TEAM_ID` →
+ `homeGames`; `home_team_id` referencing `aa_team` → `homeAaGames`). What
still collides:
+ to-many collections whose FK role is unrelated to the entity name
(`MANAGER_ID` / `AUDITOR_ID` →
+ `projects` / `projects1` — rename by role: `managedProjects` /
`auditedProjects`), and to-one
+ ends whose FK columns lack an `_ID` suffix and collapse to `employee` /
`employee1`.
3. **A common entity prefix leaking into relationship names** — when every
entity shares a prefix
that was **kept** on the class names (`AaCustomer`, `AaOrder`), the
relationship names inherit it
(`aaOrders`, `aaCustomer`). Strip the prefix from the relationship names
(`orders`, `customer`);
diff --git
a/cayenne-dbsync/src/main/java/org/apache/cayenne/dbsync/naming/BaseObjectNameGenerator.java
b/cayenne-dbsync/src/main/java/org/apache/cayenne/dbsync/naming/BaseObjectNameGenerator.java
index 004188b27..8d1a06a7d 100644
---
a/cayenne-dbsync/src/main/java/org/apache/cayenne/dbsync/naming/BaseObjectNameGenerator.java
+++
b/cayenne-dbsync/src/main/java/org/apache/cayenne/dbsync/naming/BaseObjectNameGenerator.java
@@ -81,15 +81,55 @@ public abstract class BaseObjectNameGenerator implements
ObjectNameGenerator {
protected String relationshipBase(List<DbJoin> joins, String
targetEntityName, boolean toMany) {
return toMany
- ? toManyBase(targetEntityName)
+ ? toManyBase(joins, targetEntityName)
: toOneBase(joins, targetEntityName);
}
- protected String toManyBase(String targetEntityName) {
- String baseName = dbEntityBaseName(targetEntityName);
- return EnglishInflector.pluralOf(baseName.toLowerCase());
+ protected String toManyBase(List<DbJoin> joins, String targetEntityName) {
+ String plural =
EnglishInflector.pluralOf(dbEntityBaseName(targetEntityName).toLowerCase());
+ String qualifier = toManyRoleQualifier(joins);
+ return qualifier != null ? qualifier + "_" + plural : plural;
}
+ private String toManyRoleQualifier(List<DbJoin> joins) {
+
+ if (joins.isEmpty()) {
+ return null;
+ }
+
+ DbJoin join1 = joins.getFirst();
+ DbEntity sourceEntity = join1.getRelationship().getSourceEntity();
+
+ // the FK column of a to-many relationship is on the target side of
the join
+ String fkColName = join1.getTargetName();
+ if (sourceEntity == null || fkColName == null) {
+ return null;
+ }
+
+ String fkBase = stripIdSuffix(fkColName);
+ String role = dbEntityBaseName(fkBase != null ? fkBase : fkColName);
+ String roleUpper = role.toUpperCase();
+
+ // match the role against the source entity name, then against its
shorter "_"-token suffixes,
+ // as FK columns often drop a common table-name prefix ("home_team_id"
referencing "nhl_team")
+ String suffix = dbEntityBaseName(sourceEntity.getName()).toUpperCase();
+ while (true) {
+ if (roleUpper.equals(suffix)) {
+ return null;
+ }
+
+ if (roleUpper.endsWith("_" + suffix)) {
+ String qualifier = role.substring(0, role.length() -
suffix.length() - 1);
+ return qualifier.isEmpty() ? null : qualifier;
+ }
+
+ int underscore = suffix.indexOf('_');
+ if (underscore < 0) {
+ return null;
+ }
+ suffix = suffix.substring(underscore + 1);
+ }
+ }
protected String toOneBase(List<DbJoin> joins, String targetEntityName) {
@@ -107,12 +147,19 @@ public abstract class BaseObjectNameGenerator implements
ObjectNameGenerator {
String fkColName = join1.getSourceName();
if (fkColName == null) {
return dbEntityBaseName(targetEntityName);
- } else if (fkColName.toUpperCase().endsWith("_ID") &&
fkColName.length() > 3) {
+ }
+
+ String fkBase = stripIdSuffix(fkColName);
+ return fkBase != null ? fkBase : dbEntityBaseName(targetEntityName);
+ }
+
+ private static String stripIdSuffix(String fkColName) {
+ if (fkColName.toUpperCase().endsWith("_ID") && fkColName.length() > 3)
{
return fkColName.substring(0, fkColName.length() - 3);
} else if (fkColName.toUpperCase().endsWith("ID") &&
fkColName.length() > 2) {
return fkColName.substring(0, fkColName.length() - 2);
} else {
- return dbEntityBaseName(targetEntityName);
+ return null;
}
}
}
diff --git
a/cayenne-dbsync/src/test/java/org/apache/cayenne/dbsync/naming/DefaultObjectNameGeneratorTest.java
b/cayenne-dbsync/src/test/java/org/apache/cayenne/dbsync/naming/DefaultObjectNameGeneratorTest.java
index 34877dc7a..50c0a96a3 100644
---
a/cayenne-dbsync/src/test/java/org/apache/cayenne/dbsync/naming/DefaultObjectNameGeneratorTest.java
+++
b/cayenne-dbsync/src/test/java/org/apache/cayenne/dbsync/naming/DefaultObjectNameGeneratorTest.java
@@ -90,6 +90,64 @@ public class DefaultObjectNameGeneratorTest {
assertEquals("addresses", generator.objRelationshipName(r6));
}
+ @Test
+ public void objRelationshipName_ToMany_RoleQualifiedFk() {
+
+ // two FKs from GAME to TEAM: the reverse collections take the role
from the FK column
+ DbRelationship r1 = makeRelationship("TEAM", "TEAM_ID", "GAME",
"HOME_TEAM_ID", true);
+ assertEquals("homeGames", generator.objRelationshipName(r1));
+
+ DbRelationship r2 = makeRelationship("TEAM", "TEAM_ID", "GAME",
"AWAY_TEAM_ID", true);
+ assertEquals("awayGames", generator.objRelationshipName(r2));
+
+ DbRelationship r3 = makeRelationship("team", "team_id", "game",
"home_team_id", true);
+ assertEquals("homeGames", generator.objRelationshipName(r3));
+
+ // FK without an ID suffix still carries the role
+ DbRelationship r4 = makeRelationship("TEAM", "TEAM_ID", "GAME",
"HOME_TEAM", true);
+ assertEquals("homeGames", generator.objRelationshipName(r4));
+
+ // multi-word qualifier and entity name
+ DbRelationship r5 = makeRelationship("ARTIST_GROUP", "ID", "EXHIBIT",
"PRIMARY_ARTIST_GROUP_ID", true);
+ assertEquals("primaryExhibits", generator.objRelationshipName(r5));
+
+ // FK role unrelated to the source entity name gets no qualifier
+ DbRelationship r6 = makeRelationship("PERSON", "PERSON_ID", "PERSON",
"MOTHER_ID", true);
+ assertEquals("people", generator.objRelationshipName(r6));
+
+ // FK ending with the entity name without a "_" boundary gets no
qualifier
+ DbRelationship r7 = makeRelationship("TEAM", "TEAM_ID", "GAME",
"STEAM_ID", true);
+ assertEquals("games", generator.objRelationshipName(r7));
+ }
+
+ @Test
+ public void
objRelationshipName_ToMany_RoleQualifiedFk_FkDropsTablePrefix() {
+
+ // FK columns matching a "_"-token suffix of the entity name still
carry the role...
+ DbRelationship r1 = makeRelationship("nhl_team", "id", "nhl_game",
"home_team_id", true);
+ assertEquals("homeNhlGames", generator.objRelationshipName(r1));
+
+ DbRelationship r2 = makeRelationship("nhl_team", "id", "nhl_game",
"visiting_team_id", true);
+ assertEquals("visitingNhlGames", generator.objRelationshipName(r2));
+
+ // ... and a plain suffix reference gets no qualifier
+ DbRelationship r3 = makeRelationship("nhl_team", "id", "nhl_award",
"team_id", true);
+ assertEquals("nhlAwards", generator.objRelationshipName(r3));
+
+ DbRelationship r4 = makeRelationship("nhl_game_type", "id",
"nhl_game", "type_id", true);
+ assertEquals("nhlGames", generator.objRelationshipName(r4));
+ }
+
+ @Test
+ public void dbRelationshipName_ToMany_RoleQualifiedFk() {
+
+ DbRelationship r1 = makeRelationship("TEAM", "TEAM_ID", "GAME",
"HOME_TEAM_ID", true);
+ assertEquals("homeGames", generator.dbRelationshipName(r1.getJoins(),
r1.isToMany()));
+
+ DbRelationship r2 = makeRelationship("TEAM", "TEAM_ID", "GAME",
"AWAY_TEAM_ID", true);
+ assertEquals("awayGames", generator.dbRelationshipName(r2.getJoins(),
r2.isToMany()));
+ }
+
@Test
public void dbRelationshipName() {
diff --git
a/cayenne-dbsync/src/test/java/org/apache/cayenne/dbsync/naming/PatternObjectNameGeneratorTest.java
b/cayenne-dbsync/src/test/java/org/apache/cayenne/dbsync/naming/PatternObjectNameGeneratorTest.java
index 8139091f2..468c8a86a 100644
---
a/cayenne-dbsync/src/test/java/org/apache/cayenne/dbsync/naming/PatternObjectNameGeneratorTest.java
+++
b/cayenne-dbsync/src/test/java/org/apache/cayenne/dbsync/naming/PatternObjectNameGeneratorTest.java
@@ -18,12 +18,43 @@
*/
package org.apache.cayenne.dbsync.naming;
+import org.apache.cayenne.map.DbEntity;
+import org.apache.cayenne.map.DbJoin;
+import org.apache.cayenne.map.DbRelationship;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class PatternObjectNameGeneratorTest {
+ private DbRelationship makeRelationship(
+ String srcEntity,
+ String srcKey,
+ String targetEntity,
+ String targetKey,
+ boolean toMany) {
+
+ DbRelationship relationship = new DbRelationship();
+ relationship.addJoin(new DbJoin(relationship, srcKey, targetKey));
+ relationship.setToMany(toMany);
+ relationship.setSourceEntity(new DbEntity(srcEntity));
+ relationship.setTargetEntityName(targetEntity);
+
+ return relationship;
+ }
+
+ @Test
+ public void objRelationshipName_ToMany_RoleQualifiedFk() {
+ PatternObjectNameGenerator generator = new
PatternObjectNameGenerator("^AA_");
+
+ // the stripped prefix is transparent to role qualifier matching...
+ DbRelationship r1 = makeRelationship("AA_TEAM", "TEAM_ID", "AA_GAME",
"HOME_TEAM_ID", true);
+ assertEquals("homeGames", generator.objRelationshipName(r1));
+
+ // ... and doesn't itself become a qualifier when the FK column
carries it too
+ DbRelationship r2 = makeRelationship("AA_TEAM", "TEAM_ID", "AA_GAME",
"AA_TEAM_ID", true);
+ assertEquals("games", generator.objRelationshipName(r2));
+ }
@Test
public void dbEntityBaseName_NoMatch() {
diff --git
a/cayenne-maven-plugin/src/test/resources/org/apache/cayenne/tools/dbimport/testOneToOne.map.xml-result
b/cayenne-maven-plugin/src/test/resources/org/apache/cayenne/tools/dbimport/testOneToOne.map.xml-result
index 9d7f01edb..623e058ab 100644
---
a/cayenne-maven-plugin/src/test/resources/org/apache/cayenne/tools/dbimport/testOneToOne.map.xml-result
+++
b/cayenne-maven-plugin/src/test/resources/org/apache/cayenne/tools/dbimport/testOneToOne.map.xml-result
@@ -42,7 +42,7 @@
<db-relationship name="selectedPlayer" source="PICK_SCHEDULE"
target="PLAYER">
<db-attribute-pair source="SELECTED_PLAYER_ID" target="ID"/>
</db-relationship>
- <db-relationship name="pickSchedules" source="PLAYER"
target="PICK_SCHEDULE" toMany="true">
+ <db-relationship name="selectedPickSchedules" source="PLAYER"
target="PICK_SCHEDULE" toMany="true">
<db-attribute-pair source="ID" target="SELECTED_PLAYER_ID"/>
</db-relationship>
<db-relationship name="playerInfo" source="PLAYER" target="PLAYER_INFO"
toDependentPK="true">
@@ -52,7 +52,7 @@
<db-attribute-pair source="PLAYER_ID" target="ID"/>
</db-relationship>
<obj-relationship name="selectedPlayer" source="PickSchedule"
target="Player" deleteRule="Nullify" db-relationship-path="selectedPlayer"/>
- <obj-relationship name="pickSchedules" source="Player"
target="PickSchedule" deleteRule="Deny" db-relationship-path="pickSchedules"/>
+ <obj-relationship name="selectedPickSchedules" source="Player"
target="PickSchedule" deleteRule="Deny"
db-relationship-path="selectedPickSchedules"/>
<obj-relationship name="playerInfo" source="Player" target="PlayerInfo"
deleteRule="Nullify" db-relationship-path="playerInfo"/>
<obj-relationship name="player" source="PlayerInfo" target="Player"
deleteRule="Nullify" db-relationship-path="player"/>
</data-map>