Skip to content
Open
44 changes: 44 additions & 0 deletions sqlmodel/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
)

from pydantic import BaseModel, Discriminator, EmailStr
from pydantic.fields import Deprecated as Deprecated
from pydantic.fields import FieldInfo as PydanticFieldInfo
from sqlalchemy import (
Boolean,
Expand Down Expand Up @@ -251,8 +252,12 @@ def Field(
validation_alias: str | None = None,
serialization_alias: str | None = None,
title: str | None = None,
field_title_generator: Callable[[str, PydanticFieldInfo], str] | None = None,
description: str | None = None,
examples: list[Any] | None = None,
deprecated: Deprecated | str | bool | None = None,
exclude: Set[int | str] | Mapping[int | str, Any] | Any = None,
exclude_if: Callable[[Any], bool] | None = None,
include: Set[int | str] | Mapping[int | str, Any] | Any = None,
const: bool | None = None,
gt: float | None = None,
Expand All @@ -275,6 +280,7 @@ def Field(
max_length: int | None = None,
allow_mutation: bool = True,
regex: str | None = None,
strict: bool | None = None,
discriminator: str | Discriminator | None = None,
repr: bool = True,
primary_key: bool | UndefinedType = Undefined,
Expand All @@ -300,8 +306,12 @@ def Field(
validation_alias: str | None = None,
serialization_alias: str | None = None,
title: str | None = None,
field_title_generator: Callable[[str, PydanticFieldInfo], str] | None = None,
description: str | None = None,
examples: list[Any] | None = None,
deprecated: Deprecated | str | bool | None = None,
exclude: Set[int | str] | Mapping[int | str, Any] | Any = None,
exclude_if: Callable[[Any], bool] | None = None,
include: Set[int | str] | Mapping[int | str, Any] | Any = None,
const: bool | None = None,
gt: float | None = None,
Expand All @@ -324,6 +334,7 @@ def Field(
max_length: int | None = None,
allow_mutation: bool = True,
regex: str | None = None,
strict: bool | None = None,
discriminator: str | Discriminator | None = None,
repr: bool = True,
primary_key: bool | UndefinedType = Undefined,
Expand Down Expand Up @@ -358,8 +369,12 @@ def Field(
validation_alias: str | None = None,
serialization_alias: str | None = None,
title: str | None = None,
field_title_generator: Callable[[str, PydanticFieldInfo], str] | None = None,
description: str | None = None,
examples: list[Any] | None = None,
deprecated: Deprecated | str | bool | None = None,
exclude: Set[int | str] | Mapping[int | str, Any] | Any = None,
exclude_if: Callable[[Any], bool] | None = None,
include: Set[int | str] | Mapping[int | str, Any] | Any = None,
const: bool | None = None,
gt: float | None = None,
Expand All @@ -382,6 +397,7 @@ def Field(
max_length: int | None = None,
allow_mutation: bool = True,
regex: str | None = None,
strict: bool | None = None,
discriminator: str | Discriminator | None = None,
repr: bool = True,
sa_column: Column[Any] | UndefinedType = Undefined,
Expand All @@ -397,8 +413,12 @@ def Field(
validation_alias: str | None = None,
serialization_alias: str | None = None,
title: str | None = None,
field_title_generator: Callable[[str, PydanticFieldInfo], str] | None = None,
description: str | None = None,
examples: list[Any] | None = None,
deprecated: Deprecated | str | bool | None = None,
exclude: Set[int | str] | Mapping[int | str, Any] | Any = None,
exclude_if: Callable[[Any], bool] | None = None,
include: Set[int | str] | Mapping[int | str, Any] | Any = None,
const: bool | None = None,
gt: float | None = None,
Expand All @@ -421,6 +441,7 @@ def Field(
max_length: int | None = None,
allow_mutation: bool = True,
regex: str | None = None,
strict: bool | None = None,
discriminator: str | Discriminator | None = None,
repr: bool = True,
primary_key: bool | UndefinedType = Undefined,
Expand All @@ -437,6 +458,17 @@ def Field(
) -> Any:
current_schema_extra = schema_extra or {}

for param_name in (
"strict",
"examples",
"deprecated",
"exclude_if",
"field_title_generator",
):
if param_name in current_schema_extra:
msg = f"Pass `{param_name}` parameter directly to Field instead of passing it via `schema_extra`"
warnings.warn(msg, DeprecationWarning, stacklevel=2)

if min_items is not None:
warnings.warn(MIN_ITEMS_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
if min_length is None:
Expand All @@ -449,12 +481,23 @@ def Field(
# Extract possible alias settings from schema_extra so we can control precedence
schema_validation_alias = current_schema_extra.pop("validation_alias", None)
schema_serialization_alias = current_schema_extra.pop("serialization_alias", None)
current_strict = strict or current_schema_extra.pop("strict", None)
current_examples = examples or current_schema_extra.pop("examples", None)
current_deprecated = deprecated or current_schema_extra.pop("deprecated", None)
current_exclude_if = exclude_if or current_schema_extra.pop("exclude_if", None)
current_field_title_generator = field_title_generator or current_schema_extra.pop(
"field_title_generator", None
)
field_info_kwargs = {
"alias": alias,
"title": title,
"description": description,
"examples": current_examples,
"deprecated": current_deprecated,
"exclude": exclude,
"exclude_if": current_exclude_if,
"include": include,
"field_title_generator": current_field_title_generator,
"const": const,
"gt": gt,
"ge": ge,
Expand All @@ -468,6 +511,7 @@ def Field(
"max_length": max_length,
"allow_mutation": allow_mutation,
"regex": regex,
"strict": current_strict,
"discriminator": discriminator,
"repr": repr,
"primary_key": primary_key,
Expand Down
208 changes: 207 additions & 1 deletion tests/test_pydantic/test_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@

import pytest
from pydantic import ValidationError
from sqlmodel import Discriminator, Field, SQLModel, Tag
from sqlmodel import Discriminator, Field, Session, SQLModel, Tag, create_engine
from sqlmodel._compat import PYDANTIC_MINOR_VERSION


def test_decimal():
Expand Down Expand Up @@ -88,6 +89,211 @@ class Model(SQLModel):
assert "foo=" not in repr(instance)


def test_strict_true():
class Model(SQLModel):
id: int | None = Field(default=None, primary_key=True)
val: int
val_strict: int = Field(strict=True)

class ModelDB(Model, table=True):
pass

Model(val=123, val_strict=456)
Model(val="123", val_strict=456)

with pytest.raises(ValidationError):
Model(val=123, val_strict="456")

engine = create_engine("sqlite://", echo=True)

SQLModel.metadata.create_all(engine)

model = ModelDB(val=123, val_strict=456)
with Session(engine) as session:
session.add(model)
session.commit()
session.refresh(model)

assert model.val == 123
assert model.val_strict == 456


def test_strict_table_model():
class Model(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
val_strict: int = Field(strict=True)

engine = create_engine("sqlite://", echo=True)

SQLModel.metadata.create_all(engine)

model = Model(val_strict=456)
with Session(engine) as session:
session.add(model)
session.commit()
session.refresh(model)

assert model.val_strict == 456


@pytest.mark.parametrize("strict", [None, False])
def test_strict_false(strict: int | None):
class Model(SQLModel):
val: int = Field(strict=strict)

Model(val=123)
Model(val="123")


def test_strict_via_schema_extra(): # Current workaround. Remove after some time
with pytest.warns(
DeprecationWarning,
match="Pass `strict` parameter directly to Field instead of passing it via `schema_extra`",
):

class Model(SQLModel):
val: int
val_strict: int = Field(schema_extra={"strict": True})

Model(val=123, val_strict=456)
Model(val="123", val_strict=456)

with pytest.raises(ValidationError):
Model(val=123, val_strict="456")


def test_examples():
class Model(SQLModel):
name: str = Field(examples=["Alice", "Bob"])

model_schema = Model.model_json_schema()
assert model_schema["properties"]["name"]["examples"] == ["Alice", "Bob"]


def test_examples_via_schema_extra(): # Current workaround. Remove after some time
with pytest.warns(
DeprecationWarning,
match="Pass `examples` parameter directly to Field instead of passing it via `schema_extra`",
):

class Model(SQLModel):
name: str = Field(schema_extra={"examples": ["Alice", "Bob"]})

model_schema = Model.model_json_schema()
assert model_schema["properties"]["name"]["examples"] == ["Alice", "Bob"]


def test_deprecated():
class Model(SQLModel):
old_field: str = Field(deprecated=True)
another_old_field: str = Field(deprecated="This field is deprecated")

model_schema = Model.model_json_schema()
assert model_schema["properties"]["old_field"]["deprecated"] is True
assert model_schema["properties"]["another_old_field"]["deprecated"] is True


def test_deprecated_via_schema_extra(): # Current workaround. Remove after some time
with pytest.warns(
DeprecationWarning,
match="Pass `deprecated` parameter directly to Field instead of passing it via `schema_extra`",
):

class Model(SQLModel):
old_field: str = Field(schema_extra={"deprecated": True})
another_old_field: str = Field(
schema_extra={"deprecated": "This field is deprecated"}
)

model_schema = Model.model_json_schema()
assert model_schema["properties"]["old_field"]["deprecated"] is True
assert model_schema["properties"]["another_old_field"]["deprecated"] is True


@pytest.mark.skipif(
PYDANTIC_MINOR_VERSION < (2, 12),
reason="exlude_if requires Pydantic 2.12+",
)
def test_exclude_if():
def is_empty_string(value: Any) -> bool:
return value == ""

class Model(SQLModel):
name: str = Field(exclude_if=is_empty_string)
age: int

model1 = Model(name="Alice", age=30)
model2 = Model(name="", age=25)

dict1 = model1.model_dump()
dict2 = model2.model_dump()

assert "name" in dict1
assert dict1["name"] == "Alice"

assert "name" not in dict2


@pytest.mark.skipif(
PYDANTIC_MINOR_VERSION < (2, 12),
reason="exlude_if requires Pydantic 2.12+",
)
def test_exclude_if_via_schema_extra():
def is_empty_string(value: Any) -> bool:
return value == ""

with pytest.warns(
DeprecationWarning,
match="Pass `exclude_if` parameter directly to Field instead of passing it via `schema_extra`",
):

class Model(SQLModel):
name: str = Field(schema_extra={"exclude_if": is_empty_string})
age: int

model1 = Model(name="Alice", age=30)
model2 = Model(name="", age=25)

dict1 = model1.model_dump()
dict2 = model2.model_dump()

assert "name" in dict1
assert dict1["name"] == "Alice"

assert "name" not in dict2


def test_field_title_generator():
def upper(value: str, _: Any) -> str:
return value.upper()

class Model(SQLModel):
name: str = Field(field_title_generator=upper)
age: int

model_schema = Model.model_json_schema()
assert model_schema["properties"]["name"]["title"] == "NAME"
assert model_schema["properties"]["age"]["title"] == "Age"


def test_field_title_generator_via_schema_extra():
def upper(value: str, _: Any) -> str:
return value.upper()

with pytest.warns(
DeprecationWarning,
match="Pass `field_title_generator` parameter directly to Field instead of passing it via `schema_extra`",
):

class Model(SQLModel):
name: str = Field(schema_extra={"field_title_generator": upper})
age: int

model_schema = Model.model_json_schema()
assert model_schema["properties"]["name"]["title"] == "NAME"
assert model_schema["properties"]["age"]["title"] == "Age"


def test_min_items():
with pytest.warns(
DeprecationWarning,
Expand Down
Loading