From ca5d25c921234631ccb60e2d71a2f4d33e39471f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 1 Feb 2026 02:09:17 +0200 Subject: [PATCH 1/4] Fixed missing foreign_keys with the SQLModel generator Fixes #376. # Conflicts: # CHANGES.rst # Conflicts: # CHANGES.rst # Conflicts: # CHANGES.rst --- CHANGES.rst | 2 ++ src/sqlacodegen/generators.py | 18 +++------- tests/test_generator_sqlmodel.py | 59 ++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 13 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index cbb5a4fc..7bc92242 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -14,6 +14,8 @@ Version history ``students_enrollments``). Use ``--options nofknames`` to revert to old behavior. (PR by @sheinbergon) - Fixed ``Index`` kwargs (e.g. ``mysql_length``) being ignored during code generation (PR by @luliangce) +- Fixed the SQLModel generator not adding the ``foreign_keys`` parameters when + generating multiple relationships between the same two tables **4.0.0rc2** diff --git a/src/sqlacodegen/generators.py b/src/sqlacodegen/generators.py index c5eef945..2444771e 100644 --- a/src/sqlacodegen/generators.py +++ b/src/sqlacodegen/generators.py @@ -1812,6 +1812,7 @@ def collect_imports_for_model(self, model: Model) -> None: if model.relationships: self.add_literal_import("sqlmodel", "Relationship") + self.add_literal_import("sqlalchemy.orm", "RelationshipProperty") def render_module_variables(self, models: list[Model]) -> str: declarations: list[str] = [] @@ -1876,16 +1877,7 @@ def render_relationship(self, relationship: RelationshipAttribute) -> str: return f"{relationship.name}: {annotation} = {rendered_field}" def render_relationship_args(self, arguments: str) -> list[str]: - argument_list = arguments.split(",") - # delete ')' and ' ' from args - argument_list[-1] = argument_list[-1][:-1] - argument_list = [argument[1:] for argument in argument_list] - - rendered_args: list[str] = [] - for arg in argument_list: - if "back_populates" in arg: - rendered_args.append(arg) - if "uselist=False" in arg: - rendered_args.append("sa_relationship_kwargs={'uselist': False}") - - return rendered_args + return [ + "sa_relationship=" + + arguments.replace("relationship", "RelationshipProperty", 1) + ] diff --git a/tests/test_generator_sqlmodel.py b/tests/test_generator_sqlmodel.py index 55a7f116..51a2da59 100644 --- a/tests/test_generator_sqlmodel.py +++ b/tests/test_generator_sqlmodel.py @@ -142,6 +142,65 @@ class SimpleGoods(SQLModel, table=True): ) +def test_onetomany_multiref(generator: CodeGenerator) -> None: + Table( + "simple_items", + generator.metadata, + Column("id", INTEGER, primary_key=True), + Column("parent_container_id", INTEGER), + Column("top_container_id", INTEGER, nullable=False), + ForeignKeyConstraint(["parent_container_id"], ["simple_containers.id"]), + ForeignKeyConstraint(["top_container_id"], ["simple_containers.id"]), + ) + Table( + "simple_containers", + generator.metadata, + Column("id", INTEGER, primary_key=True), + ) + + validate_code( + generator.generate(), + """\ + from typing import Optional + + from sqlalchemy import Column, ForeignKey, Integer + from sqlalchemy.orm import RelationshipProperty + from sqlmodel import Field, Relationship, SQLModel + + class SimpleContainers(SQLModel, table=True): + __tablename__ = 'simple_containers' + + id: int = Field(sa_column=Column('id', Integer, primary_key=True)) + + simple_items: list['SimpleItems'] = Relationship(\ +sa_relationship=RelationshipProperty('SimpleItems', \ +foreign_keys='[SimpleItems.parent_container_id]', back_populates='parent_container')) + simple_items_: list['SimpleItems'] = Relationship(\ +sa_relationship=RelationshipProperty('SimpleItems', \ +foreign_keys='[SimpleItems.top_container_id]', back_populates='top_container')) + + + class SimpleItems(SQLModel, table=True): + __tablename__ = 'simple_items' + + id: int = Field(sa_column=Column('id', Integer, primary_key=True)) + top_container_id: int = \ +Field(sa_column=Column('top_container_id', ForeignKey('simple_containers.id'), \ +nullable=False)) + parent_container_id: Optional[int] = \ +Field(default=None, sa_column=Column('parent_container_id', \ +ForeignKey('simple_containers.id'))) + + parent_container: Optional['SimpleContainers'] = Relationship(\ +sa_relationship=RelationshipProperty('SimpleContainers', \ +foreign_keys=['SimpleContainers.parent_container_id'], back_populates='simple_items')) + top_container: Optional['SimpleContainers'] = Relationship(\ +sa_relationship=RelationshipProperty('SimpleContainers', \ +foreign_keys=['SimpleContainers.top_container_id'], back_populates='simple_items_')) + """, + ) + + def test_onetoone(generator: CodeGenerator) -> None: Table( "simple_onetoone", From 87eae4fe9d4393abd80cbbc3100b15289f5701e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 8 Feb 2026 19:30:19 +0200 Subject: [PATCH 2/4] Refactored the API to better accommodate relationship rendering --- CHANGES.rst | 14 ++++ src/sqlacodegen/generators.py | 110 +++++++++++++++++-------------- tests/test_generator_sqlmodel.py | 53 +++++++-------- 3 files changed, 102 insertions(+), 75 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 7bc92242..81b7fa30 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,20 @@ Version history - **BACKWARD INCOMPATIBLE** Relationship names changed when multiple FKs or junction tables connect to the same target table. Regenerating models will break existing code. +- **BACKWARD INCOMPATIBLE** API changes (for those who customize code generation by + subclassing the existing generators): + + * Added new optional keyword argument, ``explicit_foreign_keys`` to + ``DeclarativeGenerator``, to force foreign keys to be rendered as + ``ClassName.attribute_name`` string references + * Removed the ``render_relationship_args()`` method from the SQLModel generator + * Added two new methods for customizing relationship rendering in + ``DeclarativeGenerator``: + + * ``render_relationship_annotation()``: returns the appropriate type annotation + (without the ``Mapped`` wrapper) for the relationship + * ``render_relationship_arguments()``: returns a dictionary of keyword arguments to + ``sqlalchemy.orm.relationship()`` - Added support for generating Python enum classes for ``ARRAY(Enum(...))`` columns (e.g., PostgreSQL ``ARRAY(ENUM)``). Supports named/unnamed enums, shared enums across columns, and multi-dimensional arrays. Respects ``--options nonativeenums``. diff --git a/src/sqlacodegen/generators.py b/src/sqlacodegen/generators.py index 2444771e..c7393878 100644 --- a/src/sqlacodegen/generators.py +++ b/src/sqlacodegen/generators.py @@ -5,7 +5,7 @@ import sys from abc import ABCMeta, abstractmethod from collections import defaultdict -from collections.abc import Collection, Iterable, Sequence +from collections.abc import Collection, Iterable, Mapping, Sequence from dataclasses import dataclass from importlib import import_module from inspect import Parameter @@ -1001,10 +1001,12 @@ def __init__( *, indentation: str = " ", base_class_name: str = "Base", + explicit_foreign_keys: bool = False, ): super().__init__(metadata, bind, options, indentation=indentation) self.base_class_name: str = base_class_name self.inflect_engine = inflect.engine() + self.explicit_foreign_keys = explicit_foreign_keys def generate_base(self) -> None: self.base = Base( @@ -1626,6 +1628,40 @@ def render_column_attribute(self, column_attr: ColumnAttribute) -> str: return f"{column_attr.name}: Mapped[{rendered_column_python_type}] = {rendered_column}" def render_relationship(self, relationship: RelationshipAttribute) -> str: + kwargs = self.render_relationship_arguments(relationship) + annotation = self.render_relationship_annotation(relationship) + rendered_relationship = render_callable( + "relationship", repr(relationship.target.name), kwargs=kwargs + ) + return f"{relationship.name}: Mapped[{annotation}] = {rendered_relationship}" + + def render_relationship_annotation( + self, relationship: RelationshipAttribute + ) -> str: + if relationship.type == RelationshipType.ONE_TO_MANY: + inner_type = f"list[{relationship.target.name!r}]" + elif relationship.type in ( + RelationshipType.ONE_TO_ONE, + RelationshipType.MANY_TO_ONE, + ): + if relationship.constraint and any( + col.nullable for col in relationship.constraint.columns + ): + self.add_literal_import("typing", "Optional") + inner_type = f"Optional[{relationship.target.name!r}]" + else: + inner_type = f"'{relationship.target.name}'" + elif relationship.type == RelationshipType.MANY_TO_MANY: + inner_type = f"list[{relationship.target.name!r}]" + else: + self.add_literal_import("typing", "Any") + inner_type = "Any" + + return inner_type + + def render_relationship_arguments( + self, relationship: RelationshipAttribute + ) -> Mapping[str, Any]: def render_column_attrs(column_attrs: list[ColumnAttribute]) -> str: rendered = [] for attr in column_attrs: @@ -1641,7 +1677,7 @@ def render_foreign_keys(column_attrs: list[ColumnAttribute]) -> str: render_as_string = False # Assume that column_attrs are all in relationship.source or none for attr in column_attrs: - if attr.model is relationship.source: + if not self.explicit_foreign_keys and attr.model is relationship.source: rendered.append(attr.name) else: rendered.append(f"{attr.model.name}.{attr.name}") @@ -1697,33 +1733,7 @@ def render_join(terms: list[JoinType]) -> str: if relationship.backref: kwargs["back_populates"] = repr(relationship.backref.name) - rendered_relationship = render_callable( - "relationship", repr(relationship.target.name), kwargs=kwargs - ) - - relationship_type: str - if relationship.type == RelationshipType.ONE_TO_MANY: - relationship_type = f"list['{relationship.target.name}']" - elif relationship.type in ( - RelationshipType.ONE_TO_ONE, - RelationshipType.MANY_TO_ONE, - ): - relationship_type = f"'{relationship.target.name}'" - if relationship.constraint and any( - col.nullable for col in relationship.constraint.columns - ): - self.add_literal_import("typing", "Optional") - relationship_type = f"Optional[{relationship_type}]" - elif relationship.type == RelationshipType.MANY_TO_MANY: - relationship_type = f"list['{relationship.target.name}']" - else: - self.add_literal_import("typing", "Any") - relationship_type = "Any" - - return ( - f"{relationship.name}: Mapped[{relationship_type}] " - f"= {rendered_relationship}" - ) + return kwargs class DataclassGenerator(DeclarativeGenerator): @@ -1778,6 +1788,7 @@ def __init__( options, indentation=indentation, base_class_name=base_class_name, + explicit_foreign_keys=True, ) @property @@ -1812,7 +1823,6 @@ def collect_imports_for_model(self, model: Model) -> None: if model.relationships: self.add_literal_import("sqlmodel", "Relationship") - self.add_literal_import("sqlalchemy.orm", "RelationshipProperty") def render_module_variables(self, models: list[Model]) -> str: declarations: list[str] = [] @@ -1859,25 +1869,27 @@ def render_column_attribute(self, column_attr: ColumnAttribute) -> str: return f"{column_attr.name}: {rendered_column_python_type} = {rendered_field}" def render_relationship(self, relationship: RelationshipAttribute) -> str: - rendered = super().render_relationship(relationship).partition(" = ")[2] - args = self.render_relationship_args(rendered) - kwargs: dict[str, Any] = {} - annotation = repr(relationship.target.name) + kwargs = self.render_relationship_arguments(relationship) + annotation = self.render_relationship_annotation(relationship) + + native_kwargs: dict[str, Any] = {} + non_native_kwargs: dict[str, Any] = {} + for key, value in kwargs.items(): + # The following keyword arguments are natively supported in Relationship + if key in ("back_populates", "cascade_delete", "passive_deletes"): + native_kwargs[key] = value + else: + non_native_kwargs[key] = value - if relationship.type in ( - RelationshipType.ONE_TO_MANY, - RelationshipType.MANY_TO_MANY, - ): - annotation = f"list[{annotation}]" - else: - self.add_literal_import("typing", "Optional") - annotation = f"Optional[{annotation}]" + if non_native_kwargs: + rendered = ( + "{" + + ", ".join( + f"{key!r}: {value}" for key, value in non_native_kwargs.items() + ) + + "}" + ) + native_kwargs["sa_relationship_kwargs"] = rendered - rendered_field = render_callable("Relationship", *args, kwargs=kwargs) + rendered_field = render_callable("Relationship", kwargs=native_kwargs) return f"{relationship.name}: {annotation} = {rendered_field}" - - def render_relationship_args(self, arguments: str) -> list[str]: - return [ - "sa_relationship=" - + arguments.replace("relationship", "RelationshipProperty", 1) - ] diff --git a/tests/test_generator_sqlmodel.py b/tests/test_generator_sqlmodel.py index 51a2da59..5314a795 100644 --- a/tests/test_generator_sqlmodel.py +++ b/tests/test_generator_sqlmodel.py @@ -144,16 +144,18 @@ class SimpleGoods(SQLModel, table=True): def test_onetomany_multiref(generator: CodeGenerator) -> None: Table( - "simple_items", + "simple_items_multiref", generator.metadata, Column("id", INTEGER, primary_key=True), Column("parent_container_id", INTEGER), Column("top_container_id", INTEGER, nullable=False), - ForeignKeyConstraint(["parent_container_id"], ["simple_containers.id"]), - ForeignKeyConstraint(["top_container_id"], ["simple_containers.id"]), + ForeignKeyConstraint( + ["parent_container_id"], ["simple_containers_multiref.id"] + ), + ForeignKeyConstraint(["top_container_id"], ["simple_containers_multiref.id"]), ) Table( - "simple_containers", + "simple_containers_multiref", generator.metadata, Column("id", INTEGER, primary_key=True), ) @@ -164,39 +166,38 @@ def test_onetomany_multiref(generator: CodeGenerator) -> None: from typing import Optional from sqlalchemy import Column, ForeignKey, Integer - from sqlalchemy.orm import RelationshipProperty from sqlmodel import Field, Relationship, SQLModel - class SimpleContainers(SQLModel, table=True): - __tablename__ = 'simple_containers' + class SimpleContainersMultiref(SQLModel, table=True): + __tablename__ = 'simple_containers_multiref' id: int = Field(sa_column=Column('id', Integer, primary_key=True)) - simple_items: list['SimpleItems'] = Relationship(\ -sa_relationship=RelationshipProperty('SimpleItems', \ -foreign_keys='[SimpleItems.parent_container_id]', back_populates='parent_container')) - simple_items_: list['SimpleItems'] = Relationship(\ -sa_relationship=RelationshipProperty('SimpleItems', \ -foreign_keys='[SimpleItems.top_container_id]', back_populates='top_container')) + simple_items_multiref_parent_container: list['SimpleItemsMultiref'] = \ +Relationship(back_populates='parent_container', sa_relationship_kwargs={\ +'foreign_keys': '[SimpleItemsMultiref.parent_container_id]'}) + simple_items_multiref_top_container: list['SimpleItemsMultiref'] = \ +Relationship(back_populates='top_container', sa_relationship_kwargs={'foreign_keys': \ +'[SimpleItemsMultiref.top_container_id]'}) - class SimpleItems(SQLModel, table=True): - __tablename__ = 'simple_items' + class SimpleItemsMultiref(SQLModel, table=True): + __tablename__ = 'simple_items_multiref' id: int = Field(sa_column=Column('id', Integer, primary_key=True)) top_container_id: int = \ -Field(sa_column=Column('top_container_id', ForeignKey('simple_containers.id'), \ -nullable=False)) +Field(sa_column=Column('top_container_id', \ +ForeignKey('simple_containers_multiref.id'), nullable=False)) parent_container_id: Optional[int] = \ Field(default=None, sa_column=Column('parent_container_id', \ -ForeignKey('simple_containers.id'))) - - parent_container: Optional['SimpleContainers'] = Relationship(\ -sa_relationship=RelationshipProperty('SimpleContainers', \ -foreign_keys=['SimpleContainers.parent_container_id'], back_populates='simple_items')) - top_container: Optional['SimpleContainers'] = Relationship(\ -sa_relationship=RelationshipProperty('SimpleContainers', \ -foreign_keys=['SimpleContainers.top_container_id'], back_populates='simple_items_')) +ForeignKey('simple_containers_multiref.id'))) + + parent_container: Optional['SimpleContainersMultiref'] = Relationship(\ +back_populates='simple_items_multiref_parent_container', sa_relationship_kwargs={\ +'foreign_keys': '[SimpleItemsMultiref.parent_container_id]'}) + top_container: 'SimpleContainersMultiref' = Relationship(\ +back_populates='simple_items_multiref_top_container', sa_relationship_kwargs={\ +'foreign_keys': '[SimpleItemsMultiref.top_container_id]'}) """, ) @@ -226,7 +227,7 @@ class OtherItems(SQLModel, table=True): id: int = Field(sa_column=Column('id', Integer, primary_key=True)) simple_onetoone: Optional['SimpleOnetoone'] = Relationship(\ -sa_relationship_kwargs={'uselist': False}, back_populates='other_item') +back_populates='other_item', sa_relationship_kwargs={'uselist': False}) class SimpleOnetoone(SQLModel, table=True): From 51bb0113504e5da3dac64f93cdbdc07f28d8ab69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 9 Feb 2026 15:26:43 +0200 Subject: [PATCH 3/4] Moved the changelog entry out of the already released version --- CHANGES.rst | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 81b7fa30..c676a408 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,10 +1,8 @@ Version history =============== -**4.0.0rc3** +**UNRELEASED** -- **BACKWARD INCOMPATIBLE** Relationship names changed when multiple FKs or junction tables - connect to the same target table. Regenerating models will break existing code. - **BACKWARD INCOMPATIBLE** API changes (for those who customize code generation by subclassing the existing generators): @@ -19,6 +17,11 @@ Version history (without the ``Mapped`` wrapper) for the relationship * ``render_relationship_arguments()``: returns a dictionary of keyword arguments to ``sqlalchemy.orm.relationship()`` + +**4.0.0rc3** + +- **BACKWARD INCOMPATIBLE** Relationship names changed when multiple FKs or junction tables + connect to the same target table. Regenerating models will break existing code. - Added support for generating Python enum classes for ``ARRAY(Enum(...))`` columns (e.g., PostgreSQL ``ARRAY(ENUM)``). Supports named/unnamed enums, shared enums across columns, and multi-dimensional arrays. Respects ``--options nonativeenums``. From d32e4be842af457b275484d02026e95b01f89d5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 9 Feb 2026 15:39:28 +0200 Subject: [PATCH 4/4] Refactored code as requested --- src/sqlacodegen/generators.py | 36 ++++++++++++++--------------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/src/sqlacodegen/generators.py b/src/sqlacodegen/generators.py index c7393878..83fe982d 100644 --- a/src/sqlacodegen/generators.py +++ b/src/sqlacodegen/generators.py @@ -1638,26 +1638,19 @@ def render_relationship(self, relationship: RelationshipAttribute) -> str: def render_relationship_annotation( self, relationship: RelationshipAttribute ) -> str: - if relationship.type == RelationshipType.ONE_TO_MANY: - inner_type = f"list[{relationship.target.name!r}]" - elif relationship.type in ( - RelationshipType.ONE_TO_ONE, - RelationshipType.MANY_TO_ONE, - ): - if relationship.constraint and any( - col.nullable for col in relationship.constraint.columns - ): - self.add_literal_import("typing", "Optional") - inner_type = f"Optional[{relationship.target.name!r}]" - else: - inner_type = f"'{relationship.target.name}'" - elif relationship.type == RelationshipType.MANY_TO_MANY: - inner_type = f"list[{relationship.target.name!r}]" - else: - self.add_literal_import("typing", "Any") - inner_type = "Any" - - return inner_type + match relationship.type: + case RelationshipType.ONE_TO_MANY: + return f"list[{relationship.target.name!r}]" + case RelationshipType.ONE_TO_ONE | RelationshipType.MANY_TO_ONE: + if relationship.constraint and any( + col.nullable for col in relationship.constraint.columns + ): + self.add_literal_import("typing", "Optional") + return f"Optional[{relationship.target.name!r}]" + else: + return f"'{relationship.target.name}'" + case RelationshipType.MANY_TO_MANY: + return f"list[{relationship.target.name!r}]" def render_relationship_arguments( self, relationship: RelationshipAttribute @@ -1882,14 +1875,13 @@ def render_relationship(self, relationship: RelationshipAttribute) -> str: non_native_kwargs[key] = value if non_native_kwargs: - rendered = ( + native_kwargs["sa_relationship_kwargs"] = ( "{" + ", ".join( f"{key!r}: {value}" for key, value in non_native_kwargs.items() ) + "}" ) - native_kwargs["sa_relationship_kwargs"] = rendered rendered_field = render_callable("Relationship", kwargs=native_kwargs) return f"{relationship.name}: {annotation} = {rendered_field}"