sqlacodegen 4.0.0rc2__tar.gz → 4.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/CHANGES.rst +40 -2
  2. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/PKG-INFO +14 -1
  3. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/README.rst +13 -0
  4. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/src/sqlacodegen/generators.py +248 -110
  5. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/src/sqlacodegen.egg-info/PKG-INFO +14 -1
  6. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/tests/test_generator_declarative.py +775 -18
  7. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/tests/test_generator_sqlmodel.py +99 -2
  8. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/tests/test_generator_tables.py +156 -4
  9. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/.github/FUNDING.yml +0 -0
  10. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/.github/ISSUE_TEMPLATE/bug_report.yaml +0 -0
  11. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  12. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/.github/ISSUE_TEMPLATE/features_request.yaml +0 -0
  13. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/.github/dependabot.yml +0 -0
  14. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/.github/pull_request_template.md +0 -0
  15. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/.github/workflows/publish.yml +0 -0
  16. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/.github/workflows/test.yml +0 -0
  17. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/.gitignore +0 -0
  18. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/.pre-commit-config.yaml +0 -0
  19. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/CONTRIBUTING.rst +0 -0
  20. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/LICENSE +0 -0
  21. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/pyproject.toml +0 -0
  22. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/setup.cfg +0 -0
  23. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/src/sqlacodegen/__init__.py +0 -0
  24. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/src/sqlacodegen/__main__.py +0 -0
  25. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/src/sqlacodegen/cli.py +0 -0
  26. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/src/sqlacodegen/models.py +0 -0
  27. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/src/sqlacodegen/py.typed +0 -0
  28. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/src/sqlacodegen/utils.py +0 -0
  29. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/src/sqlacodegen.egg-info/SOURCES.txt +0 -0
  30. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/src/sqlacodegen.egg-info/dependency_links.txt +0 -0
  31. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/src/sqlacodegen.egg-info/entry_points.txt +0 -0
  32. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/src/sqlacodegen.egg-info/requires.txt +0 -0
  33. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/src/sqlacodegen.egg-info/top_level.txt +0 -0
  34. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/tests/__init__.py +0 -0
  35. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/tests/conftest.py +0 -0
  36. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/tests/test_cli.py +0 -0
  37. {sqlacodegen-4.0.0rc2 → sqlacodegen-4.0.1}/tests/test_generator_dataclass.py +0 -0
@@ -1,10 +1,48 @@
1
1
  Version history
2
2
  ===============
3
3
 
4
+ **4.0.1**
5
+
6
+ - Fix enum column definitions to explicitly include schema and name if reflected
7
+ via SQLAlchemy's Metadata (pr by @sheinbergon)
8
+
9
+ **4.0.0**
10
+
11
+ - **BACKWARD INCOMPATIBLE** API changes (for those who customize code generation by
12
+ subclassing the existing generators):
13
+
14
+ * Added new optional keyword argument, ``explicit_foreign_keys`` to
15
+ ``DeclarativeGenerator``, to force foreign keys to be rendered as
16
+ ``ClassName.attribute_name`` string references
17
+ * Removed the ``render_relationship_args()`` method from the SQLModel generator
18
+ * Added two new methods for customizing relationship rendering in
19
+ ``DeclarativeGenerator``:
20
+
21
+ * ``render_relationship_annotation()``: returns the appropriate type annotation
22
+ (without the ``Mapped`` wrapper) for the relationship
23
+ * ``render_relationship_arguments()``: returns a dictionary of keyword arguments to
24
+ ``sqlalchemy.orm.relationship()``
25
+
26
+ **4.0.0rc3**
27
+
28
+ - **BACKWARD INCOMPATIBLE** Relationship names changed when multiple FKs or junction tables
29
+ connect to the same target table. Regenerating models will break existing code.
30
+ - Added support for generating Python enum classes for ``ARRAY(Enum(...))`` columns
31
+ (e.g., PostgreSQL ``ARRAY(ENUM)``). Supports named/unnamed enums, shared enums across
32
+ columns, and multi-dimensional arrays. Respects ``--options nonativeenums``.
33
+ (PR by @sheinbergon)
34
+ - Improved relationship naming: one-to-many uses FK column names (e.g.,
35
+ ``simple_items_parent_container``), many-to-many uses junction table names (e.g.,
36
+ ``students_enrollments``). Use ``--options nofknames`` to revert to old behavior. (PR by @sheinbergon)
37
+ - Fixed ``Index`` kwargs (e.g. ``mysql_length``) being ignored during code generation
38
+ (PR by @luliangce)
39
+ - Fixed the SQLModel generator not adding the ``foreign_keys`` parameters when
40
+ generating multiple relationships between the same two tables
41
+
4
42
  **4.0.0rc2**
