Skip to content

Commit bdcc4b8

Browse files
Better relationship naming (#453)
* Better relationship naming * Better changes descriptions * Better changes descriptions * Reformatted the changelog entry Updated version header and improved formatting for clarity. * Update src/sqlacodegen/generators.py Co-authored-by: Alex Grönholm <alex.gronholm@nextday.fi> * Support Multiple MANY2MANY * Support Multiple MANY2MANY - Attempt #2 * Support Multiple MANY2MANY - Attempt #3 * Code cleanup * Properly support multiple M2M relationship * Remove comments * Some tidying up --------- Co-authored-by: Alex Grönholm <alex.gronholm@nextday.fi>
1 parent b97ce66 commit bdcc4b8

4 files changed

Lines changed: 559 additions & 21 deletions

File tree

CHANGES.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
Version history
22
===============
33

4+
**UNRELEASED**
5+
6+
- **BACKWARD INCOMPATIBLE** Relationship names changed when multiple FKs or junction tables
7+
connect to the same target table. Regenerating models will break existing code.
8+
- Improved relationship naming: one-to-many uses FK column names (e.g.,
9+
``simple_items_parent_container``), many-to-many uses junction table names (e.g.,
10+
``students_enrollments``). Use ``--options nofknames`` to revert to old behavior.
11+
412
**4.0.0rc2**
513

614
- Add ``values_callable`` lambda to generated native enums column definitions.

README.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ values must be delimited by commas, e.g. ``--options noconstraints,nobidi``):
122122
* ``nobidi``: generate relationships in a unidirectional fashion, so only the
123123
many-to-one or first side of many-to-many relationships gets a relationship
124124
attribute, as on v2.X
125+
* ``nofknames``: disable improved relationship naming when multiple FKs or
126+
junction tables connect to the same target. By default, uses FK column names
127+
for one-to-many (e.g., ``simple_items_parent_container``) and junction table
128+
names for many-to-many (e.g., ``students_enrollments``). Reverts to
129+
underscore suffixes (``simple_items_``, ``student_``).
125130

126131
* ``dataclasses``
127132

@@ -189,6 +194,14 @@ due to that ``_id`` suffix.
189194
For self referential relationships, the reverse side of the relationship will be named
190195
with the ``_reverse`` suffix appended to it.
191196

197+
When multiple foreign keys or junction tables connect to the same target table,
198+
relationships use qualifiers for disambiguation. One-to-many relationships use FK
199+
column names (e.g., ``simple_items_parent_container``, ``simple_items_top_container``).
200+
Many-to-many relationships use junction table names (e.g., ``students_enrollments``,
201+
``students_waitlist``), except for self-referential cases which use FK column names
202+
(e.g., ``parent``, ``child``). The ``nofknames`` option reverts to underscore suffixes
203+
(``simple_items_``, ``student_``).
204+
192205
Customizing code generation logic
193206
=================================
194207

src/sqlacodegen/generators.py

Lines changed: 128 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -969,6 +969,7 @@ class DeclarativeGenerator(TablesGenerator):
969969
"nojoined",
970970
"nobidi",
971971
"noidsuffix",
972+
"nofknames",
972973
}
973974

