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

commit 5e9b009f0ad9ec02224db9f297c644448fb51d68
Author: Andrus Adamchik <[email protected]>
AuthorDate: Mon Jul 20 15:52:21 2026 +0200

    CAY-2980 Improve model name generation
    
    name relashionships based on FK even when it does not end with _ID
---
 ai-plugin/references/model-naming-conventions.md   | 20 +++++----
 ai-plugin/skills/cayenne-model-naming/SKILL.md     | 13 +++---
 .../dbsync/merge/context/EntityMergeSupport.java   |  3 +-
 .../dbsync/naming/BaseObjectNameGenerator.java     | 21 +++++----
 .../naming/DefaultObjectNameGeneratorTest.java     | 50 ++++++++++++++++++----
 .../tools/DbImporterMojoConfigurationTest.java     |  2 +-
 .../apache/cayenne/tools/config/pom-schema-2.xml   |  2 +-
 7 files changed, 73 insertions(+), 38 deletions(-)

diff --git a/ai-plugin/references/model-naming-conventions.md 
b/ai-plugin/references/model-naming-conventions.md
index 315a4b210..66a19e839 100644
--- a/ai-plugin/references/model-naming-conventions.md
+++ b/ai-plugin/references/model-naming-conventions.md
@@ -40,7 +40,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-one relationship | FK column, minus its trailing `_ID`/`ID` when present; 
target entity name for a compound (multi-column) FK or when there are no joins 
| `MANAGER_ID` → `manager`; `BIRTH_COUNTRY` → `birthCountry` |
 | 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` … |
 
@@ -91,13 +91,15 @@ directions are not equally affected** — the to-one and 
to-many sides are named
   `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`,
-  `AWAY_TEAM_ID` → `awayTeam`). Leave those alone. It **only** collides in the 
fallback path: when a
-  FK column does *not* end in `ID`/`_ID` (or is null / has no joins), the 
generator drops to the
-  target entity name, so two such FKs both become e.g. `employee` / 
`employee1`. There, derive the
-  role from the FK column yourself even though it lacks the `_ID` suffix 
(`MANAGER` → `manager`,
-  `SUPERVISOR` → `supervisor`).
+- **to-one side does NOT collide.** To-one naming is FK-column-based — the 
name is the FK column
+  with a trailing `_ID`/`ID` stripped when present (`HOME_TEAM_ID` → 
`homeTeam`, `BIRTH_COUNTRY` →
+  `birthCountry`), so distinct FK columns yield distinct, good names. Leave 
those alone. The
+  generator falls back to the target entity name only for a compound 
(multi-column) FK — whose
+  column names describe PK components, not a role — or when the relationship 
has no joins at all.
+  Models imported by **older Cayenne versions** used that fallback whenever 
the FK column lacked an
+  `ID`/`_ID` suffix, producing target-entity names and numbered collisions 
(`employee` /
+  `employee1`); when you see those, derive the role from the FK column 
yourself (`MANAGER` →
+  `manager`, `SUPERVISOR` → `supervisor`).
 
 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
@@ -169,7 +171,7 @@ A DbRelationship `name` is **arbitrary** (not derived from 
a real table/column l
 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 (to-one = 
FK column minus a
-trailing `_ID`/`ID`, else target entity name; to-many = English plural of the 
target entity with a
+trailing `_ID`/`ID` when present; 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
diff --git a/ai-plugin/skills/cayenne-model-naming/SKILL.md 
b/ai-plugin/skills/cayenne-model-naming/SKILL.md
index d4a85b2b3..edd50b59d 100644
--- a/ai-plugin/skills/cayenne-model-naming/SKILL.md
+++ b/ai-plugin/skills/cayenne-model-naming/SKILL.md
@@ -79,12 +79,13 @@ correct — leave them.** Flag only the cases the 
deterministic generator can't
    Split on a real word boundary you're confident about; never invent one.
 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`.
+   names are FK-based even without an `_ID` suffix (`HOME_TEAM_ID` → 
`homeTeam`, `BIRTH_COUNTRY` →
+   `birthCountry`), 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 — in models imported by 
older Cayenne versions —
+   to-one ends whose FK columns lack an `_ID` suffix and collapsed 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/merge/context/EntityMergeSupport.java
 
