Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 61 additions & 33 deletions sqlmodel/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@ def Field(
*,
default_factory: Optional[NoArgAnyCallable] = None,
alias: Optional[str] = None,
validation_alias: Optional[str] = None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pydantic's validation_alias type annotation is wider (validation_alias: str | AliasPath | AliasChoices | None), but I think it's fine if we only support str for now and extend it later

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have a strong preference on this.

serialization_alias: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
exclude: Union[
Expand Down Expand Up @@ -263,6 +265,8 @@ def Field(
*,
default_factory: Optional[NoArgAnyCallable] = None,
alias: Optional[str] = None,
validation_alias: Optional[str] = None,
serialization_alias: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
exclude: Union[
Expand Down Expand Up @@ -317,6 +321,8 @@ def Field(
*,
default_factory: Optional[NoArgAnyCallable] = None,
alias: Optional[str] = None,
validation_alias: Optional[str] = None,
serialization_alias: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
exclude: Union[
Expand Down Expand Up @@ -352,6 +358,8 @@ def Field(
*,
default_factory: Optional[NoArgAnyCallable] = None,
alias: Optional[str] = None,
validation_alias: Optional[str] = None,
serialization_alias: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
exclude: Union[
Expand Down Expand Up @@ -390,43 +398,63 @@ def Field(
schema_extra: Optional[Dict[str, Any]] = None,
) -> Any:
current_schema_extra = schema_extra or {}
# 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)
field_info_kwargs = {
"alias": alias,
"title": title,
"description": description,
"exclude": exclude,
"include": include,
"const": const,
"gt": gt,
"ge": ge,
"lt": lt,
"le": le,
"multiple_of": multiple_of,
"max_digits": max_digits,
"decimal_places": decimal_places,
"min_items": min_items,
"max_items": max_items,
"unique_items": unique_items,
"min_length": min_length,
"max_length": max_length,
"allow_mutation": allow_mutation,
"regex": regex,
"discriminator": discriminator,
"repr": repr,
"primary_key": primary_key,
"foreign_key": foreign_key,
"ondelete": ondelete,
"unique": unique,
"nullable": nullable,
"index": index,
"sa_type": sa_type,
"sa_column": sa_column,
"sa_column_args": sa_column_args,
"sa_column_kwargs": sa_column_kwargs,
**current_schema_extra,
}
if IS_PYDANTIC_V2:
# explicit params > schema_extra > alias propagation
field_info_kwargs["validation_alias"] = (
validation_alias or schema_validation_alias or alias
)
field_info_kwargs["serialization_alias"] = (
serialization_alias or schema_serialization_alias or alias
)
else:
if validation_alias or schema_validation_alias is not None:
raise RuntimeError("validation_alias is not supported in Pydantic v1")
if serialization_alias or schema_serialization_alias is not None:
raise RuntimeError("serialization_alias is not supported in Pydantic v1")
field_info = FieldInfo(
default,
default_factory=default_factory,
alias=alias,
title=title,
description=description,
exclude=exclude,
include=include,
const=const,
gt=gt,
ge=ge,
lt=lt,
le=le,
multiple_of=multiple_of,
max_digits=max_digits,
decimal_places=decimal_places,
min_items=min_items,
max_items=max_items,
unique_items=unique_items,
min_length=min_length,
max_length=max_length,
allow_mutation=allow_mutation,
regex=regex,
discriminator=discriminator,
repr=repr,
primary_key=primary_key,
foreign_key=foreign_key,
ondelete=ondelete,
unique=unique,
nullable=nullable,
index=index,
sa_type=sa_type,
sa_column=sa_column,
sa_column_args=sa_column_args,
sa_column_kwargs=sa_column_kwargs,
**current_schema_extra,
**field_info_kwargs,
)

post_init_field_info(field_info)
return field_info

Expand Down
256 changes: 256 additions & 0 deletions tests/test_aliases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
from typing import Type, Union

import pytest
from pydantic import VERSION, BaseModel, ValidationError
from pydantic import Field as PField
from sqlmodel import Field, SQLModel

from tests.conftest import needs_pydanticv1, needs_pydanticv2

"""
Alias tests for SQLModel and Pydantic compatibility
"""


class PydanticUser(BaseModel):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should think whether we want to keep parameterizing tests with Pydantic models.
Initially I parametrized tests with both, Pydantic and SQLModel models to show the difference in behavior between SQLModel and Pydantic models.

If it's useful to keep this parametrization in repo? 🤔
Tests would be a bit simpler without it, but on the other hand this way we ensure it works the same as with Pydantic model

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I think it’s useful to keep the parametrization for now, since it acts as a kind of integration test between the two libraries.

full_name: str = PField(alias="fullName")


class SQLModelUser(SQLModel):
full_name: str = Field(alias="fullName")


# Models with config (validate_by_name=True)
if VERSION.startswith("2."):

class PydanticUserWithConfig(PydanticUser):
model_config = {"validate_by_name": True}

class SQLModelUserWithConfig(SQLModelUser):
model_config = {"validate_by_name": True}

else:

class PydanticUserWithConfig(PydanticUser):
class Config:
allow_population_by_field_name = True

class SQLModelUserWithConfig(SQLModelUser):
class Config:
allow_population_by_field_name = True


@pytest.mark.parametrize("model", [PydanticUser, SQLModelUser])
def test_create_with_field_name(model: Union[Type[PydanticUser], Type[SQLModelUser]]):
with pytest.raises(ValidationError):
model(full_name="Alice")


@pytest.mark.parametrize("model", [PydanticUserWithConfig, SQLModelUserWithConfig])
def test_create_with_field_name_with_config(
model: Union[Type[PydanticUserWithConfig], Type[SQLModelUserWithConfig]],
):
user = model(full_name="Alice")
assert user.full_name == "Alice"


@pytest.mark.parametrize(
"model",
[PydanticUser, SQLModelUser, PydanticUserWithConfig, SQLModelUserWithConfig],
)
def test_create_with_alias(
model: Union[
Type[PydanticUser],
Type[SQLModelUser],
Type[PydanticUserWithConfig],
Type[SQLModelUserWithConfig],
],
):
user = model(fullName="Bob") # using alias
assert user.full_name == "Bob"


@pytest.mark.parametrize("model", [PydanticUserWithConfig, SQLModelUserWithConfig])
def test_create_with_both_prefers_alias(
model: Union[Type[PydanticUserWithConfig], Type[SQLModelUserWithConfig]],
):
user = model(full_name="IGNORED", fullName="Charlie")
assert user.full_name == "Charlie" # alias should take precedence


@pytest.mark.parametrize("model", [PydanticUser, SQLModelUser])
def test_dict_default_uses_field_names(
model: Union[Type[PydanticUser], Type[SQLModelUser]],
):
user = model(fullName="Dana")
if VERSION.startswith("2.") or isinstance(user, SQLModel):
data = user.model_dump()
else:
data = user.dict()
assert "full_name" in data
assert "fullName" not in data
assert data["full_name"] == "Dana"


@pytest.mark.parametrize("model", [PydanticUser, SQLModelUser])
def test_dict_by_alias_uses_aliases(
model: Union[Type[PydanticUser], Type[SQLModelUser]],
):
user = model(fullName="Dana")
if VERSION.startswith("2.") or isinstance(user, SQLModel):
data = user.model_dump(by_alias=True)
else:
data = user.dict(by_alias=True)
assert "fullName" in data
assert "full_name" not in data
assert data["fullName"] == "Dana"


@pytest.mark.parametrize("model", [PydanticUser, SQLModelUser])
def test_json_by_alias(
model: Union[Type[PydanticUser], Type[SQLModelUser]],
):
user = model(fullName="Frank")
if VERSION.startswith("2."):
json_data = user.model_dump_json(by_alias=True)
else:
json_data = user.json(by_alias=True)
assert ('"fullName":"Frank"' in json_data) or ('"fullName": "Frank"' in json_data)
assert "full_name" not in json_data


if VERSION.startswith("2."):

class PydanticUserV2(BaseModel):
first_name: str = PField(
validation_alias="firstName", serialization_alias="f_name"
)

class SQLModelUserV2(SQLModel):
first_name: str = Field(
validation_alias="firstName", serialization_alias="f_name"
)
else:
# Dummy classes for Pydantic v1 to prevent import errors
PydanticUserV2 = None
SQLModelUserV2 = None


@needs_pydanticv1
def test_validation_alias_runtimeerror_pydantic_v1():
with pytest.raises(
RuntimeError, match="validation_alias is not supported in Pydantic v1"
):
Field(validation_alias="foo")


@needs_pydanticv1
def test_serialization_alias_runtimeerror_pydantic_v1():
with pytest.raises(
RuntimeError, match="serialization_alias is not supported in Pydantic v1"
):
Field(serialization_alias="bar")


@needs_pydanticv2
@pytest.mark.parametrize("model", [PydanticUserV2, SQLModelUserV2])
def test_create_with_validation_alias(
model: Union[Type[PydanticUserV2], Type[SQLModelUserV2]],
):
user = model(firstName="John")
assert user.first_name == "John"


@needs_pydanticv2
@pytest.mark.parametrize("model", [PydanticUserV2, SQLModelUserV2])
def test_serialize_with_serialization_alias(
model: Union[Type[PydanticUserV2], Type[SQLModelUserV2]],
):
user = model(firstName="Jane")
data = user.model_dump(by_alias=True)
assert "f_name" in data
assert "firstName" not in data
assert "first_name" not in data
assert data["f_name"] == "Jane"


@needs_pydanticv2
def test_schema_extra_validation_alias_sqlmodel_v2():
class M(SQLModel):
f: str = Field(schema_extra={"validation_alias": "f_alias"})

m = M.model_validate({"f_alias": "asd"})
assert m.f == "asd"


@needs_pydanticv2
def test_schema_extra_serialization_alias_sqlmodel_v2():
class M(SQLModel):
f: str = Field(schema_extra={"serialization_alias": "f_out"})

m = M(f="x")
data = m.model_dump(by_alias=True)
assert "f_out" in data
assert "f" not in data
assert data["f_out"] == "x"


@needs_pydanticv1
def test_schema_extra_validation_alias_runtimeerror_pydantic_v1():
with pytest.raises(
RuntimeError, match="validation_alias is not supported in Pydantic v1"
):
Field(schema_extra={"validation_alias": "x"})


@needs_pydanticv1
def test_schema_extra_serialization_alias_runtimeerror_pydantic_v1():
with pytest.raises(
RuntimeError, match="serialization_alias is not supported in Pydantic v1"
):
Field(schema_extra={"serialization_alias": "y"})


@needs_pydanticv2
def test_alias_plus_validation_alias_prefers_validation_alias_sqlmodel_v2():
class M(SQLModel):
first_name: str = Field(alias="fullName", validation_alias="v_name")

m = M.model_validate({"fullName": "A", "v_name": "B"})
assert m.first_name == "B"


@needs_pydanticv2
def test_alias_plus_serialization_alias_prefers_serialization_alias_sqlmodel_v2():
class M(SQLModel):
first_name: str = Field(alias="fullName", serialization_alias="f_name")

m = M(fullName="Z")
data = m.model_dump(by_alias=True)
assert "f_name" in data
assert "fullName" not in data
assert data["f_name"] == "Z"


@needs_pydanticv2
def test_alias_generator_works_sqlmodel_v2():
class M(SQLModel):
model_config = {"alias_generator": lambda s: "gen_" + s}
f: str = Field()

m = M.model_validate({"gen_f": "ok"})
assert m.f == "ok"
data = m.model_dump(by_alias=True)
assert "gen_f" in data and data["gen_f"] == "ok"


@needs_pydanticv2
def test_alias_generator_with_explicit_alias_prefers_field_alias_sqlmodel_v2():
class M(SQLModel):
model_config = {"alias_generator": lambda s: "gen_" + s}
f: str = Field(alias="custom")

m = M.model_validate({"custom": "ok"})
assert m.f == "ok"
data = m.model_dump(by_alias=True)
assert "custom" in data and "gen_f" not in data
Loading