Skip to content

Commit 9a606ff

Browse files
authored
Add relationship() with back_populates for SQLAlchemy models (issue #47) (#77)
* Add relationship() with back_populates for SQLAlchemy models (issue #47) - Add new `relationships` parameter to `create_models()` function - Generate bidirectional relationships with `back_populates` for foreign keys - Support both `sqlalchemy` and `sqlalchemy_v2` model types: - For sqlalchemy: uses `relationship("Model", back_populates="attr")` - For sqlalchemy_v2: uses `Mapped[List["Model"]]` and `Mapped["Model"]` type hints - Add `collect_relationships()` function to gather FK relationships from tables - Handle both inline FK references and ALTER TABLE FK definitions - Add functional and integration tests for both model types - Update CHANGELOG with new feature documentation * Fix flake8 errors: remove unused imports and reduce complexity
1 parent d35760f commit 9a606ff

10 files changed

Lines changed: 459 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5252
- Uses `X | None` union syntax for nullable columns
5353
- Supports all column types, foreign keys, indexes, and constraints
5454

55+
**SQLAlchemy Relationships (issue #47)**
56+
- New `relationships` parameter for `create_models()` to generate `relationship()` with `back_populates`
57+
- Automatically generates bidirectional relationships for foreign keys:
58+
- Parent side (one-to-many): collection attribute pointing to children
59+
- Child side (many-to-one): attribute pointing to parent
60+
- Works with both `sqlalchemy` and `sqlalchemy_v2` model types
61+
- For `sqlalchemy_v2`: uses `Mapped[List[T]]` for one-to-many and `Mapped[T]` for many-to-one
62+
5563
**SQLModel Improvements**
5664
- Fixed array type generation (issue #66)
5765
- Arrays now properly generate `List[T]` with correct SQLAlchemy ARRAY type

omymodels/from_ddl.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def create_models(
4444
no_auto_snake_case: Optional[bool] = False,
4545
table_prefix: Optional[str] = "",
4646
table_suffix: Optional[str] = "",
47+
relationships: Optional[bool] = False,
4748
):
4849
"""models_type can be: "gino", "dataclass", "pydantic" """
4950
# extract data from ddl file
@@ -65,6 +66,7 @@ def create_models(
6566
defaults_off,
6667
table_prefix=table_prefix,
6768
table_suffix=table_suffix,
69+
relationships=relationships,
6870
)
6971
if dump:
7072
save_models_to_file(output, dump_path)
@@ -138,6 +140,56 @@ def save_models_to_file(models: str, dump_path: str) -> None:
138140
f.write(models)
139141

140142

143+
def _add_relationship(
144+
relationships: Dict, table_name: str, fk_column: str, ref_table: str, ref_column: str
145+
):
146+
"""Helper to add both sides of a relationship."""
147+
relationships.setdefault(table_name, []).append({
148+
"type": "many_to_one",
149+
"fk_column": fk_column,
150+
"ref_table": ref_table,
151+
"ref_column": ref_column,
152+
"child_table_name": table_name,
153+
})
154+
relationships.setdefault(ref_table, []).append({
155+
"type": "one_to_many",
156+
"child_table": table_name,
157+
"fk_column": fk_column,
158+
})
159+
160+
161+
def _get_alter_columns(table) -> List:
162+
"""Get ALTER TABLE columns if they exist."""
163+
if hasattr(table, 'alter') and table.alter:
164+
return table.alter.get("columns", [])
165+
return []
166+
167+
168+
def collect_relationships(tables: List) -> Dict:
169+
"""Collect foreign key relationships between tables."""
170+
relationships = {}
171+
172+
for table in tables:
173+
for column in table.columns:
174+
if column.references and column.references.get("table"):
175+
_add_relationship(
176+
relationships, table.name, column.name,
177+
column.references["table"],
178+
column.references.get("column") or column.name
179+
)
180+
181+
for alter_col in _get_alter_columns(table):
182+
ref_info = alter_col.get("references")
183+
if ref_info and ref_info.get("table"):
184+
_add_relationship(
185+
relationships, table.name, alter_col["name"],
186+
ref_info["table"],
187+
ref_info.get("column") or alter_col["name"]
188+
)
189+
190+
return relationships
191+
192+
141193
def generate_models_file(
142194
data: Dict[str, List],
143195
singular: bool = False,
@@ -147,6 +199,7 @@ def generate_models_file(
147199
defaults_off: Optional[bool] = False,
148200
table_prefix: Optional[str] = "",
149201
table_suffix: Optional[str] = "",
202+
relationships: Optional[bool] = False,
150203
) -> str:
151204
"""method to prepare full file with all Models &"""
152205
models_str = ""
@@ -159,6 +212,11 @@ def generate_models_file(
159212
if data["tables"]:
160213
add_custom_types_to_generator(data["types"], generator)
161214

215+
# Collect relationships if enabled
216+
relationships_map = {}
217+
if relationships:
218+
relationships_map = collect_relationships(data["tables"])
219+
162220
for table in data["tables"]:
163221
models_str += generator.generate_model(
164222
table,
@@ -168,6 +226,7 @@ def generate_models_file(
168226
defaults_off=defaults_off,
169227
table_prefix=table_prefix,
170228
table_suffix=table_suffix,
229+
relationships=relationships_map.get(table.name, []) if relationships else [],
171230
)
172231
header += generator.create_header(
173232
data["tables"], schema=schema_global, models_str=models_str

omymodels/models/sqlalchemy/core.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def __init__(self):
1818
self.postgresql_dialect_cols = set()
1919
self.constraint = False
2020
self.im_index = False
21+
self.relationship_import = False
2122
self.types_mapping = types_mapping
2223
self.templates = st
2324
self.prefix = "sa."
@@ -46,14 +47,16 @@ def generate_model(
4647
singular: bool = True,
4748
exceptions: Optional[List] = None,
4849
schema_global: Optional[bool] = True,
50+
relationships: Optional[List] = None,
4951
*args,
5052
**kwargs,
5153
) -> str:
5254
"""method to prepare one Model defention - name & tablename & columns"""
5355
model = ""
56+
model_name = create_class_name(table.name, singular, exceptions)
5457

5558
model = st.model_template.format(
56-
model_name=create_class_name(table.name, singular, exceptions),
59+
model_name=model_name,
5760
table_name=table.name,
5861
)
5962
for column in table.columns:
@@ -62,8 +65,61 @@ def generate_model(
6265
)
6366
if table.indexes or table.alter or table.checks or not schema_global:
6467
model = logic.add_table_args(self, model, table, schema_global)
68+
69+
# Generate relationships if enabled
70+
if relationships:
71+
model += self._generate_relationships(
72+
relationships, singular, exceptions
73+
)
6574
return model
6675

76+
def _generate_relationships(
77+
self,
78+
relationships: List[Dict],
79+
singular: bool,
80+
exceptions: Optional[List] = None,
81+
) -> str:
82+
"""Generate relationship() lines for the model."""
83+
result = "\n"
84+
self.relationship_import = True
85+
86+
for rel in relationships:
87+
if rel["type"] == "many_to_one":
88+
# Child side: reference to parent
89+
# e.g., posts.user = relationship("Users", back_populates="posts")
90+
ref_table = rel["ref_table"]
91+
fk_column = rel["fk_column"]
92+
child_table_name = rel["child_table_name"]
93+
related_class = create_class_name(ref_table, singular, exceptions)
94+
# Attribute name derived from FK column (user_id -> user)
95+
attr_name = fk_column.replace("_id", "") if fk_column.endswith("_id") else ref_table.lower()
96+
# back_populates points to the collection on the parent (uses child table name)
97+
back_pop_name = child_table_name.lower().replace("-", "_")
98+
back_populates = st.back_populates_template.format(attr_name=back_pop_name)
99+
result += st.relationship_template.format(
100+
attr_name=attr_name,
101+
related_class=related_class,
102+
back_populates=back_populates,
103+
)
104+
elif rel["type"] == "one_to_many":
105+
# Parent side: collection of children
106+
# e.g., users.posts = relationship("Posts", back_populates="user")
107+
child_table = rel["child_table"]
108+
fk_column = rel["fk_column"]
109+
related_class = create_class_name(child_table, singular, exceptions)
110+
# Attribute name is the child table name (as-is, since table names are typically plural)
111+
attr_name = child_table.lower().replace("-", "_")
112+
# back_populates points to the single parent reference on the child
113+
# Derived from FK column (user_id -> user)
114+
back_pop_name = fk_column.replace("_id", "") if fk_column.endswith("_id") else child_table.lower()
115+
back_populates = st.back_populates_template.format(attr_name=back_pop_name)
116+
result += st.relationship_template.format(
117+
attr_name=attr_name,
118+
related_class=related_class,
119+
back_populates=back_populates,
120+
)
121+
return result
122+
67123
def create_header(
68124
self, tables: List[Dict], schema: bool = False, *args, **kwargs
69125
) -> str:
@@ -82,4 +138,6 @@ def create_header(
82138
header += st.unique_cons_import + "\n"
83139
if self.im_index:
84140
header += st.index_import + "\n"
141+
if self.relationship_import:
142+
header += st.relationship_import + "\n"
85143
return header

omymodels/models/sqlalchemy/templates.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,8 @@ class {model_name}(Base):\n
5151

5252
on_delete = ', ondelete="{mode}"'
5353
on_update = ', onupdate="{mode}"'
54+
55+
# relationship templates
56+
relationship_import = "from sqlalchemy.orm import relationship"
57+
relationship_template = ' {attr_name} = relationship("{related_class}"{back_populates})\n'
58+
back_populates_template = ', back_populates="{attr_name}"'

omymodels/models/sqlalchemy_v2/core.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def __init__(self):
2424
self.time_import = False
2525
self.uuid_import = False
2626
self.fk_import = False
27+
self.relationship_import = False
2728
self.types_mapping = types_mapping
2829
self.templates = st
2930
self.prefix = ""
@@ -225,6 +226,7 @@ def generate_model(
225226
singular: bool = True,
226227
exceptions: Optional[List] = None,
227228
schema_global: Optional[bool] = True,
229+
relationships: Optional[List] = None,
228230
*args,
229231
**kwargs,
230232
) -> str:
@@ -242,8 +244,68 @@ def generate_model(
242244
if table.indexes or table.alter or table.checks or not schema_global:
243245
model = self._add_table_args(model, table, schema_global)
244246

247+
# Generate relationships if enabled
248+
if relationships:
249+
model += self._generate_relationships(
250+
relationships, singular, exceptions
251+
)
252+
245253
return model
246254

255+
def _generate_relationships(
256+
self,
257+
relationships: List[Dict],
258+
singular: bool,
259+
exceptions: Optional[List] = None,
260+
) -> str:
261+
"""Generate relationship() lines for the model."""
262+
result = "\n"
263+
self.relationship_import = True
264+
self.typing_imports.add("List")
265+
266+
for rel in relationships:
267+
if rel["type"] == "many_to_one":
268+
# Child side: reference to parent
269+
# e.g., author: Mapped["Authors"] = relationship("Authors", back_populates="books")
270+
ref_table = rel["ref_table"]
271+
fk_column = rel["fk_column"]
272+
child_table_name = rel["child_table_name"]
273+
related_class = create_class_name(ref_table, singular, exceptions)
274+
# Attribute name derived from FK column (author_id -> author)
275+
attr_name = fk_column.replace("_id", "") if fk_column.endswith("_id") else ref_table.lower()
276+
# back_populates points to the collection on the parent (uses child table name)
277+
back_pop_name = child_table_name.lower().replace("-", "_")
278+
back_populates = st.back_populates_template.format(attr_name=back_pop_name)
279+
# Type hint for many-to-one is the related class (quoted for forward ref)
280+
type_hint = f'"{related_class}"'
281+
result += st.relationship_template.format(
282+
attr_name=attr_name,
283+
type_hint=type_hint,
284+
related_class=related_class,
285+
back_populates=back_populates,
286+
)
287+
elif rel["type"] == "one_to_many":
288+
# Parent side: collection of children
289+
# e.g., books: Mapped[List["Books"]] = relationship("Books", back_populates="author")
290+
child_table = rel["child_table"]
291+
fk_column = rel["fk_column"]
292+
related_class = create_class_name(child_table, singular, exceptions)
293+
# Attribute name is the child table name (as-is, since table names are typically plural)
294+
attr_name = child_table.lower().replace("-", "_")
295+
# back_populates points to the single parent reference on the child
296+
# Derived from FK column (author_id -> author)
297+
back_pop_name = fk_column.replace("_id", "") if fk_column.endswith("_id") else child_table.lower()
298+
back_populates = st.back_populates_template.format(attr_name=back_pop_name)
299+
# Type hint for one-to-many is List of related class (quoted for forward ref)
300+
type_hint = f'List["{related_class}"]'
301+
result += st.relationship_template.format(
302+
attr_name=attr_name,
303+
type_hint=type_hint,
304+
related_class=related_class,
305+
back_populates=back_populates,
306+
)
307+
return result
308+
247309
def _add_table_args(
248310
self, model: str, table: Dict, schema_global: bool = True
249311
) -> str:
@@ -320,4 +382,7 @@ def create_header(
320382
if self.im_index:
321383
parts.append(st.index_import + "\n")
322384

385+
if self.relationship_import:
386+
parts.append(st.relationship_import + "\n")
387+
323388
return "".join(parts)

omymodels/models/sqlalchemy_v2/templates.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,8 @@ class {model_name}(Base):
6060

6161
on_delete = ', ondelete="{mode}"'
6262
on_update = ', onupdate="{mode}"'
63+
64+
# relationship templates
65+
relationship_import = "from sqlalchemy.orm import relationship"
66+
relationship_template = ' {attr_name}: Mapped[{type_hint}] = relationship("{related_class}"{back_populates})\n'
67+
back_populates_template = ', back_populates="{attr_name}"'

0 commit comments

Comments
 (0)