Skip to content

Commit

Permalink
Introduce Field.clone method (#452)
Browse files Browse the repository at this point in the history
The fact event generation logic was considering attributes from elements directly
and constructing new value-object based event classes directly. This does not work
well because the same attribute is shared between the actual element and the newly
generated event class.

This commit introduces a clone method at the field level so we can copy the field
safely.
  • Loading branch information
subhashb authored Aug 9, 2024
1 parent 792bf75 commit 97a2dde
Show file tree
Hide file tree
Showing 4 changed files with 132 additions and 1 deletion.
2 changes: 1 addition & 1 deletion src/protean/core/aggregate.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ def element_to_fact_event(element_cls):
# Gather all fields defined in the element, except References.
# We ignore references in event payloads.
attrs = {
key: value
key: value._clone()
for key, value in fields(element_cls).items()
if not isinstance(value, Reference)
}
Expand Down
8 changes: 8 additions & 0 deletions src/protean/fields/association.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,14 @@ def get_cache_name(self):
def has_changed(self):
return self.change is not None

def _clone(self) -> "Association":
"""
Clone the field with all its attributes.
:return: Cloned Field object
"""
return self


class HasOne(Association):
"""
Expand Down
18 changes: 18 additions & 0 deletions src/protean/fields/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,24 @@ def _run_validators(self, value):
if errors:
raise exceptions.ValidationError(errors)

def _clone(self) -> "Field":
"""
Clone the field with all its attributes.
:return: Cloned Field object
"""
return self.__class__(
referenced_as=self.referenced_as,
description=self.description,
identifier=self.identifier,
default=self.default,
required=self.required,
unique=self.unique,
choices=self.choices,
validators=self._validators,
error_messages=self.error_messages,
)

def _load(self, value: Any):
"""
Load the value for the field, run validators and return the value.
Expand Down
105 changes: 105 additions & 0 deletions tests/field/test_clone.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
from unittest.mock import MagicMock

from protean.fields import Text


class TestFieldClone:
def test_clone_basic_field(self):
# Arrange
field = Text(
referenced_as="test_field",
description="Test Text",
identifier=True,
default="default_value",
required=True,
unique=True,
choices=None,
validators=[],
error_messages={"invalid": "Invalid value"},
)

# Act
cloned_field = field._clone()

# Assert
assert cloned_field is not field, "The cloned field should be a new instance"
assert cloned_field.referenced_as == field.referenced_as
assert cloned_field.description == field.description
assert cloned_field.identifier == field.identifier
assert cloned_field.default == field.default
assert cloned_field.required == field.required
assert cloned_field.unique == field.unique
assert cloned_field.choices == field.choices
assert cloned_field.validators == field.validators
assert cloned_field.error_messages == field.error_messages

def test_clone_with_choices(self):
# Arrange
choices_mock = MagicMock()
field = Text(
referenced_as="test_field",
description="Test Text",
identifier=False,
default=None,
required=False,
unique=False,
choices=choices_mock,
validators=[],
error_messages={"invalid_choice": "Invalid choice"},
)

# Act
cloned_field = field._clone()

# Assert
assert cloned_field is not field, "The cloned field should be a new instance"
assert (
cloned_field.choices == field.choices
), "Choices should be identical in the clone"

def test_clone_with_validators(self):
# Arrange
validators = [lambda x: x > 0]
field = Text(
referenced_as="test_field",
description="Test Text",
identifier=False,
default=None,
required=False,
unique=False,
choices=None,
validators=validators,
error_messages={"required": "This field is required"},
)

# Act
cloned_field = field._clone()

# Assert
assert cloned_field is not field, "The cloned field should be a new instance"
assert (
cloned_field.validators == field.validators
), "Validators should be identical in the clone"

def test_clone_with_default_callable(self):
# Arrange
field = Text(
referenced_as="test_field",
description="Test Text",
identifier=False,
default=lambda: "dynamic_default",
required=False,
unique=False,
choices=None,
validators=[],
error_messages={"invalid": "Invalid value"},
)

# Act
cloned_field = field._clone()

# Assert
assert cloned_field is not field, "The cloned field should be a new instance"
assert (
cloned_field.default is field.default
), "Default callable should be identical in the clone"

0 comments on commit 97a2dde

Please sign in to comment.