974975
def __init__(
@@ -1290,43 +1291,149 @@ def generate_relationship_name(
12901291
global_names: set[str],
12911292
local_names: set[str],
12921293
) -> None:
1293-
# Self referential reverse relationships
1294-
preferred_name: str
1295-
if (
1296-
relationship.type
1297-
in (RelationshipType.ONE_TO_MANY, RelationshipType.ONE_TO_ONE)
1298-
and relationship.source is relationship.target
1299-
and relationship.backref
1300-
and relationship.backref.name
1301-
):
1302-
preferred_name = relationship.backref.name + "_reverse"
1303-
else:
1304-
preferred_name = relationship.target.table.name
1294+
def strip_id_suffix(name: str) -> str:
1295+
# Strip _id only if at the end or followed by underscore (e.g., "course_id" -> "course", "course_id_1" -> "course_1")
1296+
# But don't strip from "parent_id1" (where id is followed by a digit without underscore)
1297+
return re.sub(r"_id(?=_|$)", "", name)
1298+
1299+
def get_m2m_qualified_name(default_name: str) -> str:
1300+
"""Generate qualified name for many-to-many relationship when multiple junction tables exist."""
1301+
# Check if there are multiple M2M relationships to the same target
1302+
target_m2m_relationships = [
1303+
r
1304+
for r in relationship.source.relationships
1305+
if r.target is relationship.target
1306+
and r.type == RelationshipType.MANY_TO_MANY
1307+
]
1308+
1309+
# Only use junction-based naming when there are multiple M2M to same target
1310+
if len(target_m2m_relationships) > 1:
1311+
if relationship.source is relationship.target:
1312+
# Self-referential: use FK column name from junction table
1313+
# (e.g., "parent_id" -> "parent", "child_id" -> "child")
1314+
if relationship.constraint:
1315+
column_names = [c.name for c in relationship.constraint.columns]
1316+
if len(column_names) == 1:
1317+
fk_qualifier = strip_id_suffix(column_names[0])
1318+
else:
1319+
fk_qualifier = "_".join(
1320+
strip_id_suffix(col_name) for col_name in column_names
1321+
)
1322+
return fk_qualifier
1323+
elif relationship.association_table:
1324+
# Normal: use junction table name as qualifier
1325+
junction_name = relationship.association_table.table.name
1326+
fk_qualifier = strip_id_suffix(junction_name)
1327+
return f"{relationship.target.table.name}_{fk_qualifier}"
1328+
else:
1329+
# Single M2M: use simple name from junction table FK column
1330+
# (e.g., "right_id" -> "right" instead of "right_table")
1331+
if relationship.constraint and "noidsuffix" not in self.options:
1332+
column_names = [c.name for c in relationship.constraint.columns]
1333+
if len(column_names) == 1:
1334+
stripped_name = strip_id_suffix(column_names[0])
1335+
if stripped_name != column_names[0]:
1336+
return stripped_name
1337+
1338+
return default_name
1339+
1340+
def get_fk_qualified_name(constraint: ForeignKeyConstraint) -> str:
1341+
"""Generate qualified name for one-to-many/one-to-one relationship using FK column names."""
1342+
column_names = [c.name for c in constraint.columns]
1343+
1344+
if len(column_names) == 1:
1345+
# Single column FK: strip _id suffix if present
1346+
fk_qualifier = strip_id_suffix(column_names[0])
1347+
else:
1348+
# Multi-column FK: concatenate all column names (strip _id from each)
1349+
fk_qualifier = "_".join(
1350+
strip_id_suffix(col_name) for col_name in column_names
1351+
)
13051352

1306-
# If there's a constraint with a single column that ends with "_id", use the
1307-
# preceding part as the relationship name
1308-
if relationship.constraint and "noidsuffix" not in self.options:
1353+
# For self-referential relationships, don't prepend the table name
1354+
if relationship.source is relationship.target:
1355+
return fk_qualifier
1356+
else:
1357+
return f"{relationship.target.table.name}_{fk_qualifier}"
1358+
1359+
def resolve_preferred_name() -> str:
1360+
resolved_name = relationship.target.table.name
1361+
1362+
# For reverse relationships with multiple FKs to the same table, use the FK
1363+
# column name to create a more descriptive relationship name
1364+
# For M2M relationships with multiple junction tables, use the junction table name
1365+
use_fk_based_naming = "nofknames" not in self.options and (
1366+
(
1367+
relationship.constraint
1368+
and relationship.type
1369+
in (RelationshipType.ONE_TO_MANY, RelationshipType.ONE_TO_ONE)
1370+
and relationship.foreign_keys
1371+
)
1372+
or (
1373+
relationship.type == RelationshipType.MANY_TO_MANY
1374+
and relationship.association_table
1375+
)
1376+
)
1377+
1378+
if use_fk_based_naming:
1379+
if relationship.type == RelationshipType.MANY_TO_MANY:
1380+
resolved_name = get_m2m_qualified_name(resolved_name)
1381+
elif relationship.constraint:
1382+
resolved_name = get_fk_qualified_name(relationship.constraint)
1383+
1384+
# If there's a constraint with a single column that contains "_id", use the
1385+
# stripped version as the relationship name
1386+
elif relationship.constraint and "noidsuffix" not in self.options:
13091387
is_source = relationship.source.table is relationship.constraint.table
13101388
if is_source or relationship.type not in (
13111389
RelationshipType.ONE_TO_ONE,
13121390
RelationshipType.ONE_TO_MANY,
13131391
):
13141392
column_names = [c.name for c in relationship.constraint.columns]
1315-
if len(column_names) == 1 and column_names[0].endswith("_id"):
1316-
preferred_name = column_names[0][:-3]
1393+
if len(column_names) == 1:
1394+
stripped_name = strip_id_suffix(column_names[0])
1395+
# Only use the stripped name if it actually changed (had _id in it)
1396+
if stripped_name != column_names[0]:
1397+
resolved_name = stripped_name
1398+
else:
1399+
# For composite FKs, check if there are multiple FKs to the same target
1400+
target_relationships = [
1401+
r
1402+
for r in relationship.source.relationships
1403+
if r.target is relationship.target
1404+
and r.type == relationship.type
1405+
]
1406+
if len(target_relationships) > 1:
1407+
# Multiple FKs to same table - use concatenated column names
1408+
resolved_name = "_".join(
1409+
strip_id_suffix(col_name) for col_name in column_names
1410+
)
13171411

13181412
if "use_inflect" in self.options:
13191413
inflected_name: str | Literal[False]
13201414
if relationship.type in (
13211415
RelationshipType.ONE_TO_MANY,
13221416
RelationshipType.MANY_TO_MANY,
13231417
):
1324-
if not self.inflect_engine.singular_noun(preferred_name):
1325-
preferred_name = self.inflect_engine.plural_noun(preferred_name)
1418+
if not self.inflect_engine.singular_noun(resolved_name):
1419+
resolved_name = self.inflect_engine.plural_noun(resolved_name)
13261420
else:
1327-
inflected_name = self.inflect_engine.singular_noun(preferred_name)
1421+
inflected_name = self.inflect_engine.singular_noun(resolved_name)
13281422
if inflected_name:
1329-
preferred_name = inflected_name
1423+
resolved_name = inflected_name
1424+
1425+
return resolved_name
1426+
1427+
if (
1428+
relationship.type
1429+
in (RelationshipType.ONE_TO_MANY, RelationshipType.ONE_TO_ONE)
1430+
and relationship.source is relationship.target
1431+
and relationship.backref
1432+
and relationship.backref.name
1433+
):
1434+
preferred_name = relationship.backref.name + "_reverse"
1435+
else:
1436+
preferred_name = resolve_preferred_name()
13301437

13311438
relationship.name = self.find_free_name(
13321439
preferred_name, global_names, local_names

0 commit comments

Comments
 (0)