Skip to content

Commit d35760f

Browse files
authored
Add max_length validation for VARCHAR/CHAR in Pydantic models (issue … (#76)
* Add max_length validation for VARCHAR/CHAR in Pydantic models (issue #48) - Generate Field(max_length=N) for VARCHAR(N) and CHAR(N) columns - Works for both pydantic and pydantic_v2 generators - Nullable fields with max_length get Field(default=None, max_length=N) - Fields with default values preserve defaults in Field() - Add tests for max_length generation * Add integration tests for max_length validation (issue #48)
1 parent 7efa358 commit d35760f

7 files changed

Lines changed: 259 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4242
- `table_prefix` and `table_suffix` parameters for class name customization
4343
- Boolean defaults 0/1 converted to False/True
4444
- Expanded `datetime_now_check` with more SQL datetime keywords
45+
- VARCHAR(n) and CHAR(n) now generate `Field(max_length=n)` for Pydantic validation (issue #48)
4546

4647
**SQLAlchemy 2.0 Support (issue #49)**
4748
- New `sqlalchemy_v2` models type with modern SQLAlchemy 2.0 syntax

omymodels/models/pydantic/core.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
from omymodels.models.pydantic.types import types_mapping
1111
from omymodels.types import big_integer_types, integer_types, string_types, text_types
1212

13+
# Types that support max_length constraint
14+
MAX_LENGTH_TYPES = string_types
15+
1316

1417
class ModelGenerator:
1518
def __init__(self):
@@ -74,9 +77,18 @@ def get_not_custom_type(self, type_str: str) -> str:
7477
self.typing_imports.add("List")
7578
return _type
7679

80+
def _should_add_max_length(self, column: Column) -> bool:
81+
"""Check if column should have max_length constraint."""
82+
if not column.size:
83+
return False
84+
# Only add max_length for string types (varchar, char, etc.), not text
85+
original_type = column.type.lower().split("[")[0]
86+
return original_type in MAX_LENGTH_TYPES
87+
7788
def generate_attr(self, column: Column, defaults_off: bool) -> str:
7889
_type = None
7990
original_type = column.type # Keep original for array detection
91+
max_length = column.size if self._should_add_max_length(column) else None
8092

8193
if column.nullable:
8294
self.typing_imports.add("Optional")
@@ -99,13 +111,20 @@ def generate_attr(self, column: Column, defaults_off: bool) -> str:
99111
arg_name = column.name
100112
field_params = None
101113

102-
# Check if we need Field() for alias or generated column
114+
# Check if we need Field() for alias, generated column, or max_length
103115
generated_as = getattr(column, "generated_as", None)
104-
if not self._is_valid_identifier(column.name) or generated_as is not None:
105-
field_params = self._get_field_params(column, defaults_off)
116+
needs_field = (
117+
not self._is_valid_identifier(column.name)
118+
or generated_as is not None
119+
or max_length is not None
120+
)
121+
122+
if needs_field:
123+
field_params = self._get_field_params(column, defaults_off, max_length)
106124
if field_params:
107125
self.imports.add("Field")
108-
arg_name = self._generate_valid_identifier(column.name)
126+
if not self._is_valid_identifier(column.name):
127+
arg_name = self._generate_valid_identifier(column.name)
109128
else:
110129
if column.default is not None and not defaults_off:
111130
field_params = self._get_default_value_string(column)
@@ -118,20 +137,28 @@ def generate_attr(self, column: Column, defaults_off: bool) -> str:
118137

119138
return column_str
120139

121-
def _get_field_params(self, column: Column, defaults_off: bool) -> str:
140+
def _get_field_params(
141+
self, column: Column, defaults_off: bool, max_length: int = None
142+
) -> str:
122143
params = []
123144

124145
if not self._is_valid_identifier(column.name):
125146
params.append(f'alias="{column.name}"')
126147

127-
if column.default is not None and not defaults_off:
148+
# For nullable fields with max_length, add default=None
149+
if column.nullable and max_length is not None and not defaults_off:
150+
params.append("default=None")
151+
elif column.default is not None and not defaults_off:
128152
if default_value := self._get_default_value_string(column):
129153
params.append(f"default{default_value.replace(' ', '')}")
130154

131155
generated_as = getattr(column, "generated_as", None)
132156
if generated_as is not None:
133157
params.append("exclude=True")
134158

159+
if max_length is not None:
160+
params.append(f"max_length={max_length}")
161+
135162
if params:
136163
return f" = Field({', '.join(params)})"
137164
return ""

omymodels/models/pydantic_v2/core.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
from omymodels.helpers import create_class_name, datetime_now_check
77
from omymodels.models.pydantic_v2 import templates as pt
88
from omymodels.models.pydantic_v2.types import types_mapping
9-
from omymodels.types import datetime_types
9+
from omymodels.types import datetime_types, string_types
10+
11+
# Types that support max_length constraint
12+
MAX_LENGTH_TYPES = string_types
1013

1114

1215
class ModelGenerator:
@@ -49,8 +52,17 @@ def get_not_custom_type(self, column: Column) -> str:
4952
self.uuid_import = True
5053
return _type
5154

55+
def _should_add_max_length(self, column: Column) -> bool:
56+
"""Check if column should have max_length constraint."""
57+
if not column.size:
58+
return False
59+
# Only add max_length for string types (varchar, char, etc.), not text
60+
original_type = column.type.lower().split("[")[0]
61+
return original_type in MAX_LENGTH_TYPES
62+
5263
def generate_attr(self, column: Column, defaults_off: bool) -> str:
5364
_type = None
65+
max_length = column.size if self._should_add_max_length(column) else None
5466

5567
# Pydantic v2 uses X | None syntax
5668
if column.nullable:
@@ -65,14 +77,43 @@ def generate_attr(self, column: Column, defaults_off: bool) -> str:
6577

6678
column_str = column_str.format(arg_name=column.name, type=_type)
6779

68-
if column.default is not None and not defaults_off:
80+
# Handle max_length with Field()
81+
if max_length is not None:
82+
self.imports.add("Field")
83+
field_params = []
84+
# Handle defaults
85+
if column.nullable and not defaults_off:
86+
field_params.append("default=None")
87+
elif column.default is not None and not defaults_off:
88+
default_val = self._get_default_value(column)
89+
if default_val:
90+
field_params.append(f"default={default_val}")
91+
field_params.append(f"max_length={max_length}")
92+
column_str += f" = Field({', '.join(field_params)})"
93+
elif column.default is not None and not defaults_off:
6994
column_str = self.add_default_values(column_str, column)
7095
elif column.nullable and not defaults_off:
7196
# Nullable fields without explicit default should default to None
7297
column_str += pt.pydantic_default_attr.format(default="None")
7398

7499
return column_str
75100

101+
def _get_default_value(self, column: Column) -> str:
102+
"""Get formatted default value for Field()."""
103+
if column.default is None or str(column.default).upper() == "NULL":
104+
return ""
105+
106+
# Handle datetime default values
107+
if column.type.upper() in datetime_types:
108+
if datetime_now_check(column.default.lower()):
109+
return "datetime.datetime.now()"
110+
111+
# Add quotes for string defaults if not already quoted
112+
default_val = column.default
113+
if isinstance(default_val, str) and "'" not in default_val and '"' not in default_val:
114+
default_val = f"'{default_val}'"
115+
return default_val
116+
76117
@staticmethod
77118
def add_default_values(column_str: str, column: Column) -> str:
78119
# Handle datetime default values

tests/functional/generator/test_pydantic_models.py

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,17 @@ def test_pydantic_models_generator():
1919

2020
expected = """from datetime import datetime
2121
from typing import Optional
22-
from pydantic import BaseModel
22+
from pydantic import BaseModel, Field
2323
2424
2525
class UserHistory(BaseModel):
2626
runid: Optional[float]
2727
job_id: Optional[float]
28-
id: str
29-
user: str
30-
status: str
28+
id: str = Field(max_length=100)
29+
user: str = Field(max_length=100)
30+
status: str = Field(max_length=10)
3131
event_time: datetime = datetime.now()
32-
comment: str = 'none'
32+
comment: str = Field(default='none', max_length=1000)
3333
"""
3434
assert result == expected
3535

@@ -320,3 +320,53 @@ class TestDefaults(BaseModel):
320320
col_timestamp: Optional[datetime]
321321
"""
322322
assert expected == result["code"]
323+
324+
325+
def test_pydantic_varchar_max_length():
326+
"""Test that VARCHAR(n) generates Field(max_length=n).
327+
328+
Regression test for issue #48.
329+
"""
330+
ddl = """
331+
CREATE TABLE users (
332+
id SERIAL PRIMARY KEY,
333+
name VARCHAR(100) NOT NULL,
334+
email VARCHAR(255),
335+
bio TEXT
336+
);
337+
"""
338+
result = create_models(ddl, models_type="pydantic")
339+
expected = """from typing import Optional
340+
from pydantic import BaseModel, Field
341+
342+
343+
class Users(BaseModel):
344+
id: int
345+
name: str = Field(max_length=100)
346+
email: Optional[str] = Field(default=None, max_length=255)
347+
bio: Optional[str]
348+
"""
349+
assert expected == result["code"]
350+
351+
352+
def test_pydantic_char_max_length():
353+
"""Test that CHAR(n) generates Field(max_length=n).
354+
355+
Regression test for issue #48.
356+
"""
357+
ddl = """
358+
CREATE TABLE codes (
359+
code CHAR(10) NOT NULL,
360+
description VARCHAR(200)
361+
);
362+
"""
363+
result = create_models(ddl, models_type="pydantic")
364+
expected = """from typing import Optional
365+
from pydantic import BaseModel, Field
366+
367+
368+
class Codes(BaseModel):
369+
code: str = Field(max_length=10)
370+
description: Optional[str] = Field(default=None, max_length=200)
371+
"""
372+
assert expected == result["code"]

tests/functional/generator/test_pydantic_v2_models.py

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,18 @@ def test_pydantic_v2_models_generator():
2121
expected = """from __future__ import annotations
2222
2323
import datetime
24-
from pydantic import BaseModel
24+
from pydantic import BaseModel, Field
2525
2626
2727
class UserHistory(BaseModel):
2828
2929
runid: float | None = None
3030
job_id: float | None = None
31-
id: str
32-
user: str
33-
status: str
31+
id: str = Field(max_length=100)
32+
user: str = Field(max_length=100)
33+
status: str = Field(max_length=10)
3434
event_time: datetime.datetime = datetime.datetime.now()
35-
comment: str = 'none'
35+
comment: str = Field(default='none', max_length=1000)
3636
"""
3737
assert result == expected
3838

@@ -241,3 +241,57 @@ class OptionalData(BaseModel):
241241
active: bool | None = None
242242
"""
243243
assert expected == result
244+
245+
246+
def test_pydantic_v2_varchar_max_length():
247+
"""Test that VARCHAR(n) generates Field(max_length=n) in Pydantic v2.
248+
249+
Regression test for issue #48.
250+
"""
251+
ddl = """
252+
CREATE TABLE users (
253+
id SERIAL PRIMARY KEY,
254+
name VARCHAR(100) NOT NULL,
255+
email VARCHAR(255),
256+
bio TEXT
257+
);
258+
"""
259+
result = create_models(ddl, models_type="pydantic_v2")
260+
expected = """from __future__ import annotations
261+
262+
from pydantic import BaseModel, Field
263+
264+
265+
class Users(BaseModel):
266+
267+
id: int
268+
name: str = Field(max_length=100)
269+
email: str | None = Field(default=None, max_length=255)
270+
bio: str | None = None
271+
"""
272+
assert expected == result["code"]
273+
274+
275+
def test_pydantic_v2_char_max_length():
276+
"""Test that CHAR(n) generates Field(max_length=n) in Pydantic v2.
277+
278+
Regression test for issue #48.
279+
"""
280+
ddl = """
281+
CREATE TABLE codes (
282+
code CHAR(10) NOT NULL,
283+
description VARCHAR(200)
284+
);
285+
"""
286+
result = create_models(ddl, models_type="pydantic_v2")
287+
expected = """from __future__ import annotations
288+
289+
from pydantic import BaseModel, Field
290+
291+
292+
class Codes(BaseModel):
293+
294+
code: str = Field(max_length=10)
295+
description: str | None = Field(default=None, max_length=200)
296+
"""
297+
assert expected == result["code"]

tests/integration/pydantic/test_pydantic.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import os
22

3+
import pytest
4+
35
from omymodels import create_models
46

57

@@ -31,3 +33,36 @@ def test_pydantic_models_are_working_as_expected(load_generated_code) -> None:
3133
assert used_model
3234

3335
os.remove(os.path.abspath(module.__file__))
36+
37+
38+
def test_pydantic_max_length_validation(load_generated_code) -> None:
39+
"""Integration test: verify max_length constraint is enforced (issue #48)."""
40+
from pydantic import ValidationError
41+
42+
ddl = """
43+
CREATE TABLE users (
44+
id SERIAL PRIMARY KEY,
45+
name VARCHAR(10) NOT NULL,
46+
email VARCHAR(50)
47+
);
48+
"""
49+
result = create_models(ddl, models_type="pydantic")["code"]
50+
51+
module = load_generated_code(result)
52+
53+
# Valid data within max_length
54+
user = module.Users(id=1, name="John", email="john@example.com")
55+
assert user.name == "John"
56+
assert user.email == "john@example.com"
57+
58+
# Name exceeds max_length of 10
59+
with pytest.raises(ValidationError) as exc_info:
60+
module.Users(id=2, name="A" * 11, email="test@example.com")
61+
assert "name" in str(exc_info.value)
62+
63+
# Email exceeds max_length of 50
64+
with pytest.raises(ValidationError) as exc_info:
65+
module.Users(id=3, name="Jane", email="a" * 51)
66+
assert "email" in str(exc_info.value)
67+
68+
os.remove(os.path.abspath(module.__file__))

0 commit comments

Comments
 (0)