From 7d562a82e63645cb9bef5fcbbee38ef1fb9c1ffc Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Sat, 11 May 2024 16:32:22 -0700 Subject: [PATCH] 415 Generate identity as first step during entity initialization This commit also contains minor documentation fixes to related methods. It also removes autogeneration of description in a field. Now description will only be populated when explicity provided. --- src/protean/adapters/repository/memory.py | 2 +- src/protean/core/entity.py | 55 ++-- src/protean/fields/association.py | 76 ++++-- src/protean/fields/basic.py | 44 +++- src/protean/fields/embedded.py | 14 +- src/protean/fields/mixins.py | 9 - src/protean/fields/validators.py | 2 +- .../test_aggregate_association_dao.py | 239 ++++++++++++++++++ tests/entity/test_fields.py | 21 +- tests/field/test_has_one.py | 115 +++++++++ 10 files changed, 503 insertions(+), 74 deletions(-) create mode 100644 tests/aggregate/test_aggregate_association_dao.py create mode 100644 tests/field/test_has_one.py diff --git a/src/protean/adapters/repository/memory.py b/src/protean/adapters/repository/memory.py index 0cd0b4d3..cfc89acc 100644 --- a/src/protean/adapters/repository/memory.py +++ b/src/protean/adapters/repository/memory.py @@ -49,7 +49,7 @@ def from_entity(cls, entity) -> "MemoryModel": @classmethod def to_entity(cls, item: "MemoryModel"): """Convert the dictionary record to an entity""" - return cls.meta_.entity_cls(item) + return cls.meta_.entity_cls(**item) class MemorySession: diff --git a/src/protean/core/entity.py b/src/protean/core/entity.py index 55c3ca6c..77c74dbb 100644 --- a/src/protean/core/entity.py +++ b/src/protean/core/entity.py @@ -166,8 +166,28 @@ def __init__(self, *template, **kwargs): # noqa: C901 if isinstance(field_obj, Reference) } - # Load the attributes based on the template + # Track fields that have been loaded loaded_fields = [] + + # Pick identifier if provided in template or kwargs + # Generate if not provided + id_field_obj = id_field(self) + id_field_name = id_field_obj.field_name + if kwargs and id_field_name in kwargs: + setattr(self, id_field_name, kwargs.pop(id_field_name)) + loaded_fields.append(id_field_name) + elif template: + for dictionary in template: + if id_field_name in dictionary: + setattr(self, id_field_name, dictionary[id_field_name]) + loaded_fields.append(id_field_name) + break + else: + if type(id_field_obj) is Auto and not id_field_obj.increment: + setattr(self, id_field_name, generate_identity()) + loaded_fields.append(id_field_name) + + # Load the attributes based on the template for dictionary in template: if not isinstance(dictionary, dict): raise AssertionError( @@ -176,22 +196,23 @@ def __init__(self, *template, **kwargs): # noqa: C901 f"values.", ) for field_name, val in dictionary.items(): - if field_name not in kwargs: + if field_name not in kwargs and field_name not in loaded_fields: kwargs[field_name] = val # Now load against the keyword arguments for field_name, val in kwargs.items(): - try: - setattr(self, field_name, val) - except ValidationError as err: - for field_name in err.messages: - self.errors[field_name].extend(err.messages[field_name]) - finally: - loaded_fields.append(field_name) - - # Also note reference field name if its attribute was loaded - if field_name in reference_attributes: - loaded_fields.append(reference_attributes[field_name]) + if field_name not in loaded_fields: + try: + setattr(self, field_name, val) + except ValidationError as err: + for field_name in err.messages: + self.errors[field_name].extend(err.messages[field_name]) + finally: + loaded_fields.append(field_name) + + # Also note reference field name if its attribute was loaded + if field_name in reference_attributes: + loaded_fields.append(reference_attributes[field_name]) # Load Value Objects from associated fields # This block will dynamically construct value objects from field values @@ -225,9 +246,13 @@ def __init__(self, *template, **kwargs): # noqa: C901 "{}_{}".format(field_name, sub_field_name) ].extend(err.messages[sub_field_name]) - # Load Identities + # Load other identities for field_name, field_obj in declared_fields(self).items(): - if type(field_obj) is Auto and not field_obj.increment: + if ( + field_name not in loaded_fields + and type(field_obj) is Auto + and not field_obj.increment + ): if not getattr(self, field_obj.field_name, None): setattr(self, field_obj.field_name, generate_identity()) loaded_fields.append(field_obj.field_name) diff --git a/src/protean/fields/association.py b/src/protean/fields/association.py index 8111706d..c0b2bd8d 100644 --- a/src/protean/fields/association.py +++ b/src/protean/fields/association.py @@ -9,16 +9,26 @@ class _ReferenceField(Field): - """Shadow Attribute Field to back References""" + """ + Represents a reference field that can be used to establish associations between entities. + + Args: + reference (str): The reference field as an attribute. + **kwargs: Additional keyword arguments to be passed to the base `Field` class. + """ def __init__(self, reference, **kwargs): - """Accept reference field as a an attribute, otherwise is a straightforward field""" + """Accept reference field as an attribute, otherwise is a straightforward field""" self.reference = reference super().__init__(**kwargs) def __set__(self, instance, value): """Override `__set__` to update relation field and keep it in sync with the shadow attribute's value + + Args: + instance: The instance of the class. + value: The value to be set. """ value = self._load(value) @@ -29,20 +39,43 @@ def __set__(self, instance, value): self._reset_values(instance) def __delete__(self, instance): - """Nullify values and linkages""" + """Nullify values and linkages + + Args: + instance: The instance of the class. + """ self._reset_values(instance) def _cast_to_type(self, value): - """Verify type of value assigned to the shadow field""" + """Verify the type of value assigned to the shadow field + + Args: + value: The value to be assigned. + + Returns: + The casted value. + """ # FIXME Verify that the value being assigned is compatible with the remote field return value def as_dict(self, value): - """Return JSON-compatible value of self""" + """Return JSON-compatible value of self + + Args: + value: The value to be converted to JSON. + + Raises: + NotImplementedError: This method needs to be implemented in the derived class. + + """ raise NotImplementedError def _reset_values(self, instance): - """Reset all associated values and clean up dictionary items""" + """Reset all associated values and clean up dictionary items + + Args: + instance: The instance of the class. + """ self.value = None self.reference.value = None instance.__dict__.pop(self.field_name, None) @@ -52,11 +85,14 @@ def _reset_values(self, instance): class Reference(FieldCacheMixin, Field): """ - Provide a many-to-one relation by adding an attribute to the local entity - to hold the remote value. - - By default ForeignKey will target the `id` column of the remote model but this - behavior can be changed by using the ``via`` argument. + A field representing a reference to another entity. This field is used to establish + the reverse relationship to the remote entity. + + Args: + to_cls (str or Entity): The target entity class or its name. + via (str, optional): The linkage attribute between `via` and the designated + `id_field` of the target class. + **kwargs: Additional keyword arguments to be passed to the base `Field` class. """ def __init__(self, to_cls, via=None, **kwargs): @@ -207,7 +243,17 @@ def as_dict(self, value): class Association(FieldBase, FieldDescriptorMixin, FieldCacheMixin): - """Base class for all association classes""" + """ + Represents an association between entities in a domain model. + + An association field allows one entity to reference another entity in the domain model. + It provides methods to retrieve associated objects and handle changes in the association. + + Args: + to_cls (class): The class of the target entity that this association references. + via (str, optional): The name of the linkage attribute between the associated entities. + If not provided, a default linkage attribute is generated based on the entity names. + """ def __init__(self, to_cls, via=None, **kwargs): super().__init__(**kwargs) @@ -308,10 +354,10 @@ def has_changed(self): class HasOne(Association): """ - Provide a HasOne relation to a remote entity. + Represents a one-to-one association between two entities. - By default, the query will lookup an attribute of the form `_id` - to fetch and populate. This behavior can be changed by using the `via` argument. + This class is used to define a relationship where an instance of one entity + is associated with at most one instance of another entity. """ def __set__(self, instance, value): diff --git a/src/protean/fields/basic.py b/src/protean/fields/basic.py index 7c39f481..dca0805c 100644 --- a/src/protean/fields/basic.py +++ b/src/protean/fields/basic.py @@ -208,7 +208,14 @@ def as_dict(self, value): class List(Field): - """Concrete field implementation for the List type.""" + """ + A field that represents a list of values. + + :param content_type: The type of the items in the list. + :type content_type: Field, optional + :param pickled: Whether the list should be pickled when stored, defaults to False. + :type pickled: bool, optional + """ default_error_messages = { "invalid": '"{value}" value must be of list type.', @@ -256,12 +263,16 @@ def _cast_to_type(self, value): def as_dict(self, value): """Return JSON-compatible value of self""" - # FIXME Convert value of objects that the list holds? return value class Dict(Field): - """Concrete field implementation for the Dict type.""" + """ + A field that represents a dictionary. + + :param pickled: Whether to store the dictionary as a pickled object. + :type pickled: bool, optional + """ default_error_messages = { "invalid": '"{value}" value must be of dict type.', @@ -288,15 +299,18 @@ def as_dict(self, value): class Auto(Field): - """Concrete field implementation for the Database Autogenerated types.""" + """ + Auto Field represents an automatically generated field value. - def __init__(self, increment=False, **kwargs): - """Initialize an Auto Field + Values of Auto-fields are generated automatically and cannot be set explicitly. + They cannot be marked as `required` for this reason - Protean does not accept + values supplied for Auto fields. - Values of Auto-fields are generated automatically and cannot be set explicitly. - They cannot be marked as `required` for this reason - Protean does not accept - values supplied for Auto fields. - """ + Args: + increment (bool): Flag indicating whether the field value should be incremented automatically. + """ + + def __init__(self, increment=False, **kwargs): self.increment = increment super().__init__(**kwargs) @@ -341,11 +355,15 @@ def __repr__(self): class Identifier(Field): - """Concrete field implementation for Identifiers. + """ + Represents an identifier field in a domain entity. - An identity field is immutable and cannot be changed once set. + An identifier field is used to uniquely identify an entity within a domain. + It can have different types such as UUID, string, or integer, depending on the configuration. - Values can be UUIDs, Integers or Strings. + :param identity_type: The type of the identifier field. If not provided, it will be picked from the domain config. + :type identity_type: str, optional + :raises ValidationError: If the provided identity type is not supported. """ def __init__(self, identity_type=None, **kwargs): diff --git a/src/protean/fields/embedded.py b/src/protean/fields/embedded.py index e40ef36a..7d9fa27d 100644 --- a/src/protean/fields/embedded.py +++ b/src/protean/fields/embedded.py @@ -40,7 +40,19 @@ def _reset_values(self, instance): class ValueObject(Field): - """Field implementation for Value Objects""" + """ + Represents a field that holds a value object. + + This field is used to embed a value object within an entity. It provides + functionality to handle the value object's fields and their values. + + Args: + value_object_cls (class): The class of the value object to be embedded. + + Attributes: + embedded_fields (dict): A dictionary that holds the embedded fields of the value object. + + """ def __init__(self, value_object_cls, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/src/protean/fields/mixins.py b/src/protean/fields/mixins.py index eba0f548..f21d70f4 100644 --- a/src/protean/fields/mixins.py +++ b/src/protean/fields/mixins.py @@ -1,5 +1,3 @@ -import inflection - NOT_PROVIDED = object() @@ -44,13 +42,6 @@ def __set_name__(self, entity_cls, name): self.field_name = name self.attribute_name = self.get_attribute_name() - # Set the description for this field - self.description = ( - self.description - if self.description - else inflection.titleize(self.attribute_name).strip() - ) - # Record Entity setting up the field self._entity_cls = entity_cls diff --git a/src/protean/fields/validators.py b/src/protean/fields/validators.py index 9c43ff43..9f81f24c 100644 --- a/src/protean/fields/validators.py +++ b/src/protean/fields/validators.py @@ -77,7 +77,7 @@ def __init__( self.flags = flags if self.flags and not isinstance(self.regex, str): raise TypeError( - "If the flags are set, regex must be a regular expression string." + "If flags are set, regex must be a regular expression string." ) self.regex = re.compile(self.regex, self.flags) diff --git a/tests/aggregate/test_aggregate_association_dao.py b/tests/aggregate/test_aggregate_association_dao.py new file mode 100644 index 00000000..5c550d48 --- /dev/null +++ b/tests/aggregate/test_aggregate_association_dao.py @@ -0,0 +1,239 @@ +import mock +import pytest + +from protean.reflection import attributes + +from .elements import ( + Account, + AccountVia, + AccountViaWithReference, + Author, + Comment, + CommentVia, + CommentViaWithReference, + Post, + PostVia, + PostViaWithReference, + Profile, + ProfileVia, + ProfileViaWithReference, +) + + +class TestHasOne: + @pytest.fixture(autouse=True) + def register_elements(self, test_domain): + test_domain.register(Account) + test_domain.register(Author) + test_domain.register(AccountVia) + test_domain.register(AccountViaWithReference) + test_domain.register(Post) + test_domain.register(Profile) + test_domain.register(ProfileVia) + test_domain.register(ProfileViaWithReference) + + def test_successful_initialization_of_entity_with_has_one_association( + self, test_domain + ): + account = Account(email="john.doe@gmail.com", password="a1b2c3") + test_domain.repository_for(Account)._dao.save(account) + author = Author(first_name="John", last_name="Doe", account=account) + test_domain.repository_for(Author)._dao.save(author) + + assert all(key in author.__dict__ for key in ["account", "account_email"]) + assert author.account.email == account.email + assert author.account_email == account.email + + refreshed_account = test_domain.repository_for(Account)._dao.get(account.email) + assert refreshed_account.author.id == author.id + assert refreshed_account.author == author + + def test_successful_has_one_initialization_with_a_class_containing_via_and_no_reference( + self, test_domain + ): + account = AccountVia(email="john.doe@gmail.com", password="a1b2c3") + test_domain.repository_for(AccountVia)._dao.save(account) + profile = ProfileVia( + profile_id="12345", about_me="Lorem Ipsum", account_email=account.email + ) + test_domain.repository_for(ProfileVia)._dao.save(profile) + + refreshed_account = test_domain.repository_for(AccountVia)._dao.get( + account.email + ) + assert refreshed_account.profile == profile + + def test_successful_has_one_initialization_with_a_class_containing_via_and_reference( + self, test_domain + ): + account = AccountViaWithReference( + email="john.doe@gmail.com", password="a1b2c3", username="johndoe" + ) + test_domain.repository_for(AccountViaWithReference)._dao.save(account) + profile = ProfileViaWithReference(about_me="Lorem Ipsum", ac=account) + test_domain.repository_for(ProfileViaWithReference)._dao.save(profile) + + refreshed_account = test_domain.repository_for( + AccountViaWithReference + )._dao.get(account.email) + assert refreshed_account.profile == profile + + @mock.patch("protean.fields.association.Association._fetch_objects") + def test_that_subsequent_access_after_first_retrieval_do_not_fetch_record_again( + self, mock, test_domain + ): + account = AccountViaWithReference( + email="john.doe@gmail.com", password="a1b2c3", username="johndoe" + ) + test_domain.repository_for(AccountViaWithReference)._dao.save(account) + profile = ProfileViaWithReference(about_me="Lorem Ipsum", ac=account) + test_domain.repository_for(ProfileViaWithReference)._dao.save(profile) + + mock.return_value = profile + + refreshed_account = test_domain.repository_for( + AccountViaWithReference + )._dao.get(account.email) + for _ in range(3): + getattr(refreshed_account, "profile") + assert ( + mock.call_count == 0 + ) # This is because `profile` would have been loaded when account was fetched + + +class TestHasMany: + @pytest.fixture(autouse=True) + def register_elements(self, test_domain): + test_domain.register(Post) + test_domain.register(PostVia) + test_domain.register(PostViaWithReference) + test_domain.register(Comment) + test_domain.register(CommentVia) + test_domain.register(CommentViaWithReference) + + @pytest.fixture + def persisted_post(self, test_domain): + post = test_domain.repository_for(Post)._dao.create(content="Do Re Mi Fa") + return post + + def test_successful_initialization_of_entity_with_has_many_association( + self, test_domain + ): + post = Post(content="Lorem Ipsum") + test_domain.repository_for(Post).add(post) + + comment1 = Comment(id=101, content="First Comment") + comment2 = Comment(id=102, content="Second Comment") + + post.add_comments(comment1) + post.add_comments(comment2) + test_domain.repository_for(Post).add(post) + + refreshed_post = test_domain.repository_for(Post).get(post.id) + assert len(refreshed_post.comments) == 2 + assert "comments" in refreshed_post.__dict__ # Available after access + assert refreshed_post.comments[0].post_id == post.id + assert refreshed_post.comments[1].post_id == post.id + + assert isinstance(refreshed_post.comments, list) + assert all( + comment.id in [101, 102] for comment in refreshed_post.comments + ) # `__iter__` magic here + + def test_adding_multiple_associations_at_the_same_time(self, test_domain): + post = Post(content="Lorem Ipsum") + test_domain.repository_for(Post).add(post) + + comment1 = Comment(id=101, content="First Comment") + comment2 = Comment(id=102, content="Second Comment") + + post.add_comments([comment1, comment2]) + test_domain.repository_for(Post).add(post) + + refreshed_post = test_domain.repository_for(Post).get(post.id) + assert len(refreshed_post.comments) == 2 + assert "comments" in refreshed_post.__dict__ # Available after access + assert refreshed_post.comments[0].post_id == post.id + assert refreshed_post.comments[1].post_id == post.id + + assert isinstance(refreshed_post.comments, list) + assert all( + comment.id in [101, 102] for comment in refreshed_post.comments + ) # `__iter__` magic here + + def test_successful_has_one_initialization_with_a_class_containing_via_and_no_reference( + self, test_domain + ): + post = PostVia(content="Lorem Ipsum") + test_domain.repository_for(PostVia)._dao.save(post) + comment1 = CommentVia(id=101, content="First Comment", posting_id=post.id) + comment2 = CommentVia(id=102, content="First Comment", posting_id=post.id) + test_domain.repository_for(CommentVia)._dao.save(comment1) + test_domain.repository_for(CommentVia)._dao.save(comment2) + + assert comment1.posting_id == post.id + assert comment2.posting_id == post.id + + refreshed_post = test_domain.repository_for(PostVia)._dao.get(post.id) + assert len(refreshed_post.comments) == 2 + assert "comments" in refreshed_post.__dict__ # Available after access + + assert isinstance(refreshed_post.comments, list) + assert all( + comment.id in [101, 102] for comment in refreshed_post.comments + ) # `__iter__` magic here + + def test_successful_has_one_initialization_with_a_class_containing_via_and_reference( + self, test_domain + ): + post = PostViaWithReference(content="Lorem Ipsum") + test_domain.repository_for(PostViaWithReference)._dao.save(post) + comment1 = CommentViaWithReference( + id=101, content="First Comment", posting=post + ) + comment2 = CommentViaWithReference( + id=102, content="First Comment", posting=post + ) + test_domain.repository_for(CommentViaWithReference)._dao.save(comment1) + test_domain.repository_for(CommentViaWithReference)._dao.save(comment2) + + assert comment1.posting_id == post.id + assert comment2.posting_id == post.id + + refreshed_post = test_domain.repository_for(PostViaWithReference)._dao.get( + post.id + ) + assert len(refreshed_post.comments) == 2 + assert "comments" in refreshed_post.__dict__ # Available after access + + assert isinstance(refreshed_post.comments, list) + assert all( + comment.id in [101, 102] for comment in refreshed_post.comments + ) # `__iter__` magic here + + def test_that_subsequent_access_after_first_retrieval_do_not_fetch_record_again( + self, test_domain + ): + post = PostViaWithReference(content="Lorem Ipsum") + test_domain.repository_for(PostViaWithReference)._dao.save(post) + comment1 = CommentViaWithReference( + id=101, content="First Comment", posting=post + ) + comment2 = CommentViaWithReference( + id=102, content="First Comment", posting=post + ) + test_domain.repository_for(CommentViaWithReference)._dao.save(comment1) + test_domain.repository_for(CommentViaWithReference)._dao.save(comment2) + + refreshed_post = test_domain.repository_for(PostViaWithReference)._dao.get( + post.id + ) + with mock.patch("protean.fields.HasMany._fetch_objects") as mock_fetch_objects: + for _ in range(3): + getattr(refreshed_post, "comments") + assert mock_fetch_objects.call_count == 0 + + +class TestReference: + def test_that_reference_field_attribute_name_is_set_properly(self): + assert attributes(Author)["account_email"].attribute_name is not None diff --git a/tests/entity/test_fields.py b/tests/entity/test_fields.py index 7554f67f..d3043ab0 100644 --- a/tests/entity/test_fields.py +++ b/tests/entity/test_fields.py @@ -7,15 +7,6 @@ class TestFields: - @pytest.mark.xfail # To be addressed as part of https://github.com/proteanhq/protean/issues/335 - def test_list_default(self): - class Lottery(BaseEntity): - numbers = List(content_type=Integer) - - lottery = Lottery() - assert lottery.numbers is not None - assert lottery.numbers == [] - def test_lists_can_be_mandatory(self): class Lottery(BaseEntity): jackpot = Boolean() @@ -26,15 +17,6 @@ class Lottery(BaseEntity): assert exc.value.messages == {"numbers": ["is required"]} - @pytest.mark.xfail # To be addressed as part of https://github.com/proteanhq/protean/issues/335 - def test_dict_default(self): - class Lottery(BaseEntity): - numbers = Dict() - - lottery = Lottery() - assert lottery.numbers is not None - assert lottery.numbers == {} - def test_dicts_can_be_mandatory(self): class Lottery(BaseEntity): jackpot = Boolean() @@ -55,4 +37,5 @@ def test_field_default_description(self): class Lottery(BaseEntity): jackpot = Boolean() - assert fields(Lottery)["jackpot"].description == "Jackpot" + # By default, description is not auto-set. + assert fields(Lottery)["jackpot"].description is None diff --git a/tests/field/test_has_one.py b/tests/field/test_has_one.py new file mode 100644 index 00000000..d26c1ec9 --- /dev/null +++ b/tests/field/test_has_one.py @@ -0,0 +1,115 @@ +import pytest + +from protean import BaseAggregate, BaseEntity +from protean.fields import HasOne, Reference, 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) + book = Reference("Book") + + 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