Skip to content

Commit 274cc1c

Browse files
authored
Merge pull request #49 from tomsch420/main
One to many relationships are now done via association tables. I added a testcase that shows why the old way wont work always.
2 parents 754112b + 3146d35 commit 274cc1c

File tree

6 files changed

+279
-156
lines changed

6 files changed

+279
-156
lines changed

src/krrood/ormatic/dao.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import sqlalchemy.inspection
1212
import sqlalchemy.orm
1313
from sqlalchemy import Column
14-
from sqlalchemy.orm import MANYTOONE, ONETOMANY, RelationshipProperty
14+
from sqlalchemy.orm import MANYTOONE, MANYTOMANY, ONETOMANY, RelationshipProperty
1515
from typing_extensions import (
1616
Type,
1717
get_args,
@@ -581,7 +581,7 @@ def get_relationships_from(
581581
relationship=relationship,
582582
state=state,
583583
)
584-
elif relationship.direction == ONETOMANY:
584+
elif relationship.direction in (ONETOMANY, MANYTOMANY):
585585
self._extract_collection_relationship(
586586
obj=obj,
587587
relationship=relationship,
@@ -727,7 +727,7 @@ def _collect_relationship_kwargs(
727727
if is_circular:
728728
circular_refs[relationship.key] = value
729729
rel_kwargs[relationship.key] = parsed
730-
elif relationship.direction == ONETOMANY:
730+
elif relationship.direction in (ONETOMANY, MANYTOMANY):
731731
parsed_list, circular_list = state.parse_collection(value)
732732
if circular_list:
733733
circular_refs[relationship.key] = circular_list

src/krrood/ormatic/ormatic.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from .dao import AlternativeMapping
1414
from .sqlalchemy_generator import SQLAlchemyGenerator
1515
from .utils import InheritanceStrategy, module_and_class_name
16-
from .wrapped_table import WrappedTable
16+
from .wrapped_table import WrappedTable, AssociationTable
1717
from ..class_diagrams.class_diagram import (
1818
ClassDiagram,
1919
ClassRelation,
@@ -87,6 +87,11 @@ class ORMatic:
8787
The wrapped tables instances for the SQLAlchemy conversion.
8888
"""
8989

90+
association_tables: List[AssociationTable] = field(default_factory=list, init=False)
91+
"""
92+
List of association tables for many-to-many relationships.
93+
"""
94+
9095
def __post_init__(self):
9196
self.type_mappings[Type] = TypeType
9297
self.imported_modules.add(Type.__module__)

src/krrood/ormatic/templates/sqlalchemy_model.py.jinja

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Generated by ORMatic
22

33
from __future__ import annotations
4-
from sqlalchemy import Column, ForeignKey, Integer, String, Float, Boolean, DateTime, Enum, JSON
4+
from sqlalchemy import Column, ForeignKey, Integer, String, Float, Boolean, DateTime, Enum, JSON, Table
55
from sqlalchemy.orm import relationship, Mapped, mapped_column, DeclarativeBase
66

77
{% for import in ormatic.imported_modules %}
@@ -20,6 +20,17 @@ class Base(DeclarativeBase):
2020
}
2121

2222

23+
# Association tables for many-to-many relationships
24+
{% for assoc_table in ormatic.association_tables %}
25+
{{ assoc_table.name }} = Table(
26+
'{{ assoc_table.name }}',
27+
Base.metadata,
28+
Column('{{ assoc_table.left_foreign_key }}', ForeignKey('{{ assoc_table.left_primary_key }}')),
29+
Column('{{ assoc_table.right_foreign_key }}', ForeignKey('{{ assoc_table.right_primary_key }}')),
30+
)
31+
{% endfor %}
32+
33+
2334
{% for table in ormatic.wrapped_tables.values() %}
2435
class {{ table.tablename }}({{ table.base_class_name }}, DataAccessObject[{{ table.wrapped_clazz.clazz.__module__ }}.{{ table.wrapped_clazz.clazz.__name__ }}]):
2536

src/krrood/ormatic/wrapped_table.py

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,48 @@ def __str__(self) -> str:
6161
return f"{self.name}: {self.type}"
6262

6363

64+
@dataclass
65+
class AssociationTable:
66+
"""
67+
Represents an association table for many-to-many relationships in SQLAlchemy.
68+
"""
69+
70+
name: str
71+
"""
72+
The name of the association table.
73+
"""
74+
75+
left_table_name: str
76+
"""
77+
The name of the left (source) table.
78+
"""
79+
80+
left_foreign_key: str
81+
"""
82+
The foreign key column name for the left table.
83+
"""
84+
85+
left_primary_key: str
86+
"""
87+
The full primary key reference for the left table (e.g., 'TableName.primary_key').
88+
"""
89+
90+
right_table_name: str
91+
"""
92+
The name of the right (target) table.
93+
"""
94+
95+
right_foreign_key: str
96+
"""
97+
The foreign key column name for the right table.
98+
"""
99+
100+
right_primary_key: str
101+
"""
102+
The full primary key reference for the right table (e.g., 'TableName.primary_key').
103+
"""
104+
105+
64106
@dataclass
65107
class WrappedTable:
66108
"""
@@ -520,32 +562,42 @@ def create_one_to_one_relationship(self, wrapped_field: WrappedField):
520562

521563
def create_one_to_many_relationship(self, wrapped_field: WrappedField):
522564
"""
523-
Creates a one-to-many relationship mapping for the given wrapped field.
524-
The target side of the wrapped field gets a foreign key to this table with a unique name.
525-
This table gets a relationship that joins the target table with the foreign key.
565+
Creates a many-to-many relationship mapping for the given wrapped field using an association table.
566+
This allows multiple instances of the source table to reference the same instances of the target table.
526567
527-
:param wrapped_field: The field for the one-to-many relationship.
568+
:param wrapped_field: The field for the many-to-many relationship.
528569
"""
529570

530571
# get the target table
531572
target_wrapped_table = self.get_table_of_wrapped_field(wrapped_field)
532573

533-
# create a foreign key to this on the remote side
534-
fk_name = f"{self.tablename.lower()}_{wrapped_field.field.name}{self.ormatic.foreign_key_postfix}"
535-
fk_type = (
536-
f"Mapped[{module_and_class_name(Optional)}[{module_and_class_name(int)}]]"
537-
)
538-
fk_column_constructor = f"mapped_column(ForeignKey('{self.full_primary_key_name}', use_alter=True), nullable=True, use_existing_column=True)"
539-
target_wrapped_table.foreign_keys.append(
540-
ColumnConstructor(fk_name, fk_type, fk_column_constructor)
574+
# create association table name
575+
association_table_name = f"{self.tablename.lower()}_{wrapped_field.field.name}_association"
576+
577+
# create foreign key names for the association table
578+
left_fk_name = f"{self.tablename.lower()}{self.ormatic.foreign_key_postfix}"
579+
right_fk_name = f"{target_wrapped_table.tablename.lower()}{self.ormatic.foreign_key_postfix}"
580+
581+
# create association table metadata
582+
association_table = AssociationTable(
583+
name=association_table_name,
584+
left_table_name=self.tablename,
585+
left_foreign_key=left_fk_name,
586+
left_primary_key=self.full_primary_key_name,
587+
right_table_name=target_wrapped_table.tablename,
588+
right_foreign_key=right_fk_name,
589+
right_primary_key=target_wrapped_table.full_primary_key_name,
541590
)
542591

543-
# create a relationship with a list to collect the other side
592+
# add association table to ORMatic
593+
self.ormatic.association_tables.append(association_table)
594+
595+
# create a relationship with a list using the association table
544596
rel_name = f"{wrapped_field.field.name}"
545597
rel_type = (
546598
f"Mapped[{module_and_class_name(List)}[{target_wrapped_table.tablename}]]"
547599
)
548-
rel_constructor = f"relationship('{target_wrapped_table.tablename}', foreign_keys='[{target_wrapped_table.tablename}.{fk_name}]', post_update=True)"
600+
rel_constructor = f"relationship('{target_wrapped_table.tablename}', secondary='{association_table_name}', cascade='save-update, merge')"
549601
self.relationships.append(
550602
ColumnConstructor(rel_name, rel_type, rel_constructor)
551603
)

0 commit comments

Comments
 (0)