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>

Reply via email to