fix(entities): Entities id field are frozen once set

Description

Abstract

Make the id field of all entities (via BaseModelWithId) frozen, ie that cannot be updated once set at init time, else a FrozenAttributeError exception is raised

Motivation

An “id” identifies an object, so there is no point to make it editable.

Rationale

This is done by using the on_setattr argument to attr.ib with the frozen setter.

There is a main difference with other fields validators because this is done at the moment the field is set, and not later while validating it, because at validation time, the field is already set and there is no clean way to get the old value.

Info

Hash

ef8edc20b6a674bfc98c79028684279bcc9ed324

Date

2020-09-27 09:56:59 +0200

Parents
  • fix(namespace): Namespaces relationships should not create a loop [afeb5f86]2020-09-26 22:38:26 +0200

Children
  • Merge branch ‘feature/twidi/entities-fields-validation’ into develop [decf9c35]2020-09-27 10:14:37 +0200

Branches
Tags

(No tags)

Changes

isshub/domain/contexts/code_repository/entities/namespace/features/describe.feature

Type

Modified

Stats

+4 -0

@@ -12,6 +12,10 @@ Feature: Describing a Namespace
         Given a Namespace
         Then its id is mandatory

+    Scenario: A Namespace id cannot be changed
+        Given a Namespace
+        Then its id cannot be changed
+
     Scenario: A Namespace has a name
         Given a Namespace
         Then it must have a field named name

isshub/domain/contexts/code_repository/entities/namespace/tests/test_describe.py

Type

Modified

Stats

+12 -0

@@ -6,6 +6,7 @@ from pytest_bdd import given, parsers, scenario, scenarios, then

 from isshub.domain.contexts.code_repository.entities.namespace import NamespaceKind
 from isshub.domain.utils.testing.validation import (
+    FrozenAttributeError,
     check_field,
     check_field_not_nullable,
     check_field_nullable,
@@ -88,6 +89,17 @@ def namespace_field_is_optional(namespace_factory, field_name):
     check_field_nullable(namespace_factory, field_name)


+@scenario("../features/describe.feature", "A Namespace id cannot be changed")
+def test_namespace_id_cannot_be_changed():
+    pass
+
+
+@then("its id cannot be changed")
+def namespace_id_cannot_be_changed(namespace):
+    with pytest.raises(FrozenAttributeError):
+        namespace.id = namespace.id + 1
+
+
 @scenario("../features/describe.feature", "A Namespace cannot be contained in itself")
 def test_namespace_namespace_cannot_be_itself():
     pass

isshub/domain/contexts/code_repository/entities/repository/features/describe.feature

Type

Modified

Stats

+4 -0

@@ -12,6 +12,10 @@ Feature: Describing a Repository
         Given a Repository
         Then its id is mandatory

+    Scenario: A Repository id cannot be changed
+        Given a Repository
+        Then its id cannot be changed
+
     Scenario: A Repository has a name
         Given a Repository
         Then it must have a field named name

isshub/domain/contexts/code_repository/entities/repository/tests/test_describe.py

Type

Modified

Stats

+12 -0

@@ -5,6 +5,7 @@ from pytest import mark
 from pytest_bdd import given, parsers, scenario, scenarios, then

 from isshub.domain.utils.testing.validation import (
+    FrozenAttributeError,
     check_field,
     check_field_not_nullable,
     check_field_value,
@@ -65,3 +66,14 @@ def repository_field_is_of_a_certain_type(
 @then(parsers.parse("its {field_name:w} is mandatory"))
 def repository_field_is_mandatory(repository_factory, field_name):
     check_field_not_nullable(repository_factory, field_name)
+
+
+@scenario("../features/describe.feature", "A Repository id cannot be changed")
+def test_repository_id_cannot_be_changed():
+    pass
+
+
+@then("its id cannot be changed")
+def repository_id_cannot_be_changed(repository):
+    with pytest.raises(FrozenAttributeError):
+        repository.id = repository.id + 1

isshub/domain/utils/entity.py

Type

Modified

Stats

+12 -4

@@ -41,13 +41,17 @@ def optional_field(field_type):
     )


-def required_field(field_type):
+def required_field(field_type, frozen=False):
     """Define a required field of the specified `field_type`.

     Parameters
     ----------
     field_type : type
-        The expected type of the field..
+        The expected type of the field.
+    frozen : bool
+        If set to ``False`` (the default), the field can be updated after being set at init time.
+        If set to ``True``, the field can be set at init time but cannot be changed later, else a
+        ``FrozenAttributeError`` exception will be raised.

     Returns
     -------
@@ -66,7 +70,11 @@ def required_field(field_type):
     >>> check_field_not_nullable(MyModel, 'my_field', my_field='foo')

     """
-    return attr.ib(validator=attr.validators.instance_of(field_type))
+    kwargs = {"validator": attr.validators.instance_of(field_type)}
+    if frozen:
+        kwargs["on_setattr"] = attr.setters.frozen
+
+    return attr.ib(**kwargs)


 def validated():
@@ -283,7 +291,7 @@ class BaseModelWithId(BaseModel):

     """

-    id: int = required_field(int)
+    id: int = required_field(int, frozen=True)

     @field_validator(id)
     def validate_id_is_positive_integer(  # noqa  # pylint: disable=unused-argument

isshub/domain/utils/testing/validation.py

Type

Modified

Stats

+30 -12

@@ -4,6 +4,8 @@ from typing import Any, Callable, List, Optional, Tuple, Type

 import pytest

+from attr.exceptions import FrozenAttributeError
+
 from isshub.domain.utils.entity import BaseModel


@@ -86,16 +88,24 @@ def check_field_value(
             factory(**{field_name: value}, **factory_kwargs_copy)
         # When updating the value
         obj = factory(**factory_kwargs)
-        setattr(obj, field_name, value)
-        with pytest.raises(exception):
-            obj.validate()
+        try:
+            setattr(obj, field_name, value)
+        except FrozenAttributeError:
+            pass
+        else:
+            with pytest.raises(exception):
+                obj.validate()
     else:
         # When creating an instance
         factory(**{field_name: value}, **factory_kwargs_copy)
         # When updating the value
         obj = factory(**factory_kwargs)
-        setattr(obj, field_name, value)
-        obj.validate()
+        try:
+            setattr(obj, field_name, value)
+        except FrozenAttributeError:
+            pass
+        else:
+            obj.validate()


 def check_field_not_nullable(
@@ -127,9 +137,13 @@ def check_field_not_nullable(

     # When updating the value
     obj = factory(**factory_kwargs)
-    setattr(obj, field_name, None)
-    with pytest.raises(TypeError):
-        obj.validate()
+    try:
+        setattr(obj, field_name, None)
+    except FrozenAttributeError:
+        pass
+    else:
+        with pytest.raises(TypeError):
+            obj.validate()


 def check_field_nullable(
@@ -163,8 +177,12 @@ def check_field_nullable(

     # When updating the value
     obj = factory(**factory_kwargs)
-    setattr(obj, field_name, None)
     try:
-        obj.validate()
-    except TypeError:
-        pytest.fail(f"DID RAISE {TypeError}")
+        setattr(obj, field_name, None)
+    except FrozenAttributeError:
+        pass
+    else:
+        try:
+            obj.validate()
+        except TypeError:
+            pytest.fail(f"DID RAISE {TypeError}")