Skip to content

Commit 97b7566

Browse files
authored
Add split_by_schema for separate schema files (issue #40) (#79)
- Add new `split_by_schema` parameter to `create_models()` - Generate separate files per database schema with schema-specific Base classes - Schema names converted to PascalCase Base names (e.g., 'my_schema' -> 'MySchemaBase') - Tables without explicit schema use default 'Base' class - Works with both `sqlalchemy` and `sqlalchemy_v2` model types - Add functional tests for both model types - Update CHANGELOG with feature documentation
1 parent 86bb881 commit 97b7566

7 files changed

Lines changed: 224 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6060
- Works with both `sqlalchemy` and `sqlalchemy_v2` model types
6161
- For `sqlalchemy_v2`: uses `Mapped[List[T]]` for one-to-many and `Mapped[T]` for many-to-one
6262

63+
**Schema-Separated Model Files (issue #40)**
64+
- New `split_by_schema` parameter for `create_models()` to generate separate files per database schema
65+
- Each schema gets its own file with a schema-specific Base class (e.g., `Schema1Base`)
66+
- Tables without explicit schema go to a file with the default `Base` class
67+
- Works with both `sqlalchemy` and `sqlalchemy_v2` model types
68+
- File naming: `{schema_name}_{base_filename}.py` (e.g., `schema1_models.py`)
69+
6370
**SQLModel Improvements**
6471
- Fixed array type generation (issue #66)
6572
- Arrays now properly generate `List[T]` with correct SQLAlchemy ARRAY type

omymodels/from_ddl.py

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def create_models(
4545
table_prefix: Optional[str] = "",
4646
table_suffix: Optional[str] = "",
4747
relationships: Optional[bool] = False,
48+
split_by_schema: Optional[bool] = False,
4849
):
4950
"""models_type can be: "gino", "dataclass", "pydantic" """
5051
# extract data from ddl file
@@ -56,7 +57,28 @@ def create_models(
5657
sys.exit(0)
5758
else:
5859
raise NoTablesError()
59-
# generate code
60+
61+
# Handle split_by_schema mode
62+
if split_by_schema:
63+
output = generate_models_by_schema(
64+
data,
65+
singular,
66+
naming_exceptions,
67+
models_type,
68+
defaults_off,
69+
table_prefix=table_prefix,
70+
table_suffix=table_suffix,
71+
relationships=relationships,
72+
)
73+
if dump:
74+
save_models_by_schema(output, dump_path)
75+
else:
76+
for schema_name, code in output.items():
77+
print(f"# === {schema_name} ===")
78+
print(code)
79+
return {"metadata": data, "code": output}
80+
81+
# generate code (single file mode)
6082
output = generate_models_file(
6183
data,
6284
singular,
@@ -140,6 +162,104 @@ def save_models_to_file(models: str, dump_path: str) -> None:
140162
f.write(models)
141163

142164

165+
def save_models_by_schema(models_by_schema: Dict[str, str], dump_path: str) -> None:
166+
"""Save models split by schema to separate files."""
167+
folder = os.path.dirname(dump_path)
168+
base_name = os.path.basename(dump_path)
169+
name_without_ext = os.path.splitext(base_name)[0]
170+
171+
if folder:
172+
os.makedirs(folder, exist_ok=True)
173+
174+
for schema_name, code in models_by_schema.items():
175+
file_name = f"{schema_name}_{name_without_ext}.py" if schema_name else f"{name_without_ext}.py"
176+
file_path = os.path.join(folder, file_name) if folder else file_name
177+
with open(file_path, "w+") as f:
178+
f.write(code)
179+
180+
181+
def group_tables_by_schema(tables: List) -> Dict[str, List]:
182+
"""Group tables by their schema attribute."""
183+
grouped = {}
184+
for table in tables:
185+
schema = table.table_schema or ""
186+
grouped.setdefault(schema, []).append(table)
187+
return grouped
188+
189+
190+
def _schema_to_base_name(schema: str) -> str:
191+
"""Convert schema name to Base class name (e.g., 'my_schema' -> 'MySchemaBase')."""
192+
if not schema:
193+
return "Base"
194+
# Convert snake_case or kebab-case to PascalCase
195+
parts = schema.replace("-", "_").split("_")
196+
pascal = "".join(part.capitalize() for part in parts)
197+
return f"{pascal}Base"
198+
199+
200+
def generate_models_by_schema(
201+
data: Dict[str, List],
202+
singular: bool = False,
203+
exceptions: Optional[List] = None,
204+
models_type: str = "gino",
205+
defaults_off: Optional[bool] = False,
206+
table_prefix: Optional[str] = "",
207+
table_suffix: Optional[str] = "",
208+
relationships: Optional[bool] = False,
209+
) -> Dict[str, str]:
210+
"""Generate models split by schema, each with its own Base class."""
211+
from omymodels.generators import get_generator_by_type, render_jinja2_template
212+
213+
results = {}
214+
tables_by_schema = group_tables_by_schema(data["tables"])
215+
216+
# Collect relationships across all tables if enabled
217+
relationships_map = {}
218+
if relationships:
219+
relationships_map = collect_relationships(data["tables"])
220+
221+
for schema_name, tables in tables_by_schema.items():
222+
generator = get_generator_by_type(models_type)
223+
add_custom_types_to_generator(data["types"], generator)
224+
225+
models_str = ""
226+
header = ""
227+
228+
# Include types only in the first (or default) schema file
229+
if data["types"] and schema_name == "":
230+
types_generator = enum.ModelGenerator(data["types"])
231+
models_str += types_generator.create_types()
232+
header += types_generator.create_header()
233+
234+
for table in tables:
235+
models_str += generator.generate_model(
236+
table,
237+
singular,
238+
exceptions,
239+
schema_global=False, # Always include schema in __table_args__
240+
defaults_off=defaults_off,
241+
table_prefix=table_prefix,
242+
table_suffix=table_suffix,
243+
relationships=relationships_map.get(table.name, []) if relationships else [],
244+
)
245+
246+
header += generator.create_header(tables, schema=False, models_str=models_str)
247+
248+
# Generate code with schema-specific Base name
249+
base_name = _schema_to_base_name(schema_name)
250+
output = render_jinja2_template(
251+
models_type, models_str, header, base_name=base_name
252+
)
253+
254+
# Replace class inheritance from Base to custom base name
255+
if base_name != "Base":
256+
output = output.replace("(Base):", f"({base_name}):")
257+
258+
results[schema_name] = output
259+
260+
return results
261+
262+
143263
def _add_relationship(
144264
relationships: Dict, table_name: str, fk_column: str, ref_table: str, ref_column: str
145265
):

omymodels/generators.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,16 @@ def get_supported_models() -> List[str]:
8585
return list(list_generators().keys())
8686

8787

88-
def render_jinja2_template(models_type: str, models: str, headers: str) -> str:
88+
def render_jinja2_template(
89+
models_type: str, models: str, headers: str, base_name: str = "Base"
90+
) -> str:
8991
"""Render Jinja2 template for model output.
9092
9193
Args:
9294
models_type: Generator type name
9395
models: Generated model code
9496
headers: Generated header/imports code
97+
base_name: Name for the Base class (default: "Base")
9598
9699
Returns:
97100
Rendered template as string
@@ -107,5 +110,5 @@ def render_jinja2_template(models_type: str, models: str, headers: str) -> str:
107110
with open(template_file) as t:
108111
template = t.read()
109112
template = Template(template)
110-
params = {"models": models, "headers": headers}
113+
params = {"models": models, "headers": headers, "base_name": base_name}
111114
return template.render(**params)

omymodels/models/sqlalchemy/sqlalchemy.jinja2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ import sqlalchemy as sa
22
from sqlalchemy.ext.declarative import declarative_base
33
{{ headers }}
44

5-
Base = declarative_base()
5+
{{ base_name }} = declarative_base()
66
{{ models }}

omymodels/models/sqlalchemy_v2/sqlalchemy_v2.jinja2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@ from sqlalchemy import (
55
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
66
{{ headers }}
77

8-
class Base(DeclarativeBase):
8+
class {{ base_name }}(DeclarativeBase):
99
pass
1010
{{ models }}

tests/functional/generator/test_sqlalchemy.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,3 +476,68 @@ class Comments(Base):
476476
"""
477477
result = create_models(ddl, models_type="sqlalchemy", relationships=True)["code"]
478478
assert result == expected
479+
480+
481+
def test_split_by_schema():
482+
"""Test that split_by_schema generates separate files per schema with custom Base."""
483+
ddl = """
484+
CREATE SCHEMA schema1;
485+
CREATE SCHEMA schema2;
486+
487+
CREATE TABLE schema1.users (
488+
id int PRIMARY KEY,
489+
name varchar NOT NULL
490+
);
491+
492+
CREATE TABLE schema2.orders (
493+
id int PRIMARY KEY,
494+
total decimal(10,2)
495+
);
496+
"""
497+
result = create_models(ddl, models_type="sqlalchemy", split_by_schema=True, dump=False)
498+
code = result["code"]
499+
500+
# Should have two schemas
501+
assert "schema1" in code
502+
assert "schema2" in code
503+
504+
# Check schema1 output
505+
schema1_code = code["schema1"]
506+
assert "Schema1Base = declarative_base()" in schema1_code
507+
assert "class Users(Schema1Base):" in schema1_code
508+
assert 'dict(schema="schema1")' in schema1_code
509+
510+
# Check schema2 output
511+
schema2_code = code["schema2"]
512+
assert "Schema2Base = declarative_base()" in schema2_code
513+
assert "class Orders(Schema2Base):" in schema2_code
514+
assert 'dict(schema="schema2")' in schema2_code
515+
516+
517+
def test_split_by_schema_with_no_schema_tables():
518+
"""Test split_by_schema handles tables without explicit schema."""
519+
ddl = """
520+
CREATE SCHEMA myschema;
521+
522+
CREATE TABLE myschema.users (
523+
id int PRIMARY KEY
524+
);
525+
526+
CREATE TABLE public_table (
527+
id int PRIMARY KEY
528+
);
529+
"""
530+
result = create_models(ddl, models_type="sqlalchemy", split_by_schema=True, dump=False)
531+
code = result["code"]
532+
533+
# Should have myschema and empty string for tables without schema
534+
assert "myschema" in code
535+
assert "" in code
536+
537+
# Check myschema output
538+
assert "MyschemaBase = declarative_base()" in code["myschema"]
539+
assert "class Users(MyschemaBase):" in code["myschema"]
540+
541+
# Check default schema output (no schema)
542+
assert "Base = declarative_base()" in code[""]
543+
assert "class PublicTable(Base):" in code[""]

tests/functional/generator/test_sqlalchemy_v2.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,3 +250,27 @@ def test_relationships_multiple_foreign_keys():
250250
# Check child relationships
251251
assert 'user: Mapped["Users"] = relationship("Users", back_populates="comments")' in code
252252
assert 'post: Mapped["Posts"] = relationship("Posts", back_populates="comments")' in code
253+
254+
255+
def test_split_by_schema():
256+
"""Test split_by_schema with SQLAlchemy 2.0 style models."""
257+
ddl = """
258+
CREATE SCHEMA schema1;
259+
260+
CREATE TABLE schema1.users (
261+
id int PRIMARY KEY,
262+
name varchar NOT NULL
263+
);
264+
"""
265+
result = create_models(ddl, models_type="sqlalchemy_v2", split_by_schema=True, dump=False)
266+
code = result["code"]
267+
268+
# Should have schema1
269+
assert "schema1" in code
270+
271+
# Check schema1 output has SQLAlchemy 2.0 style with custom Base
272+
schema1_code = code["schema1"]
273+
assert "class Schema1Base(DeclarativeBase):" in schema1_code
274+
assert "class Users(Schema1Base):" in schema1_code
275+
assert "id: Mapped[int]" in schema1_code
276+
assert 'dict(schema="schema1")' in schema1_code

0 commit comments

Comments
 (0)