diff --git a/sqlmodel/_compat.py b/sqlmodel/_compat.py index 072d2b0f58..c164d20b80 100644 --- a/sqlmodel/_compat.py +++ b/sqlmodel/_compat.py @@ -283,14 +283,12 @@ def sqlmodel_table_construct( # End SQLModel override return self_instance - def sqlmodel_validate( + def _sqlmodel_validate( cls: Type[_TSQLModel], obj: Any, *, - strict: Union[bool, None] = None, - from_attributes: Union[bool, None] = None, - context: Union[Dict[str, Any], None] = None, update: Union[Dict[str, Any], None] = None, + validator: Callable[[Any, _TSQLModel], None], ) -> _TSQLModel: if not is_table_model_class(cls): new_obj: _TSQLModel = cls.__new__(cls) @@ -308,13 +306,7 @@ def sqlmodel_validate( use_obj = {**obj, **update} elif update: use_obj = ObjectWithUpdateWrapper(obj=obj, update=update) - cls.__pydantic_validator__.validate_python( - use_obj, - strict=strict, - from_attributes=from_attributes, - context=context, - self_instance=new_obj, - ) + validator(use_obj, new_obj) # Capture fields set to restore it later fields_set = new_obj.__pydantic_fields_set__.copy() if not is_table_model_class(cls): @@ -335,6 +327,46 @@ def sqlmodel_validate( setattr(new_obj, key, value) return new_obj + def sqlmodel_validate_python( + cls: Type[_TSQLModel], + obj: Any, + *, + strict: Union[bool, None] = None, + from_attributes: Union[bool, None] = None, + context: Union[Dict[str, Any], None] = None, + update: Union[Dict[str, Any], None] = None, + ) -> _TSQLModel: + def validate(use_obj: Any, new_obj: _TSQLModel) -> None: + cls.__pydantic_validator__.validate_python( + use_obj, + strict=strict, + from_attributes=from_attributes, + context=context, + self_instance=new_obj, + ) + + return _sqlmodel_validate(cls, obj, update=update, validator=validate) + + def sqlmodel_validate_json( + cls: Type[_TSQLModel], + json_data: Union[str, bytes, bytearray], + *, + strict: Union[bool, None] = None, + context: Union[Dict[str, Any], None] = None, + update: Union[Dict[str, Any], None] = None, + ) -> _TSQLModel: + def validate(use_obj: Any, new_obj: _TSQLModel) -> None: + cls.__pydantic_validator__.validate_json( + use_obj, + strict=strict, + context=context, + self_instance=new_obj, + ) + + return _sqlmodel_validate( + cls=cls, obj=json_data, update=update, validator=validate + ) + def sqlmodel_init(*, self: "SQLModel", data: Dict[str, Any]) -> None: old_dict = self.__dict__.copy() if not is_table_model_class(self.__class__): @@ -496,7 +528,7 @@ def _calculate_keys( return keys - def sqlmodel_validate( + def sqlmodel_validate_python( cls: Type[_TSQLModel], obj: Any, *, @@ -542,6 +574,19 @@ def sqlmodel_validate( m._init_private_attributes() # type: ignore[attr-defined] # noqa return m + def sqlmodel_validate_json( + cls: Type[_TSQLModel], + json_data: Union[str, bytes, bytearray], + *, + strict: Union[bool, None] = None, + context: Union[Dict[str, Any], None] = None, + update: Union[Dict[str, Any], None] = None, + ) -> _TSQLModel: + # We're not doing any real json validation for pydantic v1. + return sqlmodel_validate_python( + cls=cls, obj=json_data, strict=strict, context=context, update=update + ) + def sqlmodel_init(*, self: "SQLModel", data: Dict[str, Any]) -> None: values, fields_set, validation_error = validate_model(self.__class__, data) # Only raise errors if not a SQLModel model diff --git a/sqlmodel/main.py b/sqlmodel/main.py index 9e8330d69d..03ac3aadee 100644 --- a/sqlmodel/main.py +++ b/sqlmodel/main.py @@ -76,7 +76,8 @@ post_init_field_info, set_config_value, sqlmodel_init, - sqlmodel_validate, + sqlmodel_validate_json, + sqlmodel_validate_python, ) from .sql.sqltypes import GUID, AutoString @@ -749,7 +750,7 @@ def model_validate( context: Union[Dict[str, Any], None] = None, update: Union[Dict[str, Any], None] = None, ) -> _TSQLModel: - return sqlmodel_validate( + return sqlmodel_validate_python( cls=cls, obj=obj, strict=strict, @@ -758,6 +759,23 @@ def model_validate( update=update, ) + @classmethod + def model_validate_json( + cls: Type[_TSQLModel], + json_data: Union[str, bytes, bytearray], + *, + strict: Union[bool, None] = None, + context: Union[Dict[str, Any], None] = None, + update: Union[Dict[str, Any], None] = None, + ) -> _TSQLModel: + return sqlmodel_validate_json( + cls=cls, + json_data=json_data, + strict=strict, + context=context, + update=update, + ) + def model_dump( self, *, diff --git a/tests/test_validation.py b/tests/test_validation.py index 3265922070..3174421000 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,4 +1,7 @@ +import json +from datetime import date from typing import Optional +from uuid import UUID import pytest from pydantic.error_wrappers import ValidationError @@ -63,3 +66,43 @@ def reject_none(cls, v): with pytest.raises(ValidationError): Hero.model_validate({"name": None, "age": 25}) + + +@needs_pydanticv2 +def test_validation_strict_mode(clear_sqlmodel): + """Test validation of fields in strict mode from python and json.""" + from pydantic import TypeAdapter + + class Hero(SQLModel): + id: Optional[int] = None + birth_date: Optional[date] = None + uuid: Optional[UUID] = None + + model_config = {"strict": True} + + date_obj = date(1970, 1, 1) + date_str = date_obj.isoformat() + uuid_obj = UUID("0ffef15c-c04f-4e61-b586-904ffe76c9b1") + uuid_str = str(uuid_obj) + + Hero.model_validate({"id": 1, "birth_date": date_obj, "uuid": uuid_obj}) + TypeAdapter(Hero).validate_python( + {"id": 1, "birth_date": date_obj, "uuid": uuid_obj} + ) + # Check that python validation requires strict types + with pytest.raises(ValidationError): + Hero.model_validate({"id": "1"}) + with pytest.raises(ValidationError): + Hero.model_validate({"birth_date": date_str}) + with pytest.raises(ValidationError): + Hero.model_validate({"uuid": uuid_str}) + + # Check that json is a bit more lax, but still refuses to "cast" values when not necessary + Hero.model_validate_json( + json.dumps({"id": 1, "birth_date": date_str, "uuid": uuid_str}) + ) + TypeAdapter(Hero).validate_json( + json.dumps({"id": 1, "birth_date": date_str, "uuid": uuid_str}) + ) + with pytest.raises(ValidationError): + Hero.model_validate_json(json.dumps({"id": "1"}))