Skip to content

Commit

Permalink
Introduce Field.clone method
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 committed Aug 9, 2024
1 parent 792bf75 commit aa78586
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 aa78586

Please sign in to comment.