5
43
 
6
- - Add ``values_callable`` lambda to generated native enums column definitions.
7
- This allows for proper enum value insertion when working with ORM models (PR by @sheinbergon)
44
+ - Add ``values_callable`` lambda to generated native enums column definitions.
45
+ This allows for proper enum value insertion when working with ORM models (PR by @sheinbergon)
8
46
 
9
47
  **4.0.0rc1**
10
48
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlacodegen
3
- Version: 4.0.0rc2
3
+ Version: 4.0.1
4
4
  Summary: Automatic model code generator for SQLAlchemy
5
5
  Author-email: Alex Grönholm <alex.gronholm@nextday.fi>
6
6
  Maintainer-email: Idan Sheinberg <ishinberg0@gmail.com>
@@ -159,6 +159,11 @@ values must be delimited by commas, e.g. ``--options noconstraints,nobidi``):
159
159
  * ``nobidi``: generate relationships in a unidirectional fashion, so only the
160
160
  many-to-one or first side of many-to-many relationships gets a relationship
161
161
  attribute, as on v2.X
162
+ * ``nofknames``: disable improved relationship naming when multiple FKs or
163
+ junction tables connect to the same target. By default, uses FK column names
164
+ for one-to-many (e.g., ``simple_items_parent_container``) and junction table
165
+ names for many-to-many (e.g., ``students_enrollments``). Reverts to
166
+ underscore suffixes (``simple_items_``, ``student_``).
162
167
 
163
168
  * ``dataclasses``
164
169
 
@@ -226,6 +231,14 @@ due to that ``_id`` suffix.
226
231
  For self referential relationships, the reverse side of the relationship will be named
227
232
  with the ``_reverse`` suffix appended to it.
228
233
 
234
+ When multiple foreign keys or junction tables connect to the same target table,
235
+ relationships use qualifiers for disambiguation. One-to-many relationships use FK
236
+ column names (e.g., ``simple_items_parent_container``, ``simple_items_top_container``).
237
+ Many-to-many relationships use junction table names (e.g., ``students_enrollments``,
238
+ ``students_waitlist``), except for self-referential cases which use FK column names
239
+ (e.g., ``parent``, ``child``). The ``nofknames`` option reverts to underscore suffixes
240
+ (``simple_items_``, ``student_``).
241
+
229
242
  Customizing code generation logic
230
243
  =================================
231
244
 
@@ -122,6 +122,11 @@ values must be delimited by commas, e.g. ``--options noconstraints,nobidi``):
122
122
  * ``nobidi``: generate relationships in a unidirectional fashion, so only the
123
123
  many-to-one or first side of many-to-many relationships gets a relationship
124
124
  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_``).
125
130
 
126
131
  * ``dataclasses``
127
132
 
@@ -189,6 +194,14 @@ due to that ``_id`` suffix.
189
194
  For self referential relationships, the reverse side of the relationship will be named
190
195
  with the ``_reverse`` suffix appended to it.
191
196
 
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
+
192
205
  Customizing code generation logic
193
206
  =================================
194
207
 
@@ -5,7 +5,7 @@ import re
5
5
  import sys
6
6
  from abc import ABCMeta, abstractmethod
