diff --git a/README.md b/README.md index f037026..992b0e6 100644 --- a/README.md +++ b/README.md @@ -416,7 +416,7 @@ class Point(Base, SerializerMixin): position = Column(Geometry('POINT')) ``` -Unfortunately you can not access formats or tzinfo in that functions. +Unfortunately you cannot access formats or tzinfo in that functions. I'll implement this logic later if any of users needs it. @@ -517,7 +517,7 @@ But `serialize_only = ('model', '-model.id',)` will return `model` field without Do not forget to add **comma** at the end of one element tuples, it is trivial, but a lot of developers forget about it: ```python -serialize_only = ('some_field',) # <--- Thats right! +serialize_only = ('some_field',) # <--- That's right! serialize_only = ('some_field') # <--- WRONG it is actually not a tuple ``` diff --git a/sqlalchemy_serializer/lib/serializable/base.py b/sqlalchemy_serializer/lib/serializable/base.py index 6a74856..b28c993 100644 --- a/sqlalchemy_serializer/lib/serializable/base.py +++ b/sqlalchemy_serializer/lib/serializable/base.py @@ -1,3 +1,3 @@ class Base: def __call__(self, value) -> str: - raise NotImplementedError(f"Method should implement serialization logic for{value}") + raise NotImplementedError(f"Method should implement serialization logic for {value}") diff --git a/sqlalchemy_serializer/serializer.py b/sqlalchemy_serializer/serializer.py index 64f379e..0b5fb2e 100644 --- a/sqlalchemy_serializer/serializer.py +++ b/sqlalchemy_serializer/serializer.py @@ -86,7 +86,7 @@ def to_dict( exclude_values=None, max_serialization_depth=None, serialize_columns=None, - ): + ) -> dict: """Returns SQLAlchemy model's data in JSON compatible format For details about datetime formats follow: @@ -123,7 +123,7 @@ def to_dict( ), serialize_columns=serialize_columns or self.serialize_columns, ) - return s(self, only=only, extend=rules) + return s(self, only=only, extend=rules) # type: ignore Options = namedtuple( @@ -133,7 +133,7 @@ def to_dict( class Serializer: - # Types that do nod need any serialization logic + # Types that do not need any serialization logic atomic_types = ( int, str, @@ -224,16 +224,28 @@ def init_callbacks(self): @staticmethod def is_valid_callable(func) -> bool: """Determines objects that should be called before serialization""" - if callable(func): - i = inspect.getfullargspec(func) - if ( - i.args == ["self"] - and isinstance(func, MethodType) - and not any([i.varargs, i.varkw]) - ): - return True - return not any([i.args, i.varargs, i.varkw]) - return False + if not callable(func): + return False + + i = inspect.getfullargspec(func) + + # Check for varargs/varkw + # (methods with *args or **kwargs are not callable without args) + if any([i.varargs, i.varkw]): + return False + + # For methods: check if all args except 'self' have defaults + if isinstance(func, MethodType): + if not i.args or i.args[0] != "self": + return False + # All args except self must have defaults + args_count = len(i.args) - 1 + else: + # For functions: all args must have defaults + args_count = len(i.args) if i.args else 0 + + defaults_count = len(i.defaults) if i.defaults else 0 + return args_count == defaults_count def is_forkable(self, value): """Determines if object should be processed in a separate serializer""" @@ -273,7 +285,7 @@ def serialize(self, value, key=_UNSPECIFIED): return self.apply_callback(value=value) except IsNotSerializable: - logger.warning("Can not serialize type:%s", get_type(value)) + logger.warning("Cannot serialize type:%s", get_type(value)) logger.debug("Skip value:%s", value) return _UNSPECIFIED diff --git a/tests/test_flat_model.py b/tests/test_flat_model.py index c1fd315..c84fd35 100644 --- a/tests/test_flat_model.py +++ b/tests/test_flat_model.py @@ -1,4 +1,6 @@ -from .models import DATE, DATETIME, MONEY, TIME, FlatModel +import sqlalchemy as sa + +from .models import DATE, DATETIME, MONEY, TIME, Base, FlatModel, SerializerMixin def test_no_defaults_no_rules(get_instance): @@ -345,3 +347,66 @@ def test_rules_for_nested_dicts_and_lists(get_instance): assert "prop" in data assert data["prop"] == i.prop + + +def test_method_with_default_argument(): + """Test that methods with default arguments are serialized""" + + class ModelWithDefaultMethod(Base, SerializerMixin): + __tablename__ = "model_with_default_method" + serialize_only = () + serialize_rules = () + + id = sa.Column(sa.Integer, primary_key=True) + string = sa.Column(sa.String(256), default="test") + + def is_liked(self, user=None): + return user is not None + + i = ModelWithDefaultMethod(id=1, string="test") + data = i.to_dict(rules=("is_liked",)) + + assert "is_liked" in data + assert data["is_liked"] is False # Called without args, user=None + + +def test_method_with_multiple_defaults(): + """Test method with multiple default arguments""" + + class ModelWithMultiDefaults(Base, SerializerMixin): + __tablename__ = "model_with_multi_defaults" + serialize_only = () + serialize_rules = () + + id = sa.Column(sa.Integer, primary_key=True) + string = sa.Column(sa.String(256), default="test") + + def compute(self, a=1, b=2): + return a + b + + i = ModelWithMultiDefaults(id=1, string="test") + data = i.to_dict(rules=("compute",)) + + assert "compute" in data + assert data["compute"] == 3 # 1 + 2 + + +def test_method_with_required_arg_rejected(): + """Test that methods with required args are still rejected""" + + class ModelWithRequiredArg(Base, SerializerMixin): + __tablename__ = "model_with_required_arg" + serialize_only = () + serialize_rules = () + + id = sa.Column(sa.Integer, primary_key=True) + string = sa.Column(sa.String(256), default="test") + + def method_required(self, user): # No default + return user + + i = ModelWithRequiredArg(id=1, string="test") + data = i.to_dict(rules=("method_required",)) + + # Should not be serialized (not callable without args) + assert "method_required" not in data diff --git a/tests/test_schema.py b/tests/test_schema.py index df091fd..b3d73a5 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -240,9 +240,9 @@ def check_rule(text: str, tree: Tree) -> Tree: """ rule = Rule(text) for k in rule.keys: - tree = tree.get(k) + tree = tree.get(k) # type: ignore if tree is None: - raise NoNodeException(f"Can not find key:{k} in tree:{tree}") + raise NoNodeException(f"Cannot find key:{k} in tree:{tree}") if rule.is_negative: assert tree.to_exclude else: