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
isshub/domain/contexts/code_repository/entities/namespace/tests/test_describe.py
isshub/domain/contexts/code_repository/entities/repository/features/describe.feature
isshub/domain/contexts/code_repository/entities/repository/tests/test_describe.py
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}")