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 84ad32f69d605a75c3c6f03f2bf429abe3e98a72
Author: Andrus Adamchik <[email protected]>
AuthorDate: Mon Jul 20 15:52:21 2026 +0200

    CAY-2980 Improve model name generation
    
    to-many generated names should attempt to strip shared prefix between 
entity names
---
 ai-plugin/references/model-naming-conventions.md   | 17 ++++-----
 ai-plugin/skills/cayenne-model-naming/SKILL.md     | 14 +++++---
 .../dbsync/naming/BaseObjectNameGenerator.java     | 31 ++++++++++++++++-
 .../naming/DefaultObjectNameGeneratorTest.java     | 40 +++++++++++++++++++---
 .../cayenne/dbsync/reverse/dbload/DbLoaderIT.java  |  3 +-
 5 files changed, 86 insertions(+), 19 deletions(-)

diff --git a/ai-plugin/references/model-naming-conventions.md 
b/ai-plugin/references/model-naming-conventions.md
index 66a19e839..94df7ea39 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 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` |
+| to-many relationship | English plural of the target entity name (minus the 
leading `_`-tokens shared with the source table name), prefixed with the FK 
role qualifier when the FK column embeds the source entity name | `PAINTING` → 
`paintings`; `HOME_TEAM_ID` → `homeGames`; `AA_TEAM` referencing `AA_GAME` → 
`games` |
 | name collision within an entity | append a numeric suffix | `team`, `team1`, 
`team2` … |
 
 Generation also collapses all-upper tokens to lowercase and preserves 
already-mixed
@@ -84,8 +84,8 @@ directions are not equally affected** — the to-one and 
to-many sides are named
 - **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
+  correct, leave them. The FK column may drop a common table prefix: 
`home_team_id` referencing
+  `aa_team` still yields the `home` qualifier. 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
@@ -121,12 +121,13 @@ cleanly *when it's used*: entity names come through 
stripped (`AA_CUSTOMER` →
 no problem — leave that model alone.
 
 The case that needs you is when the prefix is **kept on the entity names** 
(stripping was not
-configured — often intentional, treating the prefix as a class-name 
namespace). Then the prefix
-**leaks into the relationship names**, which you almost never want:
+configured — often intentional, treating the prefix as a class-name 
namespace). The generator keeps
+the prefix out of **to-many** names on its own: the leading `_`-tokens the 
source and target table
+names share are dropped from the pluralized target (`aa_customer` referencing 
`aa_order` →
+`orders`) — those are already clean. What still **leaks**:
 
-- to-many names are the pluralized target entity name; with the prefix kept, 
the target's prefixed
-  name flows straight in → `aaOrders`, `aaCustomers`.
-- to-one names built from a prefixed FK column (`AA_CUSTOMER_ID`) carry it too 
→ `aaCustomer`.
+- to-one names built from a prefixed FK column (`AA_CUSTOMER_ID`) carry it → 
`aaCustomer`.
+- models imported by **older Cayenne versions** kept the prefix in to-many 
names too → `aaOrders`.
 
 A relationship name is a **role/property** on a class 
(`order.getAaCustomer()`), and the shared
 prefix is pure noise there. **Strip the common prefix from the relationship 
names** — `aaOrders` →
diff --git a/ai-plugin/skills/cayenne-model-naming/SKILL.md 
b/ai-plugin/skills/cayenne-model-naming/SKILL.md
index edd50b59d..b86aa44d7 100644
--- a/ai-plugin/skills/cayenne-model-naming/SKILL.md
+++ b/ai-plugin/skills/cayenne-model-naming/SKILL.md
@@ -82,15 +82,19 @@ correct — leave them.** Flag only the cases the 
deterministic generator can't
    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
+   referencing `aa_team` → `homeGames` too). 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`);
-   a relationship is a role/property, and the prefix is noise there. **Leave 
the entity names alone**
-   — the prefix on classes is the user's choice, and renaming entities 
regenerates classes.
+   that was **kept** on the class names (`AaCustomer`, `AaOrder`), the prefix 
can leak into
+   relationship names. The generator already keeps it out of to-many names 
(the leading `_`-tokens
+   shared by the source and target table names are dropped → `orders`, not 
`aaOrders`); what leaks is
+   to-one names built from prefixed FK columns (`AA_CUSTOMER_ID` → 
`aaCustomer`) and to-many names in
+   models imported by older Cayenne versions (`aaOrders`). Strip the prefix 
from those relationship
+   names (`customer`, `orders`); a relationship is a role/property, and the 
prefix is noise there.
+   **Leave the entity names alone** — the prefix on classes is the user's 
choice, and renaming
+   entities regenerates classes.
 4. **Other clear, defensible improvements** — reserved words, lost acronym 
casing, obvious cryptic
    abbreviations applied consistently, plural-table-to-singular-entity. 
Conservative by default; when
    unsure, leave the baseline name and ask.
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 e03e956a8..26d230ade 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
@@ -87,10 +87,39 @@ public abstract class BaseObjectNameGenerator implements 
ObjectNameGenerator {
     }
 
     protected String toManyBase(List<DbJoin> joins, String targetEntityName) {
-        String plural = 
EnglishInflector.pluralOf(dbEntityBaseName(targetEntityName).toLowerCase());
+
+        String targetBase = dbEntityBaseName(targetEntityName);
+
+        DbEntity sourceEntity = joins.isEmpty() ? null : 
joins.getFirst().getRelationship().getSourceEntity();
+        if (sourceEntity != null) {
+            targetBase = 
stripSharedPrefix(dbEntityBaseName(sourceEntity.getName()), targetBase);
+        }
+
+        String plural = EnglishInflector.pluralOf(targetBase.toLowerCase());
         String qualifier = toManyRoleQualifier(joins);
         return qualifier != null ? qualifier + "_" + plural : plural;
     }