7
7
  from collections import defaultdict
8
- from collections.abc import Collection, Iterable, Sequence
8
+ from collections.abc import Collection, Iterable, Mapping, Sequence
9
9
  from dataclasses import dataclass
10
10
  from importlib import import_module
11
11
  from inspect import Parameter
@@ -424,7 +424,10 @@ class TablesGenerator(CodeGenerator):
424
424
 
425
425
  def render_index(self, index: Index) -> str:
426
426
  extra_args = [repr(col.name) for col in index.columns]
427
- kwargs = {}
427
+ kwargs = {
428
+ key: repr(value) if isinstance(value, str) else value
429
+ for key, value in sorted(index.kwargs.items(), key=lambda item: item[0])
430
+ }
428
431
  if index.unique:
429
432
  kwargs["unique"] = True
430
433
 
@@ -547,10 +550,38 @@ class TablesGenerator(CodeGenerator):
547
550
  ):
548
551
  # Import SQLAlchemy Enum (will be handled in collect_imports)
549
552
  self.add_import(Enum)
550
- return f"Enum({enum_class_name}, values_callable=lambda cls: [member.value for member in cls])"
553
+ extra_kwargs = ""
554
+ if column_type.name is not None:
555
+ extra_kwargs += f", name={column_type.name!r}"
556
+
557
+ if column_type.schema is not None:
558
+ extra_kwargs += f", schema={column_type.schema!r}"
559
+
560
+ return f"Enum({enum_class_name}, values_callable=lambda cls: [member.value for member in cls]{extra_kwargs})"
551
561
 
552
562
  args = []
553
563
  kwargs: dict[str, Any] = {}
564
+
565
+ # Check if this is an ARRAY column with an Enum item type mapped to a Python enum class
566
+ if isinstance(column_type, ARRAY) and isinstance(column_type.item_type, Enum):
567
+ if enum_class_name := self.enum_classes.get(
568
+ (column.table.name, column.name)
569
+ ):
570
+ self.add_import(ARRAY)
571
+ self.add_import(Enum)
572
+ extra_kwargs = ""
573
+ if column_type.item_type.name is not None:
574
+ extra_kwargs += f", name={column_type.item_type.name!r}"
575
+
576
+ if column_type.item_type.schema is not None:
577
+ extra_kwargs += f", schema={column_type.item_type.schema!r}"
578
+
579
+ rendered_enum = f"Enum({enum_class_name}, values_callable=lambda cls: [member.value for member in cls]{extra_kwargs})"
580
+ if column_type.dimensions is not None:
581
+ kwargs["dimensions"] = repr(column_type.dimensions)
582
+
583
+ return render_callable("ARRAY", rendered_enum, kwargs=kwargs)
584
+
554
585
  sig = inspect.signature(column_type.__class__.__init__)
555
586
  defaults = {param.name: param.default for param in sig.parameters.values()}
556
587
  missing = object()
@@ -806,6 +837,31 @@ class TablesGenerator(CodeGenerator):
806
837
 
807
838
  def fix_column_types(self, table: Table) -> None:
808
839
  """Adjust the reflected column types."""
840
+
841
+ def fix_enum_column(col_name: str, enum_type: Enum) -> None:
842
+ if (table.name, col_name) in self.enum_classes:
843
+ return
844
+
845
+ if enum_type.name:
846
+ existing_class = None
847
+ for (_, _), cls in self.enum_classes.items():
848
+ if cls == self._enum_name_to_class_name(enum_type.name):
849
+ existing_class = cls
850
+ break
851
+
852
+ if existing_class:
853
+ enum_class_name = existing_class
854
+ else:
855
+ enum_class_name = self._enum_name_to_class_name(enum_type.name)
856
+ if enum_class_name not in self.enum_values:
857
+ self.enum_values[enum_class_name] = list(enum_type.enums)
858
+ else:
859
+ enum_class_name = self._create_enum_class(
860
+ table.name, col_name, list(enum_type.enums)
861
+ )
862
+
863
+ self.enum_classes[(table.name, col_name)] = enum_class_name
864
+
809
865
  # Detect check constraints for boolean and enum columns
810
866
  for constraint in table.constraints.copy():
811
867
  if isinstance(constraint, CheckConstraint):
@@ -849,37 +905,16 @@ class TablesGenerator(CodeGenerator):
849
905
  and isinstance(column.type, Enum)
850
906
  and column.type.enums
851
907
  ):
852
- if column.type.name:
853
- # Named enum - create shared enum class if not already created
854
- if (table.name, column.name) not in self.enum_classes:
855
- # Check if we've already created an enum for this name
856
- existing_class = None
857
- for (t, c), cls in self.enum_classes.items():
858
- if cls == self._enum_name_to_class_name(column.type.name):
859
- existing_class = cls
860
- break
861
-
862
- if existing_class:
863
- enum_class_name = existing_class
864
- else:
865
- # Create new enum class from the enum's name
866
- enum_class_name = self._enum_name_to_class_name(
867
- column.type.name
868
- )
869
- # Register the enum values if not already registered
870
- if enum_class_name not in self.enum_values:
871
- self.enum_values[enum_class_name] = list(
872
- column.type.enums
873
- )
908
+ fix_enum_column(column.name, column.type)
874
909
 
875
- self.enum_classes[(table.name, column.name)] = enum_class_name
876
- else:
877
- # Unnamed enum - create enum class per column
878
- if (table.name, column.name) not in self.enum_classes:
879
- enum_class_name = self._create_enum_class(
880
- table.name, column.name, list(column.type.enums)
881
- )
882
- self.enum_classes[(table.name, column.name)] = enum_class_name
910
+ # Handle ARRAY columns with Enum item types (e.g., PostgreSQL ARRAY(ENUM))
911
+ elif (
912
+ "nonativeenums" not in self.options
913
+ and isinstance(column.type, ARRAY)
914
+ and isinstance(column.type.item_type, Enum)
915
+ and column.type.item_type.enums
916
+ ):
917
+ fix_enum_column(column.name, column.type.item_type)
883
918
 
884
919
  if not self.keep_dialect_types:
885
920
  try:
@@ -969,6 +1004,7 @@ class DeclarativeGenerator(TablesGenerator):
969
1004
  "nojoined",
970
1005
  "nobidi",
971
1006
  "noidsuffix",
1007
+ "nofknames",
972
1008
  }
973
1009
 
974
1010
  def __init__(
@@ -979,10 +1015,12 @@ class DeclarativeGenerator(TablesGenerator):
979
1015
  *,
980
1016
  indentation: str = " ",
981
1017
  base_class_name: str = "Base",
1018
+ explicit_foreign_keys: bool = False,
982
1019
  ):
983
1020
  super().__init__(metadata, bind, options, indentation=indentation)
984
1021
  self.base_class_name: str = base_class_name
985
1022
  self.inflect_engine = inflect.engine()
1023
+ self.explicit_foreign_keys = explicit_foreign_keys
986
1024
 
987
1025
  def generate_base(self) -> None:
988
1026
  self.base = Base(
@@ -1290,30 +1328,123 @@ class DeclarativeGenerator(TablesGenerator):
1290
1328
  global_names: set[str],
1291
1329
  local_names: set[str],
1292
1330
  ) -> 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
1331
+ def strip_id_suffix(name: str) -> str:
1332
+ # Strip _id only if at the end or followed by underscore (e.g., "course_id" -> "course", "course_id_1" -> "course_1")
1333
+ # But don't strip from "parent_id1" (where id is followed by a digit without underscore)
1334
+ return re.sub(r"_id(?=_|$)", "", name)
1335
+
1336
+ def get_m2m_qualified_name(default_name: str) -> str:
1337
+ """Generate qualified name for many-to-many relationship when multiple junction tables exist."""
1338
+ # Check if there are multiple M2M relationships to the same target
1339
+ target_m2m_relationships = [
1340
+ r
1341
+ for r in relationship.source.relationships
1342
+ if r.target is relationship.target
1343
+ and r.type == RelationshipType.MANY_TO_MANY
1344
+ ]
1345
+
1346
+ # Only use junction-based naming when there are multiple M2M to same target
1347
+ if len(target_m2m_relationships) > 1:
1348
+ if relationship.source is relationship.target:
1349
+ # Self-referential: use FK column name from junction table
1350
+ # (e.g., "parent_id" -> "parent", "child_id" -> "child")
1351
+ if relationship.constraint:
1352
+ column_names = [c.name for c in relationship.constraint.columns]
1353
+ if len(column_names) == 1:
1354
+ fk_qualifier = strip_id_suffix(column_names[0])
1355
+ else:
1356
+ fk_qualifier = "_".join(
1357
+ strip_id_suffix(col_name) for col_name in column_names
1358
+ )
1359
+ return fk_qualifier
1360
+ elif relationship.association_table:
1361
+ # Normal: use junction table name as qualifier
1362
+ junction_name = relationship.association_table.table.name
1363
+ fk_qualifier = strip_id_suffix(junction_name)
1364
+ return f"{relationship.target.table.name}_{fk_qualifier}"
1365
+ else:
1366
+ # Single M2M: use simple name from junction table FK column
1367
+ # (e.g., "right_id" -> "right" instead of "right_table")
1368
+ if relationship.constraint and "noidsuffix" not in self.options:
1369
+ column_names = [c.name for c in relationship.constraint.columns]
1370
+ if len(column_names) == 1:
1371
+ stripped_name = strip_id_suffix(column_names[0])
1372
+ if stripped_name != column_names[0]:
1373
+ return stripped_name
1374
+
1375
+ return default_name
1376
+
1377
+ def get_fk_qualified_name(constraint: ForeignKeyConstraint) -> str:
1378
+ """Generate qualified name for one-to-many/one-to-one relationship using FK column names."""
1379
+ column_names = [c.name for c in constraint.columns]
1380
+
1381
+ if len(column_names) == 1:
1382
+ # Single column FK: strip _id suffix if present
1383
+ fk_qualifier = strip_id_suffix(column_names[0])
1384
+ else:
1385
+ # Multi-column FK: concatenate all column names (strip _id from each)
1386
+ fk_qualifier = "_".join(
1387
+ strip_id_suffix(col_name) for col_name in column_names
1388
+ )
1305
1389
 
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:
1390
+ # For self-referential relationships, don't prepend the table name
1391
+ if relationship.source is relationship.target:
1392
+ return fk_qualifier
1393
+ else:
1394
+ return f"{relationship.target.table.name}_{fk_qualifier}"
1395
+
1396
+ def resolve_preferred_name() -> str:
1397
+ resolved_name = relationship.target.table.name
1398
+
1399
+ # For reverse relationships with multiple FKs to the same table, use the FK
1400
+ # column name to create a more descriptive relationship name
1401
+ # For M2M relationships with multiple junction tables, use the junction table name
1402
+ use_fk_based_naming = "nofknames" not in self.options and (
1403
+ (
1404
+ relationship.constraint
1405
+ and relationship.type
1406
+ in (RelationshipType.ONE_TO_MANY, RelationshipType.ONE_TO_ONE)
1407
+ and relationship.foreign_keys
1408
+ )
1409
+ or (
1410
+ relationship.type == RelationshipType.MANY_TO_MANY
1411
+ and relationship.association_table
1412
+ )
1413
+ )
1414
+
1415
+ if use_fk_based_naming:
1416
+ if relationship.type == RelationshipType.MANY_TO_MANY:
1417
+ resolved_name = get_m2m_qualified_name(resolved_name)
1418
+ elif relationship.constraint:
1419
+ resolved_name = get_fk_qualified_name(relationship.constraint)
1420
+
1421
+ # If there's a constraint with a single column that contains "_id", use the
1422
+ # stripped version as the relationship name
1423
+ elif relationship.constraint and "noidsuffix" not in self.options:
1309
1424
  is_source = relationship.source.table is relationship.constraint.table
