diff --git a/sqlmodel/main.py b/sqlmodel/main.py index 7c916f79af..087f80614f 100644 --- a/sqlmodel/main.py +++ b/sqlmodel/main.py @@ -218,6 +218,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[ @@ -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[ @@ -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[ @@ -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[ @@ -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 diff --git a/tests/test_aliases.py b/tests/test_aliases.py new file mode 100644 index 0000000000..99c89f4a2d --- /dev/null +++ b/tests/test_aliases.py @@ -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): + 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