b/cayenne-dbsync/src/main/java/org/apache/cayenne/dbsync/merge/context/EntityMergeSupport.java
index 953672571..305e8e4cf 100644
--- 
a/cayenne-dbsync/src/main/java/org/apache/cayenne/dbsync/merge/context/EntityMergeSupport.java
+++ 
b/cayenne-dbsync/src/main/java/org/apache/cayenne/dbsync/merge/context/EntityMergeSupport.java
@@ -284,8 +284,7 @@ public class EntityMergeSupport {
         }
 
         // Check additional common DB types, like 'json' or 'geometry'
-        if (dbAttribute instanceof DetectedDbAttribute) {
-            DetectedDbAttribute detectedDbAttribute = (DetectedDbAttribute) 
dbAttribute;
+        if (dbAttribute instanceof DetectedDbAttribute detectedDbAttribute) {
             String jdbcTypeName = detectedDbAttribute.getJdbcTypeName();
             if (jdbcTypeName != null) {
                 String type = 
SQL_ADDITIONAL_TYPES_TO_JAVA_TYPE.get(jdbcTypeName.toLowerCase());
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 a818642d6..f1073b261 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
@@ -97,7 +97,8 @@ public abstract class BaseObjectNameGenerator implements 
ObjectNameGenerator {
 
     private String toManyRoleQualifier(List<DbJoin> joins) {
 
-        if (joins.isEmpty()) {
+        // a role is only derivable from a single-column FK; a compound FK's 
columns describe PK components
+        if (joins.size() != 1) {
             return null;
         }
 
@@ -115,7 +116,7 @@ public abstract class BaseObjectNameGenerator implements 
ObjectNameGenerator {
         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")
+        // as FK columns often drop a common table-name prefix ("home_team_id" 
referencing "acme_team")
         String suffix = dbEntityBaseName(sourceEntity.getName()).toUpperCase();
         while (true) {
             if (roleUpper.equals(suffix)) {
@@ -137,24 +138,22 @@ public abstract class BaseObjectNameGenerator implements 
ObjectNameGenerator {
 
     protected String toOneBase(List<DbJoin> joins, String targetEntityName) {
 
-        if (joins.isEmpty()) {
-            // In case, when uses EditRelationship button, relationship 
doesn't exist => it doesn't have joins
-            // and just return targetName
+        // the FK column is a name source only for a single-join relationship: 
with no joins (e.g. the Modeler's
+        // EditRelationship dialog) there is no FK, and a compound FK's column 
names describe PK components,
+        // not the relationship role
+        if (joins.size() != 1) {
             return dbEntityBaseName(targetEntityName);
         }
 
-        DbJoin join1 = joins.getFirst();
-
-        // TODO: multi-join relationships
-
         // return the name of the FK column sans ID
-        String fkColName = join1.getSourceName();
+        String fkColName = joins.getFirst().getSourceName();
         if (fkColName == null) {
             return dbEntityBaseName(targetEntityName);
         }
 
+        // an FK without an ID suffix ("birth_country" referencing "country") 
is still the best name source
         String fkBase = stripIdSuffix(fkColName);
-        return fkBase != null ? fkBase : dbEntityBaseName(targetEntityName);
+        return fkBase != null ? fkBase : fkColName;
     }
 
     private static String stripIdSuffix(String fkColName) {
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 102f6917c..5ec86462e 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
@@ -165,22 +165,56 @@ public class DefaultObjectNameGeneratorTest {
         assertEquals("games", dbRelationshipName(r7));
     }
 
+    @Test
+    public void dbRelationshipName_ToOne_FkWithoutIdSuffix() {
+
+        // an FK without an ID suffix still names the relationship after the 
column, not the target table
+        DbRelationship r1 = makeRelationship("employee", "birth_country", 
"country", "id", false);
+        assertEquals("birthCountry", dbRelationshipName(r1));
+
+        DbRelationship r2 = makeRelationship("PERSON", "SHIPPING_ADDRESS", 
"ADDRESS", "ID", false);
+        assertEquals("shippingAddress", dbRelationshipName(r2));
+
+        // an FK named after the bare target concept keeps the column name
+        DbRelationship r3 = makeRelationship("employee", "country", 
"acme_country", "id", false);
+        assertEquals("country", dbRelationshipName(r3));
+    }
+
     @Test
     public void dbRelationshipName_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", dbRelationshipName(r1));
+        DbRelationship r1 = makeRelationship("acme_team", "id", "acme_game", 
"home_team_id", true);
+        assertEquals("homeAcmeGames", dbRelationshipName(r1));
 
-        DbRelationship r2 = makeRelationship("nhl_team", "id", "nhl_game", 
"visiting_team_id", true);
-        assertEquals("visitingNhlGames", dbRelationshipName(r2));
+        DbRelationship r2 = makeRelationship("acme_team", "id", "acme_game", 
"visiting_team_id", true);
+        assertEquals("visitingAcmeGames", dbRelationshipName(r2));
 
         // ... and a plain suffix reference gets no qualifier
-        DbRelationship r3 = makeRelationship("nhl_team", "id", "nhl_award", 
"team_id", true);
-        assertEquals("nhlAwards", dbRelationshipName(r3));
+        DbRelationship r3 = makeRelationship("acme_team", "id", "acme_award", 
"team_id", true);
+        assertEquals("acmeAwards", dbRelationshipName(r3));
 
-        DbRelationship r4 = makeRelationship("nhl_game_type", "id", 
"nhl_game", "type_id", true);
-        assertEquals("nhlGames", dbRelationshipName(r4));
+        DbRelationship r4 = makeRelationship("acme_game_type", "id", 
"acme_game", "type_id", true);
+        assertEquals("acmeGames", dbRelationshipName(r4));
+    }
+
+    @Test
+    public void dbRelationshipName_MultiJoin() {
+
+        // a compound FK's columns describe PK components, so the to-one side 
is named after the target entity
+        DbRelationship r1 = makeRelationship("shipment", "order_id", 
"order_line", "order_id", false);
+        r1.addJoin(new DbJoin(r1, "line_num", "line_num"));
+        assertEquals("orderLine", dbRelationshipName(r1));
+
+        // ... and the to-many side gets an unqualified plural
+        DbRelationship r2 = makeRelationship("order_line", "order_id", 
"shipment", "order_id", true);
+        r2.addJoin(new DbJoin(r2, "line_num", "line_num"));
+        assertEquals("shipments", dbRelationshipName(r2));
+
+        // no role qualifier even when the first FK column alone would suggest 
one
+        DbRelationship r3 = makeRelationship("team", "team_id", "game", 
"home_team_id", true);
+        r3.addJoin(new DbJoin(r3, "season_id", "season_id"));
+        assertEquals("games", dbRelationshipName(r3));
     }
 
     @Test
diff --git 
a/cayenne-maven-plugin/src/test/java/org/apache/cayenne/tools/DbImporterMojoConfigurationTest.java
 
b/cayenne-maven-plugin/src/test/java/org/apache/cayenne/tools/DbImporterMojoConfigurationTest.java
index 6bd13626d..00a6b1c58 100644
--- 
a/cayenne-maven-plugin/src/test/java/org/apache/cayenne/tools/DbImporterMojoConfigurationTest.java
+++ 
b/cayenne-maven-plugin/src/test/java/org/apache/cayenne/tools/DbImporterMojoConfigurationTest.java
@@ -90,7 +90,7 @@ public class DbImporterMojoConfigurationTest {
         List<Pattern> excludes = new ArrayList<>();
         excludes.add(PatternFilter.pattern("^ETL_.*"));
 
-        assertEquals(filters.tableFilter(null, "NHL_STATS"),
+        assertEquals(filters.tableFilter(null, "GAME_STATS"),
                 new TableFilter(includes, excludes));
     }
 
diff --git 
a/cayenne-maven-plugin/src/test/resources/org/apache/cayenne/tools/config/pom-schema-2.xml
 
b/cayenne-maven-plugin/src/test/resources/org/apache/cayenne/tools/config/pom-schema-2.xml
index ceda9eaad..9ca13e33a 100644
--- 
a/cayenne-maven-plugin/src/test/resources/org/apache/cayenne/tools/config/pom-schema-2.xml
+++ 
b/cayenne-maven-plugin/src/test/resources/org/apache/cayenne/tools/config/pom-schema-2.xml
@@ -28,7 +28,7 @@
                 <configuration>
                     <dbimport>
                         <schema>
-                            <name>NHL_STATS</name>
+                            <name>GAME_STATS</name>
                             <excludeTable>^ETL_.*</excludeTable>
                             <excludeColumn>^ETL_.*</excludeColumn>
                         </schema>

Reply via email to