1310
1425
  if is_source or relationship.type not in (
1311
1426
  RelationshipType.ONE_TO_ONE,
1312
1427
  RelationshipType.ONE_TO_MANY,
1313
1428
  ):
1314
1429
  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]
1430
+ if len(column_names) == 1:
1431
+ stripped_name = strip_id_suffix(column_names[0])
1432
+ # Only use the stripped name if it actually changed (had _id in it)
1433
+ if stripped_name != column_names[0]:
1434
+ resolved_name = stripped_name
1435
+ else:
1436
+ # For composite FKs, check if there are multiple FKs to the same target
1437
+ target_relationships = [
1438
+ r
1439
+ for r in relationship.source.relationships
1440
+ if r.target is relationship.target
1441
+ and r.type == relationship.type
1442
+ ]
1443
+ if len(target_relationships) > 1:
1444
+ # Multiple FKs to same table - use concatenated column names
1445
+ resolved_name = "_".join(
1446
+ strip_id_suffix(col_name) for col_name in column_names
1447
+ )
1317
1448
 
1318
1449
  if "use_inflect" in self.options:
1319
1450
  inflected_name: str | Literal[False]
@@ -1321,12 +1452,25 @@ class DeclarativeGenerator(TablesGenerator):
1321
1452
  RelationshipType.ONE_TO_MANY,
1322
1453
  RelationshipType.MANY_TO_MANY,
1323
1454
  ):
1324
- if not self.inflect_engine.singular_noun(preferred_name):
1325
- preferred_name = self.inflect_engine.plural_noun(preferred_name)
1455
+ if not self.inflect_engine.singular_noun(resolved_name):
1456
+ resolved_name = self.inflect_engine.plural_noun(resolved_name)
1326
1457
  else:
1327
- inflected_name = self.inflect_engine.singular_noun(preferred_name)
1458
+ inflected_name = self.inflect_engine.singular_noun(resolved_name)
1328
1459
  if inflected_name:
1329
- preferred_name = inflected_name
1460
+ resolved_name = inflected_name
1461
+
1462
+ return resolved_name
1463
+
1464
+ if (
1465
+ relationship.type
1466
+ in (RelationshipType.ONE_TO_MANY, RelationshipType.ONE_TO_ONE)
1467
+ and relationship.source is relationship.target
1468
+ and relationship.backref
1469
+ and relationship.backref.name
1470
+ ):
1471
+ preferred_name = relationship.backref.name + "_reverse"
1472
+ else:
1473
+ preferred_name = resolve_preferred_name()
1330
1474
 
