fix(entity): id changed from int to uuid4, renamed to identifier

Description

Abstract

The id field of entities is renamed to identifier and the type is changed from int, validated as positive integer, to uuid.UUID, validated to be a UUID version 4.

Motivation

The id term is more a “technical” term, that is a shortened version of the word identifier. And it cannot be used as an argument without shadowing the id python builtin. So it’s better to use the full word.

And being an integer means that checks have to be done for uniqueness. UUIDs exists exactly to solve this.

Rationale

N/A

Info

Hash

79f704bde4575a9ddeb623d67d8965a62138adc9

Date

2020-10-05 10:51:49 +0200

Parents
  • style(entity): Change the world “model” by “entity” [c2dd0fc6]2020-10-04 21:07:00 +0200

Children
  • feat(repository): Add domain repositories [27f013e2]2020-10-06 17:30:45 +0200

Branches
Tags

(No tags)

Changes

isshub/domain/contexts/code_repository/entities/namespace/__init__.py

Type

Modified

Stats

+3 -3

@@ -4,7 +4,7 @@ import enum
 from typing import Any, Optional

 from isshub.domain.utils.entity import (
-    BaseEntityWithId,
+    BaseEntityWithIdentifier,
     field_validator,
     optional_field,
     required_field,
@@ -21,12 +21,12 @@ class NamespaceKind(enum.Enum):


 @validated()
-class Namespace(BaseEntityWithId):
+class Namespace(BaseEntityWithIdentifier):
     """A namespace can contain namespaces and repositories.

     Attributes
     ----------
-    id : int
+    identifier : UUID
         The unique identifier of the namespace
     name : str
         The name of the namespace. Unique in its parent namespace.

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

Type

Modified

Stats

+8 -8

@@ -1,20 +1,20 @@
 Feature: Describing a Namespace

-    Scenario: A Namespace has an id
+    Scenario: A Namespace has an identifier
         Given a Namespace
-        Then it must have a field named id
+        Then it must have a field named identifier

-    Scenario: A Namespace id is a positive integer
+    Scenario: A Namespace identifier is a uuid
         Given a Namespace
-        Then its id must be a positive integer
+        Then its identifier must be a uuid

-    Scenario: A Namespace id is mandatory
+    Scenario: A Namespace identifier is mandatory
         Given a Namespace
-        Then its id is mandatory
+        Then its identifier is mandatory

-    Scenario: A Namespace id cannot be changed
+    Scenario: A Namespace identifier cannot be changed
         Given a Namespace
-        Then its id cannot be changed
+        Then its identifier cannot be changed

     Scenario: A Namespace has a name
         Given a Namespace

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

Type

Modified

Stats

+1 -1

@@ -21,6 +21,6 @@ class NamespaceFactory(factory.Factory):

         model = Namespace

-    id = factory.Faker("pyint", min_value=1)
+    identifier = factory.Faker("uuid4", cast_to=None)
     name = factory.Faker("pystr", min_chars=2)
     kind = factory.Faker("enum", enum_cls=NamespaceKind)

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

Type

Modified

Stats

+10 -9

@@ -1,4 +1,5 @@
 """Module holding BDD tests for isshub Namespace code_repository entity."""
+from uuid import uuid4

 import pytest
 from pytest import mark
@@ -11,16 +12,16 @@ from isshub.domain.utils.testing.validation import (
     check_field_not_nullable,
     check_field_nullable,
     check_field_value,
-    positive_integer_only,
     string_only,
+    uuid4_only,
 )

 from .fixtures import namespace, namespace_factory


-@mark.parametrize(["value", "exception"], positive_integer_only)
-@scenario("../features/describe.feature", "A Namespace id is a positive integer")
-def test_namespace_id_is_a_positive_integer(value, exception):
+@mark.parametrize(["value", "exception"], uuid4_only)
+@scenario("../features/describe.feature", "A Namespace identifier is a uuid")
+def test_namespace_identifier_is_a_uuid(value, exception):
     pass


@@ -89,15 +90,15 @@ 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():
+@scenario("../features/describe.feature", "A Namespace identifier cannot be changed")
+def test_namespace_identifier_cannot_be_changed():
     pass


-@then("its id cannot be changed")
-def namespace_id_cannot_be_changed(namespace):
+@then("its identifier cannot be changed")
+def namespace_identifier_cannot_be_changed(namespace):
     with pytest.raises(FrozenAttributeError):
-        namespace.id = namespace.id + 1
+        namespace.identifier = uuid4()


 @scenario("../features/describe.feature", "A Namespace cannot be contained in itself")

isshub/domain/contexts/code_repository/entities/repository/__init__.py

Type

Modified

Stats

+7 -3

@@ -1,16 +1,20 @@
 """Package defining the ``Repository`` entity."""

 from isshub.domain.contexts.code_repository.entities.namespace import Namespace
-from isshub.domain.utils.entity import BaseEntityWithId, required_field, validated
+from isshub.domain.utils.entity import (
+    BaseEntityWithIdentifier,
+    required_field,
+    validated,
+)


 @validated()
-class Repository(BaseEntityWithId):
+class Repository(BaseEntityWithIdentifier):
     """A repository holds code, issues, code requests...

     Attributes
     ----------
-    id : int
+    identifier : UUID
         The unique identifier of the repository
     name : str
         The name of the repository. Unique in its namespace.

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

Type

Modified

Stats

+8 -8

@@ -1,20 +1,20 @@
 Feature: Describing a Repository

-    Scenario: A Repository has an id
+    Scenario: A Repository has an identifier
         Given a Repository
-        Then it must have a field named id
+        Then it must have a field named identifier

-    Scenario: A Repository id is a positive integer
+    Scenario: A Repository identifier is a uuid
         Given a Repository
-        Then its id must be a positive integer
+        Then its identifier must be a uuid

-    Scenario: A Repository id is mandatory
+    Scenario: A Repository identifier is mandatory
         Given a Repository
-        Then its id is mandatory
+        Then its identifier is mandatory

-    Scenario: A Repository id cannot be changed
+    Scenario: A Repository identifier cannot be changed
         Given a Repository
-        Then its id cannot be changed
+        Then its identifier cannot be changed

     Scenario: A Repository has a name
         Given a Repository

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

Type

Modified

Stats

+1 -1

@@ -15,6 +15,6 @@ class RepositoryFactory(factory.Factory):

         model = Repository

-    id = factory.Faker("pyint", min_value=1)
+    identifier = factory.Faker("uuid4", cast_to=None)
     name = factory.Faker("pystr", min_chars=2)
     namespace = factory.SubFactory(NamespaceFactory)

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

Type

Modified

Stats

+10 -9

@@ -1,4 +1,5 @@
 """Module holding BDD tests for isshub Repository code_repository entity."""
+from uuid import uuid4

 import pytest
 from pytest import mark
@@ -9,17 +10,17 @@ from isshub.domain.utils.testing.validation import (
     check_field,
     check_field_not_nullable,
     check_field_value,
-    positive_integer_only,
     string_only,
+    uuid4_only,
 )

 from ...namespace.tests.fixtures import namespace
 from .fixtures import repository_factory


-@mark.parametrize(["value", "exception"], positive_integer_only)
-@scenario("../features/describe.feature", "A Repository id is a positive integer")
-def test_repository_id_is_a_positive_integer(value, exception):
+@mark.parametrize(["value", "exception"], uuid4_only)
+@scenario("../features/describe.feature", "A Repository identifier is a uuid")
+def test_repository_identifier_is_a_uuid(value, exception):
     pass


@@ -68,12 +69,12 @@ 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():
+@scenario("../features/describe.feature", "A Repository identifier cannot be changed")
+def test_repository_identifier_cannot_be_changed():
     pass


-@then("its id cannot be changed")
-def repository_id_cannot_be_changed(repository):
+@then("its identifier cannot be changed")
+def repository_identifier_cannot_be_changed(repository):
     with pytest.raises(FrozenAttributeError):
-        repository.id = repository.id + 1
+        repository.identifier = uuid4()

isshub/domain/utils/entity.py

Type

Modified

Stats

+72 -10

@@ -14,6 +14,7 @@ from typing import (
     Union,
     cast,
 )
+from uuid import UUID

 import attr

@@ -382,6 +383,67 @@ def validate_positive_integer(
         raise ValueError(f"{display_name} must be a positive integer")


+def validate_uuid(value: Any, none_allowed: bool, display_name: str) -> None:
+    """Validate that the given `value` is a uuid (version 4) (``None`` accepted if `none_allowed`).
+
+    Parameters
+    ----------
+    value : Any
+        The value to validate as a uuid.
+    none_allowed : bool
+        If ``True``, the value can be ``None``. If ``False``, the value must be a uuid.
+    display_name : str
+        The name of the field to display in errors.
+
+    Raises
+    ------
+    TypeError
+        If `value` is not of type ``UUID`` version 4 .
+
+    Examples
+    --------
+    >>> from uuid import UUID
+    >>> from isshub.domain.utils.entity import field_validator, required_field, BaseEntity
+    >>>
+    >>> @validated()
+    ... class MyEntity(BaseEntity):
+    ...    my_field: UUID = required_field(UUID)
+    ...
+    ...    @field_validator(my_field)
+    ...    def validate_my_field(self, field, value):
+    ...        validate_uuid(
+    ...            value=value,
+    ...            none_allowed=False,
+    ...            display_name=f"{self.__class__.__name__}.my_field",
+    ...        )
+    >>>
+    >>> instance = MyEntity(my_field='foo')
+    Traceback (most recent call last):
+        ...
+    TypeError: ("'my_field' must be <class 'uuid.UUID'> (got 'foo' that is a <class 'str'>)...
+    >>> instance = MyEntity(my_field='7298d61a-f08f-4f83-b75e-934e786eb43d')
+    Traceback (most recent call last):
+        ...
+    TypeError: ("'my_field' must be <class 'uuid.UUID'> (got '7298d61a-f08f-4f83-b75e-934e786eb43d' that is a <class 'str'>)...
+    >>> instance = MyEntity(my_field=UUID('19f49bc8-06e5-11eb-8465-bf44725d7bd3'))
+    Traceback (most recent call last):
+        ...
+    TypeError: MyEntity.my_field must be a UUID version 4
+    >>> instance = MyEntity(my_field=UUID('7298d61a-f08f-4f83-b75e-934e786eb43d'))
+    >>> instance.my_field = UUID('19f49bc8-06e5-11eb-8465-bf44725d7bd3')
+    >>> instance.validate()
+    Traceback (most recent call last):
+        ...
+    TypeError: MyEntity.my_field must be a UUID version 4
+
+    """
+    if none_allowed and value is None:
+        return
+
+    if not isinstance(value, UUID) or value.version != 4:
+        raise TypeError(f"{display_name} must be a UUID version 4")
+
+
 @validated()
 class BaseEntity:
     """A base entity without any field, that is able to validate itself."""
@@ -399,23 +461,23 @@ class BaseEntity:


 @validated()
-class BaseEntityWithId(BaseEntity):
-    """A base entity with an ``id``, that is able to validate itself.
+class BaseEntityWithIdentifier(BaseEntity):
+    """A base entity with an ``identifier``, that is able to validate itself.

     Attributes
     ----------
-    id : int
-        The identifier of the instance. Validated to be a positive integer.
+    identifier : UUID
+        The identifier of the instance. Validated to be a UUID version 4.

     """

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

-    @field_validator(id)
-    def validate_id_is_positive_integer(  # noqa  # pylint: disable=unused-argument
+    @field_validator(identifier)
+    def validate_id_is_uuid(  # noqa  # pylint: disable=unused-argument
         self, field: "Attribute[_T]", value: _T
     ) -> None:
-        """Validate that the ``id`` field is a positive integer.
+        """Validate that the ``identifier`` field is a uuid.

         Parameters
         ----------
@@ -425,8 +487,8 @@ class BaseEntityWithId(BaseEntity):
             The value to validate for the `field`.

         """
-        validate_positive_integer(
+        validate_uuid(
             value=value,
             none_allowed=False,
-            display_name=f"{self.__class__.__name__}.id",
+            display_name=f"{self.__class__.__name__}.identifier",
         )

isshub/domain/utils/testing/validation.py

Type

Modified

Stats

+7 -0

@@ -1,6 +1,7 @@
 """Validation helpers for BDD tests for isshub entities."""

 from typing import Any, Callable, List, Optional, Tuple, Type
+from uuid import UUID

 import pytest

@@ -29,6 +30,12 @@ positive_integer_only: ValuesValidation = integer_only + no_zero

 string_only: ValuesValidation = [("foo", None), (1, TypeError), (-0.1, TypeError)]

+uuid4_only: ValuesValidation = [
+    (UUID("19f49bc8-06e5-11eb-8465-bf44725d7bd3"), TypeError),
+    ("7298d61a-f08f-4f83-b75e-934e786eb43d", TypeError),
+    (UUID("7298d61a-f08f-4f83-b75e-934e786eb43d"), None),
+]
+

 def check_field(obj: BaseEntity, field_name: str) -> None:
     """Assert that the given `obj` has an attribute named `field_name`.

pylintrc

Type

Modified

Stats

+2 -1

@@ -142,7 +142,8 @@ disable=print-statement,
         too-few-public-methods,
         bad-continuation,
         duplicate-code,
-        wrong-import-position
+        wrong-import-position,
+        line-too-long,

 # Enable the message, report, category or checker with the given id(s). You can
 # either give multiple identifier separated by comma (,) or put this option

setup.cfg

Type

Modified

Stats

+2 -1

@@ -98,7 +98,8 @@ ignore =
     W503
     # Allow assigning lambda expressions
     E731
-max-line-length = 99
+    # Ignore line length, handled by black
+    B950
 max-complexity = 15
 select =
     # flake8 error class