+    
+    private static String stripSharedPrefix(String sourceName, String 
targetName) {
+
+        String[] sourceTokens = sourceName.split("_");
+        String[] targetTokens = targetName.split("_");
+
+        // strip every leading "_"-token shared by both names...
+        int maxShared = Math.min(sourceTokens.length, targetTokens.length);
+        int strip = 0;
+        for (int i = 0; i < maxShared && 
sourceTokens[i].equalsIgnoreCase(targetTokens[i]); i++) {
+            int next = strip + targetTokens[i].length() + 1;
+
+            // ... but stop before the base name degenerates to a single 
letter or less
+            if (targetName.length() - next <= 1) {
+                break;
+            }
+            strip = next;
+        }
+
+        return targetName.substring(strip);
+    }
 
     private String toManyRoleQualifier(List<DbJoin> joins) {
 
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 5ec86462e..214573df7 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
@@ -185,17 +185,49 @@ public class DefaultObjectNameGeneratorTest {
 
         // FK columns matching a "_"-token suffix of the entity name still 
carry the role...
         DbRelationship r1 = makeRelationship("acme_team", "id", "acme_game", 
"home_team_id", true);
-        assertEquals("homeAcmeGames", dbRelationshipName(r1));
+        assertEquals("homeGames", dbRelationshipName(r1));
 
         DbRelationship r2 = makeRelationship("acme_team", "id", "acme_game", 
"visiting_team_id", true);
-        assertEquals("visitingAcmeGames", dbRelationshipName(r2));
+        assertEquals("visitingGames", dbRelationshipName(r2));
 
         // ... and a plain suffix reference gets no qualifier
         DbRelationship r3 = makeRelationship("acme_team", "id", "acme_award", 
"team_id", true);
-        assertEquals("acmeAwards", dbRelationshipName(r3));
+        assertEquals("awards", dbRelationshipName(r3));
 
         DbRelationship r4 = makeRelationship("acme_game_type", "id", 
"acme_game", "type_id", true);
-        assertEquals("acmeGames", dbRelationshipName(r4));
+        assertEquals("games", dbRelationshipName(r4));
+    }
+
+    @Test
+    public void dbRelationshipName_ToMany_SharedTablePrefix() {
+
+        // a leading "_"-token shared by both table names is schema 
vocabulary, not a part of the role,
+        // and is stripped to mirror the FK-based to-one names that never 
carry it
+        DbRelationship r1 = makeRelationship("acme_team", "id", "acme_game", 
"team_id", true);
+        assertEquals("games", dbRelationshipName(r1));
+
+        DbRelationship r2 = makeRelationship("acme_country", "id", 
"acme_draft_pick", "drafted_by_country_id", true);
+        assertEquals("draftedByDraftPicks", dbRelationshipName(r2));
+
+        // all shared tokens are stripped, including a source name that is a 
full token-prefix of the target
+        DbRelationship r3 = makeRelationship("acme_playoff_series", "id", 
"acme_playoff_series_game",
+                "playoff_series_id", true);
+        assertEquals("games", dbRelationshipName(r3));
+
+        DbRelationship r4 = makeRelationship("aa_bb_team", "id", "aa_bb_game", 
"team_id", true);
+        assertEquals("games", dbRelationshipName(r4));
+
+        // a prefix on the target alone is kept...
+        DbRelationship r5 = makeRelationship("team", "id", "acme_game", 
"team_id", true);
+        assertEquals("acmeGames", dbRelationshipName(r5));
+
+        // ... as are differing prefixes
+        DbRelationship r6 = makeRelationship("aa_team", "id", "bb_game", 
"team_id", true);
+        assertEquals("bbGames", dbRelationshipName(r6));
+
+        // stripping backs off when the base name would shrink to a single 
letter
+        DbRelationship r7 = makeRelationship("acme_team", "id", "acme_a", 
"team_id", true);
+        assertEquals("acmeAs", dbRelationshipName(r7));
     }
 
     @Test
diff --git 
a/cayenne-dbsync/src/test/java/org/apache/cayenne/dbsync/reverse/dbload/DbLoaderIT.java
 
b/cayenne-dbsync/src/test/java/org/apache/cayenne/dbsync/reverse/dbload/DbLoaderIT.java
index d73c0fb5b..e30b0e73e 100644
--- 
a/cayenne-dbsync/src/test/java/org/apache/cayenne/dbsync/reverse/dbload/DbLoaderIT.java
+++ 
b/cayenne-dbsync/src/test/java/org/apache/cayenne/dbsync/reverse/dbload/DbLoaderIT.java
@@ -117,7 +117,8 @@ public class DbLoaderIT {
         // DbRelationship
         assertEquals(5, artist.getRelationships().size());
 
-        DbRelationship exhibits = artist.getRelationship("artistExhibits");
+        // the "artist" prefix shared with the source table is stripped from 
the relationship name
+        DbRelationship exhibits = artist.getRelationship("exhibits");
         assertNotNull(exhibits);
         assertEquals("ARTIST_EXHIBIT", 
exhibits.getTargetEntityName().toUpperCase());
         DbEntity target = exhibits.getTargetEntity();

Reply via email to