1331
1475
  relationship.name = self.find_free_name(
1332
1476
  preferred_name, global_names, local_names
@@ -1498,6 +1642,33 @@ class DeclarativeGenerator(TablesGenerator):
1498
1642
  return f"{column_attr.name}: Mapped[{rendered_column_python_type}] = {rendered_column}"
1499
1643
 
1500
1644
  def render_relationship(self, relationship: RelationshipAttribute) -> str:
1645
+ kwargs = self.render_relationship_arguments(relationship)
1646
+ annotation = self.render_relationship_annotation(relationship)
1647
+ rendered_relationship = render_callable(
1648
+ "relationship", repr(relationship.target.name), kwargs=kwargs
1649
+ )
1650
+ return f"{relationship.name}: Mapped[{annotation}] = {rendered_relationship}"
1651
+
1652
+ def render_relationship_annotation(
1653
+ self, relationship: RelationshipAttribute
1654
+ ) -> str:
1655
+ match relationship.type:
1656
+ case RelationshipType.ONE_TO_MANY:
1657
+ return f"list[{relationship.target.name!r}]"
1658
+ case RelationshipType.ONE_TO_ONE | RelationshipType.MANY_TO_ONE:
1659
+ if relationship.constraint and any(
1660
+ col.nullable for col in relationship.constraint.columns
1661
+ ):
1662
+ self.add_literal_import("typing", "Optional")
1663
+ return f"Optional[{relationship.target.name!r}]"
1664
+ else:
1665
+ return f"'{relationship.target.name}'"
1666
+ case RelationshipType.MANY_TO_MANY:
1667
+ return f"list[{relationship.target.name!r}]"
1668
+
1669
+ def render_relationship_arguments(
1670
+ self, relationship: RelationshipAttribute
1671
+ ) -> Mapping[str, Any]:
1501
1672
  def render_column_attrs(column_attrs: list[ColumnAttribute]) -> str:
1502
1673
  rendered = []
1503
1674
  for attr in column_attrs:
@@ -1513,7 +1684,7 @@ class DeclarativeGenerator(TablesGenerator):
1513
1684
  render_as_string = False
1514
1685
  # Assume that column_attrs are all in relationship.source or none
1515
1686
  for attr in column_attrs:
1516
- if attr.model is relationship.source:
1687
+ if not self.explicit_foreign_keys and attr.model is relationship.source:
1517
1688
  rendered.append(attr.name)
1518
1689
  else:
1519
1690
  rendered.append(f"{attr.model.name}.{attr.name}")
@@ -1569,33 +1740,7 @@ class DeclarativeGenerator(TablesGenerator):
1569
1740
  if relationship.backref:
1570
1741
  kwargs["back_populates"] = repr(relationship.backref.name)
1571
1742
 
1572
- rendered_relationship = render_callable(
1573
- "relationship", repr(relationship.target.name), kwargs=kwargs
1574
- )
1575
-
1576
- relationship_type: str
1577
- if relationship.type == RelationshipType.ONE_TO_MANY:
1578
- relationship_type = f"list['{relationship.target.name}']"
1579
- elif relationship.type in (
1580
- RelationshipType.ONE_TO_ONE,
1581
- RelationshipType.MANY_TO_ONE,
1582
- ):
1583
- relationship_type = f"'{relationship.target.name}'"
1584
- if relationship.constraint and any(
1585
- col.nullable for col in relationship.constraint.columns
1586
- ):
1587
- self.add_literal_import("typing", "Optional")
1588
- relationship_type = f"Optional[{relationship_type}]"
1589
- elif relationship.type == RelationshipType.MANY_TO_MANY:
1590
- relationship_type = f"list['{relationship.target.name}']"
1591
- else:
1592
- self.add_literal_import("typing", "Any")
1593
- relationship_type = "Any"
1594
-
1595
- return (
1596
- f"{relationship.name}: Mapped[{relationship_type}] "
1597
- f"= {rendered_relationship}"
1598
- )
1743
+ return kwargs
1599
1744
 
1600
1745
 
1601
1746
  class DataclassGenerator(DeclarativeGenerator):
@@ -1650,6 +1795,7 @@ class SQLModelGenerator(DeclarativeGenerator):
1650
1795
  options,
1651
1796
  indentation=indentation,
1652
1797
  base_class_name=base_class_name,
1798
+ explicit_foreign_keys=True,
1653
1799
  )
1654
1800
 
1655
1801
  @property
@@ -1730,34 +1876,26 @@ class SQLModelGenerator(DeclarativeGenerator):
1730
1876
  return f"{column_attr.name}: {rendered_column_python_type} = {rendered_field}"
1731
1877
 
1732
1878
  def render_relationship(self, relationship: RelationshipAttribute) -> str:
1733
- rendered = super().render_relationship(relationship).partition(" = ")[2]
1734
- args = self.render_relationship_args(rendered)
1735
- kwargs: dict[str, Any] = {}
1736
- annotation = repr(relationship.target.name)
1879
+ kwargs = self.render_relationship_arguments(relationship)
1880
+ annotation = self.render_relationship_annotation(relationship)
1881
+
1882
+ native_kwargs: dict[str, Any] = {}
1883
+ non_native_kwargs: dict[str, Any] = {}
1884
+ for key, value in kwargs.items():
1885
+ # The following keyword arguments are natively supported in Relationship
1886
+ if key in ("back_populates", "cascade_delete", "passive_deletes"):
1887
+ native_kwargs[key] = value
1888
+ else:
1889
+ non_native_kwargs[key] = value
1737
1890
 
1738
- if relationship.type in (
1739
- RelationshipType.ONE_TO_MANY,
1740
- RelationshipType.MANY_TO_MANY,
1741
- ):
1742
- annotation = f"list[{annotation}]"
1743
- else:
1744
- self.add_literal_import("typing", "Optional")
1745
- annotation = f"Optional[{annotation}]"
1891
+ if non_native_kwargs:
1892
+ native_kwargs["sa_relationship_kwargs"] = (
1893
+ "{"
1894
+ + ", ".join(
1895
+ f"{key!r}: {value}" for key, value in non_native_kwargs.items()
1896
+ )
1897
+ + "}"
1898
+ )
1746
1899
 
1747
- rendered_field = render_callable("Relationship", *args, kwargs=kwargs)
1900
+ rendered_field = render_callable("Relationship", kwargs=native_kwargs)
1748
1901
  return f"{relationship.name}: {annotation} = {rendered_field}"
1749
-
1750
- def render_relationship_args(self, arguments: str) -> list[str]:
1751
- argument_list = arguments.split(",")
1752
- # delete ')' and ' ' from args
1753
- argument_list[-1] = argument_list[-1][:-1]
1754
- argument_list = [argument[1:] for argument in argument_list]
1755
-
1756
- rendered_args: list[str] = []
1757
- for arg in argument_list:
1758
- if "back_populates" in arg:
1759
- rendered_args.append(arg)
1760
- if "uselist=False" in arg:
1761
- rendered_args.append("sa_relationship_kwargs={'uselist': False}")
1762
-
1763
- return rendered_args
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlacodegen
3
- Version: 4.0.0rc2
3
+ Version: 4.0.1
4
4
  Summary: Automatic model code generator for SQLAlchemy
5
5
  Author-email: Alex Grönholm <alex.gronholm@nextday.fi>
6
6
  Maintainer-email: Idan Sheinberg <ishinberg0@gmail.com>
@@ -159,6 +159,11 @@ values must be delimited by commas, e.g. ``--options noconstraints,nobidi``):
159
159
  * ``nobidi``: generate relationships in a unidirectional fashion, so only the
160
160
  many-to-one or first side of many-to-many relationships gets a relationship
161
161
  attribute, as on v2.X
162
+ * ``nofknames``: disable improved relationship naming when multiple FKs or
163
+ junction tables connect to the same target. By default, uses FK column names
164
+ for one-to-many (e.g., ``simple_items_parent_container``) and junction table
165
+ names for many-to-many (e.g., ``students_enrollments``). Reverts to
166
+ underscore suffixes (``simple_items_``, ``student_``).
162
167
 
163
168
  * ``dataclasses``
164
169
 
@@ -226,6 +231,14 @@ due to that ``_id`` suffix.
226
231
  For self referential relationships, the reverse side of the relationship will be named
227
232
  with the ``_reverse`` suffix appended to it.
228
233
 
234
+ When multiple foreign keys or junction tables connect to the same target table,
235
+ relationships use qualifiers for disambiguation. One-to-many relationships use FK
236
+ column names (e.g., ``simple_items_parent_container``, ``simple_items_top_container``).
237
+ Many-to-many relationships use junction table names (e.g., ``students_enrollments``,
238
+ ``students_waitlist``), except for self-referential cases which use FK column names
239
+ (e.g., ``parent``, ``child``). The ``nofknames`` option reverts to underscore suffixes
240
+ (``simple_items_``, ``student_``).
241
+
229
242
  Customizing code generation logic
230
243
  =================================
231
244