Skip to content

Commit 01a8391

Browse files
authored
Add Pydal model conversion support (issue #30) (#80)
- Add convert_models() support for Pydal table definitions - Support conversion to all model types: SQLAlchemy, SQLAlchemy v2, Gino, Pydantic, Pydantic v2, Dataclass, SQLModel - Handle Pydal-specific types: id (primary key), string, text, integer, boolean, datetime, date, float, decimal - Convert 'reference table_name' to ForeignKey - Strip quotes from py_models_parser output - Preserve table names without pluralization (Pydal names are table names) - Add functional tests for all model types - Add integration tests for SQLAlchemy, Pydantic, and Dataclass
1 parent 97b7566 commit 01a8391

File tree

6 files changed

+527
-2
lines changed

6 files changed

+527
-2
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6767
- Works with both `sqlalchemy` and `sqlalchemy_v2` model types
6868
- File naming: `{schema_name}_{base_filename}.py` (e.g., `schema1_models.py`)
6969

70+
**Pydal Model Conversion (issue #30)**
71+
- Convert Pydal table definitions to any supported model type using `convert_models()`
72+
- Supports all output formats: SQLAlchemy, SQLAlchemy v2, Gino, Pydantic, Pydantic v2, Dataclass, SQLModel
73+
- Handles Pydal types: `id`, `string`, `text`, `integer`, `boolean`, `datetime`, `date`, `float`, `decimal`
74+
- Pydal's `id` type maps to primary key
75+
- Pydal's `reference table_name` type maps to foreign key
76+
7077
**SQLModel Improvements**
7178
- Fixed array type generation (issue #66)
7279
- Arrays now properly generate `List[T]` with correct SQLAlchemy ARRAY type

omymodels/converter.py

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,69 @@
88
from omymodels.models.enum import core as enum
99

1010

11+
def _strip_quotes(value: str) -> str:
12+
"""Strip surrounding quotes from a string value."""
13+
if value and isinstance(value, str):
14+
return value.strip("'\"")
15+
return value
16+
17+
18+
def _is_pydal_result(data: List[Dict]) -> bool:
19+
"""Check if parsed data is from Pydal (names contain quotes)."""
20+
if data and data[0].get("name", ""):
21+
name = data[0]["name"]
22+
return name.startswith(("'", '"')) and name.endswith(("'", '"'))
23+
return False
24+
25+
26+
def _process_pydal_type(col_type: str, attr: Dict) -> str:
27+
"""Process Pydal column type and handle special types.
28+
29+
- 'id' type: Pydal's auto-generated primary key (maps to integer + primary_key)
30+
- 'reference table_name': Foreign key to another table (maps to integer + FK)
31+
"""
32+
if col_type == "id":
33+
# Pydal 'id' type is auto-generated primary key
34+
attr["properties"]["primary_key"] = True
35+
return "integer"
36+
37+
if col_type and col_type.startswith("reference "):
38+
ref_table = col_type.split(" ", 1)[1].strip()
39+
# Store reference info for foreign key generation
40+
# All keys expected by add_reference_to_the_column must be present
41+
attr["references"] = {
42+
"table": ref_table,
43+
"column": "id", # Pydal references default to 'id' column
44+
"schema": None,
45+
"on_delete": None,
46+
"on_update": None,
47+
}
48+
return "integer" # Reference fields are integers
49+
50+
return col_type
51+
52+
53+
def _clean_pydal_data(data: List[Dict]) -> List[Dict]:
54+
"""Clean Pydal parsed data by stripping quotes from names and types.
55+
56+
For Pydal, the model name is already the table name, so we set table_name
57+
directly to avoid incorrect pluralization.
58+
"""
59+
for model in data:
60+
table_name = _strip_quotes(model.get("name", ""))
61+
model["name"] = table_name
62+
# For Pydal, the name is already the table name - mark it to skip pluralization
63+
model["table_name"] = table_name
64+
for attr in model.get("attrs", []):
65+
attr["name"] = _strip_quotes(attr.get("name", ""))
66+
if attr.get("type"):
67+
col_type = _strip_quotes(attr["type"])
68+
# Handle special Pydal types (id, reference)
69+
col_type = _process_pydal_type(col_type, attr)
70+
attr["type"] = col_type
71+
return data
72+
73+
1174
def get_primary_keys(columns: List[Dict]) -> List[str]:
1275
primary_keys = []
1376
for column in columns:
@@ -29,7 +92,9 @@ def models_to_meta(data: List[Dict]) -> List[TableMeta]:
2992
types = []
3093
for model in data:
3194
if "Enum" not in model["parents"]:
32-
model["table_name"] = from_class_to_table_name(model["name"])
95+
# Use existing table_name if set (e.g., from Pydal), otherwise derive it
96+
if not model.get("table_name"):
97+
model["table_name"] = from_class_to_table_name(model["name"])
3398
model["columns"] = prepare_columns_data(model["attrs"])
3499
model["properties"]["indexes"] = model["properties"].get("indexes") or []
35100
model["primary_key"] = get_primary_keys(model["columns"])
@@ -43,6 +108,9 @@ def models_to_meta(data: List[Dict]) -> List[TableMeta]:
43108

44109
def convert_models(model_from: str, models_type: str = "gino") -> str:
45110
result = parse(model_from)
111+
# Clean up Pydal parsed data (strip quotes from names/types)
112+
if _is_pydal_result(result):
113+
result = _clean_pydal_data(result)
46114
tables, types = models_to_meta(result)
47115
generator = get_generator_by_type(models_type)
48116
models_str = ""
@@ -56,7 +124,7 @@ def convert_models(model_from: str, models_type: str = "gino") -> str:
56124

57125
for table in tables:
58126
models_str += generator.generate_model(table)
59-
header += generator.create_header(tables)
127+
header += generator.create_header(tables, models_str=models_str)
60128
else:
61129
header += enum.create_header(generator.enum_imports)
62130
models_type = "enum"
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
"""Tests for Pydal model conversion to various output formats."""
2+
3+
from omymodels import convert_models
4+
5+
6+
# === SQLAlchemy Tests ===
7+
8+
9+
def test_basic_pydal_to_sqlalchemy():
10+
"""Test basic Pydal table conversion to SQLAlchemy."""
11+
# Using "id" type which is Pydal's primary key type
12+
pydal_code = (
13+
'''db.define_table("users", Field("id", "id"), '''
14+
'''Field("name", "string"), Field("email", "string"))'''
15+
)
16+
17+
result = convert_models(pydal_code, models_type="sqlalchemy")
18+
19+
assert "class User(Base):" in result
20+
assert "__tablename__ = 'users'" in result
21+
assert "id = sa.Column(sa.Integer(), primary_key=True)" in result
22+
assert "name = sa.Column(sa.String())" in result
23+
assert "email = sa.Column(sa.String())" in result
24+
25+
26+
def test_pydal_to_sqlalchemy_v2():
27+
"""Test Pydal table conversion to SQLAlchemy 2.0 style."""
28+
pydal_code = '''db.define_table("users", Field("id", "id"), Field("name", "string"))'''
29+
30+
result = convert_models(pydal_code, models_type="sqlalchemy_v2")
31+
32+
assert "class User(Base):" in result
33+
assert "__tablename__ = 'users'" in result
34+
assert "primary_key=True" in result
35+
assert "name: Mapped[str | None] = mapped_column(String)" in result
36+
37+
38+
def test_pydal_multiple_tables():
39+
"""Test conversion of multiple Pydal tables."""
40+
pydal_code = '''db.define_table("users", Field("id", "id"), Field("name", "string"))
41+
42+
db.define_table("posts", Field("id", "id"), Field("title", "string"))'''
43+
44+
result = convert_models(pydal_code, models_type="sqlalchemy")
45+
46+
assert "class User(Base):" in result
47+
assert "class Post(Base):" in result
48+
assert "__tablename__ = 'users'" in result
49+
assert "__tablename__ = 'posts'" in result
50+
51+
52+
def test_pydal_foreign_key():
53+
"""Test Pydal reference type conversion to SQLAlchemy ForeignKey."""
54+
pydal_code = '''db.define_table("users", Field("id", "id"), Field("name", "string"))
55+
56+
db.define_table("posts", Field("id", "id"), Field("title", "string"), \
57+
Field("user_id", "reference users"))'''
58+
59+
result = convert_models(pydal_code, models_type="sqlalchemy")
60+
61+
assert "class User(Base):" in result
62+
assert "class Post(Base):" in result
63+
assert "user_id = sa.Column(sa.Integer(), sa.ForeignKey('users.id'))" in result
64+
65+
66+
def test_pydal_foreign_key_v2():
67+
"""Test Pydal reference type conversion to SQLAlchemy v2 ForeignKey."""
68+
pydal_code = '''db.define_table("users", Field("id", "id"), Field("name", "string"))
69+
70+
db.define_table("posts", Field("id", "id"), Field("user_id", "reference users"))'''
71+
72+
result = convert_models(pydal_code, models_type="sqlalchemy_v2")
73+
74+
assert "from sqlalchemy import ForeignKey" in result
75+
assert "user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey('users.id'))" in result
76+
77+
78+
def test_pydal_various_types():
79+
"""Test conversion of various Pydal types."""
80+
pydal_code = '''db.define_table("test_types", Field("id", "id"),
81+
Field("col_string", "string"),
82+
Field("col_text", "text"),
83+
Field("col_integer", "integer"),
84+
Field("col_boolean", "boolean"),
85+
Field("col_datetime", "datetime"),
86+
Field("col_date", "date"),
87+
Field("col_float", "float"),
88+
Field("col_decimal", "decimal"))'''
89+
90+
result = convert_models(pydal_code, models_type="sqlalchemy")
91+
92+
assert "col_string = sa.Column(sa.String())" in result
93+
assert "col_text = sa.Column(sa.Text())" in result
94+
assert "col_integer = sa.Column(sa.Integer())" in result
95+
assert "col_boolean = sa.Column(sa.Boolean())" in result
96+
assert "col_datetime = sa.Column(sa.DateTime())" in result
97+
assert "col_date = sa.Column(sa.Date())" in result
98+
assert "col_float = sa.Column(sa.Float())" in result
99+
assert "col_decimal = sa.Column(sa.Numeric())" in result
100+
101+
102+
def test_pydal_table_name_preserved():
103+
"""Test that Pydal table names are preserved without pluralization."""
104+
pydal_code = '''db.define_table("my_table", Field("id", "id"))'''
105+
106+
result = convert_models(pydal_code, models_type="sqlalchemy")
107+
108+
# Table name should be preserved as 'my_table', not pluralized
109+
assert "__tablename__ = 'my_table'" in result
110+
# Class name should be derived from table name
111+
assert "class MyTable(Base):" in result
112+
113+
114+
def test_pydal_id_type_is_primary_key():
115+
"""Test that Pydal 'id' type creates a primary key."""
116+
pydal_code = '''db.define_table("users", Field("id", "id"), Field("name", "string"))'''
117+
118+
result = convert_models(pydal_code, models_type="sqlalchemy")
119+
120+
assert "primary_key=True" in result
121+
122+
123+
# === Gino Tests ===
124+
125+
126+
def test_pydal_to_gino():
127+
"""Test Pydal table conversion to Gino ORM."""
128+
pydal_code = (
129+
'''db.define_table("users", Field("id", "id"), '''
130+
'''Field("name", "string"), Field("email", "string"))'''
131+
)
132+
133+
result = convert_models(pydal_code, models_type="gino")
134+
135+
assert "from gino import Gino" in result
136+
assert "db = Gino()" in result
137+
assert "class User(db.Model):" in result
138+
assert "__tablename__ = 'users'" in result
139+
assert "id = db.Column(db.Integer(), primary_key=True)" in result
140+
assert "name = db.Column(db.String())" in result
141+
assert "email = db.Column(db.String())" in result
142+
143+
144+
def test_pydal_to_gino_with_foreign_key():
145+
"""Test Pydal reference type conversion to Gino ForeignKey."""
146+
pydal_code = '''db.define_table("users", Field("id", "id"), Field("name", "string"))
147+
148+
db.define_table("posts", Field("id", "id"), Field("user_id", "reference users"))'''
149+
150+
result = convert_models(pydal_code, models_type="gino")
151+
152+
assert "class User(db.Model):" in result
153+
assert "class Post(db.Model):" in result
154+
assert "user_id = db.Column(db.Integer(), db.ForeignKey('users.id'))" in result
155+
156+
157+
# === Pydantic Tests ===
158+
159+
160+
def test_pydal_to_pydantic():
161+
"""Test Pydal table conversion to Pydantic."""
162+
pydal_code = (
163+
'''db.define_table("users", Field("id", "id"), '''
164+
'''Field("name", "string"), Field("is_active", "boolean"))'''
165+
)
166+
167+
result = convert_models(pydal_code, models_type="pydantic")
168+
169+
assert "from pydantic import BaseModel" in result
170+
assert "class User(BaseModel):" in result
171+
assert "id: Optional[int]" in result
172+
assert "name: Optional[str]" in result
173+
assert "is_active: Optional[bool]" in result
174+
175+
176+
def test_pydal_to_pydantic_v2():
177+
"""Test Pydal table conversion to Pydantic v2."""
178+
pydal_code = (
179+
'''db.define_table("users", Field("id", "id"), '''
180+
'''Field("name", "string"), Field("is_active", "boolean"))'''
181+
)
182+
183+
result = convert_models(pydal_code, models_type="pydantic_v2")
184+
185+
assert "from pydantic import BaseModel" in result
186+
assert "class User(BaseModel):" in result
187+
assert "id: int | None" in result
188+
assert "name: str | None" in result
189+
assert "is_active: bool | None" in result
190+
191+
192+
# === Dataclass Tests ===
193+
194+
195+
def test_pydal_to_dataclass():
196+
"""Test Pydal table conversion to Python dataclass."""
197+
pydal_code = (
198+
'''db.define_table("users", Field("id", "id"), '''
199+
'''Field("name", "string"), Field("score", "float"))'''
200+
)
201+
202+
result = convert_models(pydal_code, models_type="dataclass")
203+
204+
assert "from dataclasses import dataclass" in result
205+
assert "@dataclass" in result
206+
assert "class User:" in result
207+
assert "id: int" in result
208+
assert "name: str" in result
209+
assert "score: float" in result
210+
211+
212+
# === SQLModel Tests ===
213+
214+
215+
def test_pydal_to_sqlmodel():
216+
"""Test Pydal table conversion to SQLModel."""
217+
pydal_code = (
218+
'''db.define_table("users", Field("id", "id"), '''
219+
'''Field("name", "string"), Field("email", "string"))'''
220+
)
221+
222+
result = convert_models(pydal_code, models_type="sqlmodel")
223+
224+
assert "from sqlmodel import" in result
225+
assert "SQLModel" in result
226+
assert "class User(SQLModel, table=True):" in result
227+
assert "__tablename__ = 'users'" in result
228+
assert "id: int | None" in result or "id: Optional[int]" in result
229+
assert "name: str | None" in result or "name: Optional[str]" in result
230+
231+
232+
def test_pydal_to_sqlmodel_with_foreign_key():
233+
"""Test Pydal reference type conversion to SQLModel ForeignKey."""
234+
pydal_code = '''db.define_table("users", Field("id", "id"), Field("name", "string"))
235+
236+
db.define_table("posts", Field("id", "id"), Field("user_id", "reference users"))'''
237+
238+
result = convert_models(pydal_code, models_type="sqlmodel")
239+
240+
assert "class User(SQLModel, table=True):" in result
241+
assert "class Post(SQLModel, table=True):" in result
242+
assert "foreign_key='users.id'" in result

tests/integration/converter/__init__.py

Whitespace-only changes.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import importlib
2+
import os
3+
import uuid
4+
from types import ModuleType
5+
from typing import Optional
6+
7+
import pytest
8+
9+
current_path = os.path.dirname(os.path.abspath(__file__))
10+
package = os.path.dirname(os.path.relpath(__file__)).replace("/", ".")
11+
12+
13+
@pytest.fixture
14+
def load_generated_code():
15+
def _inner(code_text: str, module_name: Optional[str] = None) -> ModuleType:
16+
if not module_name:
17+
module_name = f"module_{uuid.uuid1()}"
18+
19+
with open(os.path.join(current_path, f"{module_name}.py"), "w+") as f:
20+
f.write(code_text)
21+
22+
module = importlib.import_module(f"{package}.{module_name}")
23+
24+
return module
25+
26+
yield _inner

0 commit comments

Comments
 (0)