diff --git a/src/protean/container.py b/src/protean/container.py index fe1c025e..6ac29ae8 100644 --- a/src/protean/container.py +++ b/src/protean/container.py @@ -399,10 +399,14 @@ def __create_id_field(new_class): id_field = Auto(identifier=True) setattr(new_class, "id", id_field) + + # Set the name of the field on itself id_field.__set_name__(new_class, "id") + # Set the name of the attribute on the class setattr(new_class, _ID_FIELD_NAME, id_field.field_name) + # Add the attribute to _FIELDS for introspection field_objects = getattr(new_class, _FIELDS) field_objects["id"] = id_field setattr(new_class, _FIELDS, field_objects) diff --git a/src/protean/core/entity.py b/src/protean/core/entity.py index 77c74dbb..37c97be6 100644 --- a/src/protean/core/entity.py +++ b/src/protean/core/entity.py @@ -10,7 +10,7 @@ from protean.exceptions import IncorrectUsageError, NotSupportedError, ValidationError from protean.fields import Auto, HasMany, Reference, ValueObject from protean.fields.association import Association -from protean.reflection import attributes, declared_fields, fields, id_field +from protean.reflection import attributes, declared_fields, fields, id_field, _FIELDS from protean.utils import ( DomainObjects, derive_element_class, @@ -111,19 +111,6 @@ class User(BaseEntity): class Meta: abstract = True - def __init_subclass__(subclass) -> None: - super().__init_subclass__() - - subclass.__set_up_reference_fields() - - @classmethod - def __set_up_reference_fields(subclass): - """Walk through relation fields and setup shadow attributes""" - for _, field in declared_fields(subclass).items(): - if isinstance(field, Reference): - shadow_field_name, shadow_field = field.get_shadow_field() - shadow_field.__set_name__(subclass, shadow_field_name) - def __init__(self, *template, **kwargs): # noqa: C901 """ Initialise the entity object. @@ -462,4 +449,42 @@ def entity_factory(element_cls, **kwargs): } ) + # Set up reference fields + if not element_cls.meta_.abstract: + reference_field = None + for field_obj in declared_fields(element_cls).values(): + if isinstance(field_obj, Reference): + # An explicit `Reference` field is already present + reference_field = field_obj + break + + if reference_field is None: + # If no explicit Reference field is present, create one + reference_field = Reference(element_cls.meta_.aggregate_cls) + + # If aggregate_cls is a string, set field name to inflection.underscore(aggregate_cls) + # Else, if it is a class, extract class name and set field name to inflection.underscore(class_name) + if isinstance(element_cls.meta_.aggregate_cls, str): + field_name = inflection.underscore(element_cls.meta_.aggregate_cls) + else: + field_name = inflection.underscore( + element_cls.meta_.aggregate_cls.__name__ + ) + + setattr(element_cls, field_name, reference_field) + + # Set the name of the field on itself + reference_field.__set_name__(element_cls, field_name) + + # FIXME Centralize this logic to add fields dynamically to _FIELDS + field_objects = getattr(element_cls, _FIELDS) + field_objects[field_name] = reference_field + setattr(element_cls, _FIELDS, field_objects) + + # Set up shadow fields for Reference fields + for _, field in fields(element_cls).items(): + if isinstance(field, Reference): + shadow_field_name, shadow_field = field.get_shadow_field() + shadow_field.__set_name__(element_cls, shadow_field_name) + return element_cls diff --git a/tests/entity/elements.py b/tests/entity/elements.py index ab7c9cf5..11c41e9d 100644 --- a/tests/entity/elements.py +++ b/tests/entity/elements.py @@ -1,10 +1,14 @@ from collections import defaultdict from enum import Enum -from protean import BaseEntity +from protean import BaseAggregate, BaseEntity from protean.fields import Auto, HasOne, Integer, String +class Account(BaseAggregate): + account_number = String(max_length=50, required=True) + + class AbstractPerson(BaseEntity): age = Integer(default=5) @@ -16,12 +20,18 @@ class ConcretePerson(BaseEntity): first_name = String(max_length=50, required=True) last_name = String(max_length=50) + class Meta: + aggregate_cls = "Account" + class Person(BaseEntity): first_name = String(max_length=50, required=True) last_name = String(max_length=50) age = Integer(default=21) + class Meta: + aggregate_cls = "Account" + class PersonAutoSSN(BaseEntity): ssn = Auto(identifier=True) @@ -29,6 +39,9 @@ class PersonAutoSSN(BaseEntity): last_name = String(max_length=50) age = Integer(default=21) + class Meta: + aggregate_cls = "Account" + class PersonExplicitID(BaseEntity): ssn = String(max_length=36, identifier=True) @@ -36,6 +49,9 @@ class PersonExplicitID(BaseEntity): last_name = String(max_length=50) age = Integer(default=21) + class Meta: + aggregate_cls = "Account" + class Relative(BaseEntity): first_name = String(max_length=50, required=True) @@ -43,12 +59,16 @@ class Relative(BaseEntity): age = Integer(default=21) relative_of = HasOne(Person) + class Meta: + aggregate_cls = "Account" + class Adult(Person): pass class Meta: schema_name = "adults" + aggregate_cls = "Account" class NotAPerson(BaseEntity): @@ -56,6 +76,9 @@ class NotAPerson(BaseEntity): last_name = String(max_length=50) age = Integer(default=21) + class Meta: + aggregate_cls = "Account" + # Entities to test Meta Info overriding # START # class DbPerson(BaseEntity): @@ -65,21 +88,25 @@ class DbPerson(BaseEntity): class Meta: schema_name = "pepes" + aggregate_cls = "Account" class SqlPerson(Person): class Meta: schema_name = "people" + aggregate_cls = "Account" class DifferentDbPerson(Person): class Meta: provider = "non-default" + aggregate_cls = "Account" class SqlDifferentDbPerson(Person): class Meta: provider = "non-default-sql" + aggregate_cls = "Account" class OrderedPerson(BaseEntity): @@ -89,11 +116,13 @@ class OrderedPerson(BaseEntity): class Meta: order_by = "first_name" + aggregate_cls = "Account" class OrderedPersonSubclass(Person): class Meta: order_by = "last_name" + aggregate_cls = "Account" class BuildingStatus(Enum): @@ -101,11 +130,18 @@ class BuildingStatus(Enum): DONE = "DONE" +class Area(BaseAggregate): + name = String(max_length=50) + + class Building(BaseEntity): name = String(max_length=50) floors = Integer() status = String(choices=BuildingStatus) + class Meta: + aggregate_cls = "Area" + def defaults(self): if not self.status: if self.floors == 4: diff --git a/tests/entity/test_entity.py b/tests/entity/test_entity.py index b463fb77..4d624af7 100644 --- a/tests/entity/test_entity.py +++ b/tests/entity/test_entity.py @@ -1,125 +1,19 @@ -from collections import defaultdict -from enum import Enum - -from protean import BaseEntity from protean.container import Options -from protean.fields import Auto, HasOne, Integer, String +from protean.fields import Auto, Integer, String from protean.reflection import attributes, declared_fields - -class AbstractPerson(BaseEntity): - age = Integer(default=5) - - class Meta: - abstract = True - - -class ConcretePerson(BaseEntity): - first_name = String(max_length=50, required=True) - last_name = String(max_length=50) - - -class Person(BaseEntity): - first_name = String(max_length=50, required=True) - last_name = String(max_length=50) - age = Integer(default=21) - - -class PersonAutoSSN(BaseEntity): - ssn = Auto(identifier=True) - first_name = String(max_length=50, required=True) - last_name = String(max_length=50) - age = Integer(default=21) - - -class PersonExplicitID(BaseEntity): - ssn = String(max_length=36, identifier=True) - first_name = String(max_length=50, required=True) - last_name = String(max_length=50) - age = Integer(default=21) - - -class Relative(BaseEntity): - first_name = String(max_length=50, required=True) - last_name = String(max_length=50) - age = Integer(default=21) - relative_of = HasOne(Person) - - -class Adult(Person): - class Meta: - schema_name = "adults" - - -class NotAPerson(BaseEntity): - first_name = String(max_length=50, required=True) - last_name = String(max_length=50) - age = Integer(default=21) - - -# Entities to test Meta Info overriding # START # -class DbPerson(BaseEntity): - first_name = String(max_length=50, required=True) - last_name = String(max_length=50) - age = Integer(default=21) - - class Meta: - schema_name = "pepes" - - -class SqlPerson(Person): - class Meta: - schema_name = "people" - - -class DifferentDbPerson(Person): - class Meta: - provider = "non-default" - - -class SqlDifferentDbPerson(Person): - class Meta: - provider = "non-default-sql" - - -class OrderedPerson(BaseEntity): - first_name = String(max_length=50, required=True) - last_name = String(max_length=50) - age = Integer(default=21) - - class Meta: - order_by = "first_name" - - -class OrderedPersonSubclass(Person): - class Meta: - order_by = "last_name" - - -class BuildingStatus(Enum): - WIP = "WIP" - DONE = "DONE" - - -class Building(BaseEntity): - name = String(max_length=50) - floors = Integer() - status = String(choices=BuildingStatus) - - def defaults(self): - if not self.status: - if self.floors == 4: - self.status = BuildingStatus.DONE.value - else: - self.status = BuildingStatus.WIP.value - - def clean(self): - errors = defaultdict(list) - - if self.floors >= 4 and self.status != BuildingStatus.DONE.value: - errors["status"].append("should be DONE") - - return errors +from .elements import ( + AbstractPerson, + ConcretePerson, + Person, + PersonAutoSSN, + Relative, + SqlDifferentDbPerson, + SqlPerson, + DbPerson, + DifferentDbPerson, + Adult, +) class TestEntityMeta: diff --git a/tests/entity/test_fields.py b/tests/entity/test_fields.py index d3043ab0..9e80f03e 100644 --- a/tests/entity/test_fields.py +++ b/tests/entity/test_fields.py @@ -1,6 +1,6 @@ import pytest -from protean import BaseEntity +from protean import BaseAggregate from protean.exceptions import ValidationError from protean.fields import Boolean, Dict, Integer, List from protean.reflection import fields @@ -8,7 +8,7 @@ class TestFields: def test_lists_can_be_mandatory(self): - class Lottery(BaseEntity): + class Lottery(BaseAggregate): jackpot = Boolean() numbers = List(content_type=Integer, required=True) @@ -18,7 +18,7 @@ class Lottery(BaseEntity): assert exc.value.messages == {"numbers": ["is required"]} def test_dicts_can_be_mandatory(self): - class Lottery(BaseEntity): + class Lottery(BaseAggregate): jackpot = Boolean() numbers = Dict(required=True) @@ -28,13 +28,13 @@ class Lottery(BaseEntity): assert exc.value.messages == {"numbers": ["is required"]} def test_field_description(self): - class Lottery(BaseEntity): + class Lottery(BaseAggregate): jackpot = Boolean(description="Jackpot won or not") assert fields(Lottery)["jackpot"].description == "Jackpot won or not" def test_field_default_description(self): - class Lottery(BaseEntity): + class Lottery(BaseAggregate): jackpot = Boolean() # By default, description is not auto-set. diff --git a/tests/field/elements.py b/tests/field/elements.py index 72bb2403..5adf7b00 100644 --- a/tests/field/elements.py +++ b/tests/field/elements.py @@ -19,6 +19,9 @@ class PostMeta(BaseEntity): post = Reference(Post) + class Meta: + aggregate_cls = Post + class Comment(BaseEntity): content = Text(required=True) diff --git a/tests/field/test_has_one_without_explicit_reference.py b/tests/field/test_has_one_without_explicit_reference.py new file mode 100644 index 00000000..09596644 --- /dev/null +++ b/tests/field/test_has_one_without_explicit_reference.py @@ -0,0 +1,120 @@ +"""These tests are for HasOne fields without explicit reference fields + +A `Reference` field is dynamically created on the referenced entity to hold the reference +to the parent aggregat. +""" + +import pytest + +from protean import BaseAggregate, BaseEntity +from protean.fields import HasOne, String +from protean.reflection import attributes, declared_fields + + +class Book(BaseAggregate): + title = String(required=True, max_length=100) + author = HasOne("Author") + + +class Author(BaseEntity): + name = String(required=True, max_length=50) + + class Meta: + aggregate_cls = "Book" + + +@pytest.fixture(autouse=True) +def register(test_domain): + test_domain.register(Book) + test_domain.register(Author) + + +class TestHasOneFields: + def test_that_has_one_field_appears_in_fields(self): + assert "author" in declared_fields(Book) + + def test_that_has_one_field_does_not_appear_in_attributes(self): + assert "author" not in attributes(Book) + + def test_that_reference_field_appears_in_fields(self): + assert "book" in declared_fields(Author) + + def test_that_reference_field_does_not_appear_in_attributes(self): + assert "book" not in attributes(Author) + + +class TestHasOnePersistence: + def test_that_has_one_field_is_persisted_along_with_aggregate(self, test_domain): + author = Author(name="John Doe") + book = Book(title="My Book", author=author) + + test_domain.repository_for(Book).add(book) + + assert book.id is not None + assert book.author.id is not None + + persisted_book = test_domain.repository_for(Book).get(book.id) + assert persisted_book.author == author + assert persisted_book.author.id == author.id + assert persisted_book.author.name == author.name + + def test_that_has_one_field_is_persisted_on_aggregate_update(self, test_domain): + book = Book(title="My Book") + test_domain.repository_for(Book).add(book) + + assert book.id is not None + assert book.author is None + + author = Author(name="John Doe") + + # Fetch the persisted book and update its author + persisted_book = test_domain.repository_for(Book).get(book.id) + persisted_book.author = author + test_domain.repository_for(Book).add(persisted_book) + + # Fetch it again to ensure the author is persisted + persisted_book = test_domain.repository_for(Book).get(book.id) + + # Ensure that the author is persisted along with the book + assert persisted_book.author == author + assert persisted_book.author.id == author.id + assert persisted_book.author.name == author.name + + def test_that_has_one_field_is_updated_with_new_entity_on_aggregate_update( + self, test_domain + ): + author = Author(name="John Doe") + book = Book(title="My Book", author=author) + + test_domain.repository_for(Book).add(book) + + persisted_book = test_domain.repository_for(Book).get(book.id) + + # Switch the author to a new one + new_author = Author(name="Jane Doe") + persisted_book.author = new_author + + test_domain.repository_for(Book).add(persisted_book) + + # Fetch the book again to ensure the author is updated + updated_book = test_domain.repository_for(Book).get(persisted_book.id) + assert updated_book.author == new_author + assert updated_book.author.id == new_author.id + assert updated_book.author.name == new_author.name + + def test_that_has_one_field_can_be_removed_on_aggregate_update(self, test_domain): + author = Author(name="John Doe") + book = Book(title="My Book", author=author) + + test_domain.repository_for(Book).add(book) + + persisted_book = test_domain.repository_for(Book).get(book.id) + + # Remove the author from the book + persisted_book.author = None + + test_domain.repository_for(Book).add(persisted_book) + + # Fetch the book again to ensure the author is removed + updated_book = test_domain.repository_for(Book).get(persisted_book.id) + assert updated_book.author is None diff --git a/tests/field/test_reference.py b/tests/field/test_reference.py index 98ff6ea8..403d8b07 100644 --- a/tests/field/test_reference.py +++ b/tests/field/test_reference.py @@ -8,6 +8,9 @@ class Address(BaseEntity): postal_code = String(max_length=6) + class Meta: + aggregate_cls = "User" + class User(BaseAggregate): address = Reference(Address, required=True) diff --git a/tests/reflection/test_has_id_field.py b/tests/reflection/test_has_id_field.py index 7d269aee..1c6e0d8b 100644 --- a/tests/reflection/test_has_id_field.py +++ b/tests/reflection/test_has_id_field.py @@ -7,6 +7,9 @@ class Entity1(BaseEntity): foo = String() + class Meta: + aggregate_cls = "Aggregate1" + class Aggregate1(BaseAggregate): foo = String() diff --git a/tests/views/test_view_validations_for_fields.py b/tests/views/test_view_validations_for_fields.py index 8d9f5d66..07581563 100644 --- a/tests/views/test_view_validations_for_fields.py +++ b/tests/views/test_view_validations_for_fields.py @@ -1,17 +1,27 @@ import pytest -from protean import BaseEntity, BaseValueObject, BaseView +from protean import BaseAggregate, BaseEntity, BaseValueObject, BaseView from protean.exceptions import IncorrectUsageError from protean.fields import HasOne, Identifier, Reference, String, ValueObject +class User(BaseAggregate): + name = String() + + class Email(BaseValueObject): address = String() + class Meta: + aggregate_cls = "User" + class Role(BaseEntity): name = String(max_length=50) + class Meta: + aggregate_cls = "User" + def test_that_views_should_have_at_least_one_identifier_field(): with pytest.raises(IncorrectUsageError) as exception: