From 809eab86d1855b93c9334499f2499e152f1c2c27 Mon Sep 17 00:00:00 2001 From: Idan Sheinberg Date: Fri, 4 Sep 2026 15:50:30 +0300 Subject: [PATCH 1/4] Render SQLModel many-to-many link tables as link model classes Association tables that have a primary key are now generated as `SQLModel` table classes (without relationships of their own) and referenced from both sides via `Relationship(link_model=...)` instead of `sa_relationship_kwargs={'secondary': ...}`, matching the idiom from the SQLModel documentation. Link tables without a primary key cannot be SQLModel classes and keep the previous plain `Table` output. The link model classes are emitted before the classes referencing them, since `link_model=` needs the class object rather than a string. Fixes #405 Co-Authored-By: Claude Fable 5.1 --- CHANGES.rst | 4 + README.rst | 5 +- src/sqlacodegen/generators.py | 58 +++++++- tests/test_generator_sqlmodel.py | 219 +++++++++++++++++++++++++++++++ 4 files changed, 279 insertions(+), 7 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 213cd5d3..63bc5c9d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -7,6 +7,10 @@ Version history when the foreign key targets other columns (such as a ``UNIQUE`` constraint), which made mapper configuration fail with ``ArgumentError`` (`#484 `_; PR by @NixBiks) +- Added support for many-to-many link models in the SQLModel generator: association + tables with a primary key are now rendered as ``SQLModel`` classes and referenced via + ``Relationship(link_model=...)`` instead of ``secondary`` + (`#405 `_; PR by @sheinbergon) **4.0.4** diff --git a/README.rst b/README.rst index a6805f13..4bd08d01 100644 --- a/README.rst +++ b/README.rst @@ -169,8 +169,9 @@ Relationships are detected based on existing foreign key constraints as follows: * **many-to-one**: a foreign key constraint exists on the table * **one-to-one**: same as **many-to-one**, but a unique constraint exists on the column(s) involved -* **many-to-many**: (not implemented on the ``sqlmodel`` generator) an association table - is found to exist between two tables +* **many-to-many**: an association table is found to exist between two tables (the + ``sqlmodels`` generator renders association tables that have a primary key as link + model classes, passed to the relationships via ``link_model=``) A table is considered an association table if it satisfies all of the following conditions: diff --git a/src/sqlacodegen/generators.py b/src/sqlacodegen/generators.py index 788c91e4..bd6e822f 100644 --- a/src/sqlacodegen/generators.py +++ b/src/sqlacodegen/generators.py @@ -1154,6 +1154,7 @@ def generate_models(self) -> list[Model]: # Pick association tables from the metadata into their own set, don't process # them normally links: defaultdict[str, list[Model]] = defaultdict(lambda: []) + link_tables: set[Table] = set() for table in self.metadata.sorted_tables: qualified_name = qualified_table_name(table) @@ -1165,9 +1166,12 @@ def generate_models(self) -> list[Model]: if len(fk_constraints) == 2 and all( col.foreign_keys for col in table.columns ): - model = models_by_table_name[qualified_name] = Model(table) + model = models_by_table_name[qualified_name] = self.generate_link_model( + table + ) tablename = fk_constraints[0].elements[0].column.table.name links[tablename].append(model) + link_tables.add(table) continue # Only form model classes for tables that have a primary key and are not @@ -1183,9 +1187,9 @@ def generate_models(self) -> list[Model]: column_attr = ColumnAttribute(model, column) model.columns.append(column_attr) - # Add relationships + # Add relationships (link models only take part as association tables) for model in models_by_table_name.values(): - if isinstance(model, ModelClass): + if isinstance(model, ModelClass) and model.table not in link_tables: self.generate_relationships( model, models_by_table_name, links[model.table.name] ) @@ -1193,7 +1197,7 @@ def generate_models(self) -> list[Model]: # Nest inherited classes in their superclasses to ensure proper ordering if "nojoined" not in self.options: for model in list(models_by_table_name.values()): - if not isinstance(model, ModelClass): + if not isinstance(model, ModelClass) or model.table in link_tables: continue pk_column_names = {col.name for col in model.table.primary_key.columns} @@ -1226,6 +1230,10 @@ def generate_models(self) -> list[Model]: return list(models_by_table_name.values()) + def generate_link_model(self, table: Table) -> Model: + """Create the model for an association (link) table.""" + return Model(table) + def generate_relationships( self, source: ModelClass, @@ -1919,6 +1927,29 @@ def render_table(self, table: Table) -> str: return super().render_table(table) + def generate_link_model(self, table: Table) -> Model: + # SQLModel link models need a primary key; otherwise fall back to a plain Table + if not table.primary_key: + return super().generate_link_model(table) + + model = ModelClass(table) + model.columns = [ColumnAttribute(model, column) for column in table.c] + return model + + def generate_models(self) -> list[Model]: + models = super().generate_models() + + # Link models must be defined before the classes referencing them via + # link_model=, so move them to the front (stable sort keeps the rest as is) + link_model_ids = { + id(relationship.association_table) + for model in models + if isinstance(model, ModelClass) + for relationship in model.relationships + if isinstance(relationship.association_table, ModelClass) + } + return sorted(models, key=lambda model: id(model) not in link_model_ids) + def generate_base(self) -> None: self.base = Base( literal_imports=[], @@ -1990,6 +2021,18 @@ def render_column_attribute(self, column_attr: ColumnAttribute) -> str: return f"{column_attr.name}: {rendered_column_python_type} = {rendered_field}" + def render_relationship_arguments( + self, relationship: RelationshipAttribute + ) -> Mapping[str, Any]: + kwargs = dict(super().render_relationship_arguments(relationship)) + + # Link models are passed as link_model= rather than secondary= + if isinstance(relationship.association_table, ModelClass): + del kwargs["secondary"] + kwargs["link_model"] = relationship.association_table.name + + return kwargs + def render_relationship(self, relationship: RelationshipAttribute) -> str: kwargs = self.render_relationship_arguments(relationship) annotation = self.render_relationship_annotation(relationship) @@ -1998,7 +2041,12 @@ def render_relationship(self, relationship: RelationshipAttribute) -> str: 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"): + if key in ( + "back_populates", + "cascade_delete", + "passive_deletes", + "link_model", + ): native_kwargs[key] = value else: non_native_kwargs[key] = value diff --git a/tests/test_generator_sqlmodel.py b/tests/test_generator_sqlmodel.py index 433b5f5b..17b0304f 100644 --- a/tests/test_generator_sqlmodel.py +++ b/tests/test_generator_sqlmodel.py @@ -469,3 +469,222 @@ class SimpleItemsSelfref(SQLModel, table=True): 'foreign_keys': '[SimpleItemsSelfref.top_item_id]'}) """, ) + + +def test_manytomany(generator: CodeGenerator) -> None: + Table("left_table", generator.metadata, Column("id", INTEGER, primary_key=True)) + Table("right_table", generator.metadata, Column("id", INTEGER, primary_key=True)) + Table( + "association_table", + generator.metadata, + Column("left_id", INTEGER, primary_key=True), + Column("right_id", INTEGER, primary_key=True), + ForeignKeyConstraint(["left_id"], ["left_table.id"]), + ForeignKeyConstraint(["right_id"], ["right_table.id"]), + ) + + validate_code( + generator.generate(), + """\ + from sqlalchemy import Column, ForeignKey, Integer + from sqlmodel import Field, Relationship, SQLModel + + class AssociationTable(SQLModel, table=True): + __tablename__ = 'association_table' + + left_id: int = Field(sa_column=Column('left_id', \ +ForeignKey('left_table.id'), primary_key=True)) + right_id: int = Field(sa_column=Column('right_id', \ +ForeignKey('right_table.id'), primary_key=True)) + + + class LeftTable(SQLModel, table=True): + __tablename__ = 'left_table' + + id: int = Field(sa_column=Column('id', Integer, primary_key=True)) + + right: list['RightTable'] = Relationship(back_populates='left', \ +link_model=AssociationTable) + + + class RightTable(SQLModel, table=True): + __tablename__ = 'right_table' + + id: int = Field(sa_column=Column('id', Integer, primary_key=True)) + + left: list['LeftTable'] = Relationship(back_populates='right', \ +link_model=AssociationTable) + """, + ) + + +def test_manytomany_selfref(generator: CodeGenerator) -> None: + Table("m2m_items", generator.metadata, Column("id", INTEGER, primary_key=True)) + Table( + "m2m_child_items", + generator.metadata, + Column("parent_id", INTEGER, primary_key=True), + Column("child_id", INTEGER, primary_key=True), + ForeignKeyConstraint(["parent_id"], ["m2m_items.id"]), + ForeignKeyConstraint(["child_id"], ["m2m_items.id"]), + schema="otherschema", + ) + + validate_code( + generator.generate(), + """\ + from sqlalchemy import Column, ForeignKey, Integer + from sqlmodel import Field, Relationship, SQLModel + + class M2mChildItems(SQLModel, table=True): + __tablename__ = 'm2m_child_items' + __table_args__ = {'schema': 'otherschema'} + + parent_id: int = Field(sa_column=Column('parent_id', \ +ForeignKey('m2m_items.id'), primary_key=True)) + child_id: int = Field(sa_column=Column('child_id', \ +ForeignKey('m2m_items.id'), primary_key=True)) + + + class M2mItems(SQLModel, table=True): + __tablename__ = 'm2m_items' + + id: int = Field(sa_column=Column('id', Integer, primary_key=True)) + + parent: list['M2mItems'] = Relationship(back_populates='child', \ +link_model=M2mChildItems, sa_relationship_kwargs={\ +'primaryjoin': lambda: M2mItems.id == M2mChildItems.child_id, \ +'secondaryjoin': lambda: M2mItems.id == M2mChildItems.parent_id}) + child: list['M2mItems'] = Relationship(back_populates='parent', \ +link_model=M2mChildItems, sa_relationship_kwargs={\ +'primaryjoin': lambda: M2mItems.id == M2mChildItems.parent_id, \ +'secondaryjoin': lambda: M2mItems.id == M2mChildItems.child_id}) + """, + ) + + +def test_manytomany_composite(generator: CodeGenerator) -> None: + Table( + "composite_items", + generator.metadata, + Column("id1", INTEGER, primary_key=True), + Column("id2", INTEGER, primary_key=True), + ) + Table( + "composite_containers", + generator.metadata, + Column("id1", INTEGER, primary_key=True), + Column("id2", INTEGER, primary_key=True), + ) + Table( + "composite_container_items", + generator.metadata, + Column("item_id1", INTEGER, primary_key=True), + Column("item_id2", INTEGER, primary_key=True), + Column("container_id1", INTEGER, primary_key=True), + Column("container_id2", INTEGER, primary_key=True), + ForeignKeyConstraint( + ["item_id1", "item_id2"], + ["composite_items.id1", "composite_items.id2"], + ), + ForeignKeyConstraint( + ["container_id1", "container_id2"], + ["composite_containers.id1", "composite_containers.id2"], + ), + ) + + validate_code( + generator.generate(), + """\ + from sqlalchemy import Column, ForeignKeyConstraint, Integer + from sqlmodel import Field, Relationship, SQLModel + + class CompositeContainerItems(SQLModel, table=True): + __tablename__ = 'composite_container_items' + __table_args__ = ( + ForeignKeyConstraint(['container_id1', 'container_id2'], \ +['composite_containers.id1', 'composite_containers.id2']), + ForeignKeyConstraint(['item_id1', 'item_id2'], \ +['composite_items.id1', 'composite_items.id2']) + ) + + item_id1: int = Field(sa_column=Column('item_id1', Integer, \ +primary_key=True)) + item_id2: int = Field(sa_column=Column('item_id2', Integer, \ +primary_key=True)) + container_id1: int = Field(sa_column=Column('container_id1', Integer, \ +primary_key=True)) + container_id2: int = Field(sa_column=Column('container_id2', Integer, \ +primary_key=True)) + + + class CompositeContainers(SQLModel, table=True): + __tablename__ = 'composite_containers' + + id1: int = Field(sa_column=Column('id1', Integer, primary_key=True)) + id2: int = Field(sa_column=Column('id2', Integer, primary_key=True)) + + composite_items: list['CompositeItems'] = Relationship(\ +back_populates='composite_containers', link_model=CompositeContainerItems) + + + class CompositeItems(SQLModel, table=True): + __tablename__ = 'composite_items' + + id1: int = Field(sa_column=Column('id1', Integer, primary_key=True)) + id2: int = Field(sa_column=Column('id2', Integer, primary_key=True)) + + composite_containers: list['CompositeContainers'] = Relationship(\ +back_populates='composite_items', link_model=CompositeContainerItems) + """, + ) + + +def test_manytomany_no_pk(generator: CodeGenerator) -> None: + """Link tables without a primary key cannot be SQLModel classes; fall back.""" + Table( + "nopk_left_table", generator.metadata, Column("id", INTEGER, primary_key=True) + ) + Table( + "nopk_right_table", generator.metadata, Column("id", INTEGER, primary_key=True) + ) + Table( + "nopk_association_table", + generator.metadata, + Column("left_id", INTEGER), + Column("right_id", INTEGER), + ForeignKeyConstraint(["left_id"], ["nopk_left_table.id"]), + ForeignKeyConstraint(["right_id"], ["nopk_right_table.id"]), + ) + + validate_code( + generator.generate(), + """\ + from sqlalchemy import Column, ForeignKey, Integer, Table + from sqlmodel import Field, Relationship, SQLModel + + class NopkLeftTable(SQLModel, table=True): + __tablename__ = 'nopk_left_table' + + id: int = Field(sa_column=Column('id', Integer, primary_key=True)) + + right: list['NopkRightTable'] = Relationship(back_populates='left', \ +sa_relationship_kwargs={'secondary': 'nopk_association_table'}) + + + class NopkRightTable(SQLModel, table=True): + __tablename__ = 'nopk_right_table' + + id: int = Field(sa_column=Column('id', Integer, primary_key=True)) + + left: list['NopkLeftTable'] = Relationship(back_populates='right', \ +sa_relationship_kwargs={'secondary': 'nopk_association_table'}) + + + t_nopk_association_table = Table( + 'nopk_association_table', SQLModel.metadata, + Column('left_id', ForeignKey('nopk_left_table.id')), + Column('right_id', ForeignKey('nopk_right_table.id')) + ) + """, + ) From 5d49fbca787fea2f798974b314ebb1c050808aac Mon Sep 17 00:00:00 2001 From: Idan Sheinberg Date: Fri, 4 Sep 2026 15:53:24 +0300 Subject: [PATCH 2/4] Add nolinktables option to the SQLModel generator Opts out of rendering association tables as link model classes, keeping the previous plain Table + secondary= output. Co-Authored-By: Claude Fable 5.1 --- CHANGES.rst | 3 ++- README.rst | 3 +++ src/sqlacodegen/generators.py | 6 ++++- tests/test_generator_sqlmodel.py | 46 ++++++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 63bc5c9d..578252a3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -9,7 +9,8 @@ Version history (`#484 `_; PR by @NixBiks) - Added support for many-to-many link models in the SQLModel generator: association tables with a primary key are now rendered as ``SQLModel`` classes and referenced via - ``Relationship(link_model=...)`` instead of ``secondary`` + ``Relationship(link_model=...)`` instead of ``secondary``; use the new + ``nolinktables`` option to keep the previous behavior (`#405 `_; PR by @sheinbergon) **4.0.4** diff --git a/README.rst b/README.rst index 4bd08d01..ae2a085e 100644 --- a/README.rst +++ b/README.rst @@ -135,6 +135,9 @@ values must be delimited by commas, e.g. ``--options noconstraints,nobidi``): * ``sqlmodels`` * all the options from ``declarative`` + * ``nolinktables``: don't render many-to-many association tables as link model + classes (``Relationship(link_model=...)``); render them as plain ``Table`` objects + referenced via ``secondary`` instead Model class generators ---------------------- diff --git a/src/sqlacodegen/generators.py b/src/sqlacodegen/generators.py index bd6e822f..d5bd042f 100644 --- a/src/sqlacodegen/generators.py +++ b/src/sqlacodegen/generators.py @@ -1895,6 +1895,10 @@ def generate_base(self) -> None: class SQLModelGenerator(DeclarativeGenerator): + valid_options: ClassVar[set[str]] = DeclarativeGenerator.valid_options | { + "nolinktables" + } + def __init__( self, metadata: MetaData, @@ -1929,7 +1933,7 @@ def render_table(self, table: Table) -> str: def generate_link_model(self, table: Table) -> Model: # SQLModel link models need a primary key; otherwise fall back to a plain Table - if not table.primary_key: + if "nolinktables" in self.options or not table.primary_key: return super().generate_link_model(table) model = ModelClass(table) diff --git a/tests/test_generator_sqlmodel.py b/tests/test_generator_sqlmodel.py index 17b0304f..70d4ed85 100644 --- a/tests/test_generator_sqlmodel.py +++ b/tests/test_generator_sqlmodel.py @@ -688,3 +688,49 @@ class NopkRightTable(SQLModel, table=True): ) """, ) + + +@pytest.mark.parametrize("generator", [["nolinktables"]], indirect=True) +def test_manytomany_nolinktables(generator: CodeGenerator) -> None: + Table("nolink_left", generator.metadata, Column("id", INTEGER, primary_key=True)) + Table("nolink_right", generator.metadata, Column("id", INTEGER, primary_key=True)) + Table( + "nolink_association", + generator.metadata, + Column("left_id", INTEGER, primary_key=True), + Column("right_id", INTEGER, primary_key=True), + ForeignKeyConstraint(["left_id"], ["nolink_left.id"]), + ForeignKeyConstraint(["right_id"], ["nolink_right.id"]), + ) + + validate_code( + generator.generate(), + """\ + from sqlalchemy import Column, ForeignKey, Integer, Table + from sqlmodel import Field, Relationship, SQLModel + + class NolinkLeft(SQLModel, table=True): + __tablename__ = 'nolink_left' + + id: int = Field(sa_column=Column('id', Integer, primary_key=True)) + + right: list['NolinkRight'] = Relationship(back_populates='left', \ +sa_relationship_kwargs={'secondary': 'nolink_association'}) + + + class NolinkRight(SQLModel, table=True): + __tablename__ = 'nolink_right' + + id: int = Field(sa_column=Column('id', Integer, primary_key=True)) + + left: list['NolinkLeft'] = Relationship(back_populates='right', \ +sa_relationship_kwargs={'secondary': 'nolink_association'}) + + + t_nolink_association = Table( + 'nolink_association', SQLModel.metadata, + Column('left_id', ForeignKey('nolink_left.id'), primary_key=True), + Column('right_id', ForeignKey('nolink_right.id'), primary_key=True) + ) + """, + ) From 7c1155bbd47f125a900b5f488f98fb9eddd599ad Mon Sep 17 00:00:00 2001 From: Idan Sheinberg Date: Fri, 4 Sep 2026 15:57:30 +0300 Subject: [PATCH 3/4] Flag SQLModel link model change as backward incompatible in changelog --- CHANGES.rst | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 578252a3..8f210eaf 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,17 +1,21 @@ Version history =============== -**UNRELEASED** +**5.0.0** +- **BACKWARD INCOMPATIBLE** The SQLModel generator no longer emits ``t_`` + ``Table`` variables for many-to-many association tables that have a primary key, and + their relationships no longer pass ``secondary`` via ``sa_relationship_kwargs``. + Code importing those variables will break when models are regenerated. Use the + ``nolinktables`` option to keep the previous output. +- Added support for many-to-many link models in the SQLModel generator: association + tables with a primary key are now rendered as ``SQLModel`` classes and referenced via + ``Relationship(link_model=...)`` + (`#405 `_; PR by @sheinbergon) - Fixed ``remote_side`` in a self-referential relationship pointing at the primary key when the foreign key targets other columns (such as a ``UNIQUE`` constraint), which made mapper configuration fail with ``ArgumentError`` (`#484 `_; PR by @NixBiks) -- Added support for many-to-many link models in the SQLModel generator: association - tables with a primary key are now rendered as ``SQLModel`` classes and referenced via - ``Relationship(link_model=...)`` instead of ``secondary``; use the new - ``nolinktables`` option to keep the previous behavior - (`#405 `_; PR by @sheinbergon) **4.0.4** From ec475e2ff40623579a66869272f7309044b32473 Mon Sep 17 00:00:00 2001 From: Idan Sheinberg Date: Fri, 4 Sep 2026 16:13:13 +0300 Subject: [PATCH 4/4] Unreleased change desc --- CHANGES.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 8f210eaf..77d3f404 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,7 +1,7 @@ Version history =============== -**5.0.0** +**UNRELEASED** - **BACKWARD INCOMPATIBLE** The SQLModel generator no longer emits ``t_`` ``Table`` variables for many-to-many association tables that have a primary key, and