def test_that_class_referenced_is_resolved_as_soon_as_element_is_registered( self, test_domain): class Post(BaseAggregate): content = Text(required=True) comments = HasMany("Comment") test_domain.register(Post) # Still a string reference assert isinstance(declared_fields(Post)["comments"].to_cls, str) class Comment(BaseEntity): content = Text() added_on = DateTime() post = Reference("Post") class Meta: aggregate_cls = Post # Still a string reference assert isinstance(declared_fields(Comment)["post"].to_cls, str) assert (len(test_domain._pending_class_resolutions) == 1 ) # Comment has not been registered yet # Registering `Comment` resolves references in both `Comment` and `Post` classes test_domain.register(Comment) assert declared_fields(Post)["comments"].to_cls == Comment assert declared_fields(Comment)["post"].to_cls == Post assert len(test_domain._pending_class_resolutions) == 0
def test_id_field_in_meta(self): assert hasattr(Person, _ID_FIELD_NAME) assert id_field(Person) is not None assert id_field(Person) == declared_fields(Person)["person_id"] assert type(id_field(Person)) is Identifier declared_fields(Person)["person_id"].identifier is True
def test_that_meta_is_loaded_with_attributes(self): assert UserSchema.meta_.aggregate_cls is not None assert UserSchema.meta_.aggregate_cls == User assert declared_fields(UserSchema) is not None assert all(key in declared_fields(UserSchema) for key in ["name", "age"])
def test_id_field_recognition(self): assert "person_id" in declared_fields(Person) assert "person_id" in attributes(Person) assert type(declared_fields(Person)["person_id"]) is Identifier assert id_field(Person) == declared_fields(Person)["person_id"] declared_fields(Person)["person_id"].identifier is True
def test_that_class_reference_is_resolved_on_domain_activation(self): domain = Domain("Inline Domain") class Post(BaseAggregate): content = Text(required=True) comments = HasMany("Comment") domain.register(Post) # Still a string assert isinstance(declared_fields(Post)["comments"].to_cls, str) class Comment(BaseEntity): content = Text() added_on = DateTime() post = Reference("Post") class Meta: aggregate_cls = Post domain.register(Comment) with domain.domain_context(): # Resolved references assert declared_fields(Post)["comments"].to_cls == Comment assert declared_fields(Comment)["post"].to_cls == Post assert len(domain._pending_class_resolutions) == 0
def test_non_default_auto_id_field_construction(self): assert "id" not in declared_fields(PersonAutoSSN) assert "id" not in attributes(PersonAutoSSN) assert type(declared_fields(PersonAutoSSN)["ssn"]) is Auto assert id_field(PersonAutoSSN).field_name == "ssn" assert id_field(PersonAutoSSN) == declared_fields(PersonAutoSSN)["ssn"]
def test_non_default_explicit_id_field_construction(self, test_domain): assert "id" not in declared_fields(PersonExplicitID) assert "id" not in attributes(PersonExplicitID) assert type(declared_fields(PersonExplicitID)["ssn"]) is String assert id_field(PersonExplicitID).field_name == "ssn" assert id_field(PersonExplicitID) == declared_fields( PersonExplicitID)["ssn"]
def test_that_explicit_names_are_preserved_in_aggregate(self): assert len(declared_fields(PolymorphicOwner)) == 2 assert "id" in declared_fields(PolymorphicOwner) assert "connector" in declared_fields(PolymorphicOwner) owner = PolymorphicOwner() attrs = attributes(owner) assert attrs is not None assert "connected_id" in attrs assert "connected_type" in attrs
def test_field_order_when_overridden_after_inheritance(self): class ChildCustomContainer(CustomContainer): baz = String() foo = Integer() assert list(declared_fields(ChildCustomContainer).keys()) == [ "foo", "bar", "baz", ] assert isinstance(declared_fields(ChildCustomContainer)["foo"], Integer)
def test_that_class_reference_is_tracked_at_the_domain_level(self): domain = Domain() class Post(BaseAggregate): content = Text(required=True) comments = HasMany("Comment") domain.register(Post) # Still a string assert isinstance(declared_fields(Post)["comments"].to_cls, str) class Comment(BaseEntity): content = Text() added_on = DateTime() post = Reference("Post") class Meta: aggregate_cls = Post domain.register(Comment) assert len(domain._pending_class_resolutions) == 2 assert all(field_name in domain._pending_class_resolutions for field_name in ["Comment", "Post"])
def __track_id_field(subclass): """Check if an identifier field has been associated with the command. When an identifier is provided, its value is used to construct unique stream name.""" if declared_fields(subclass): try: id_field = next( field for _, field in declared_fields(subclass).items() if isinstance(field, (Field)) and field.identifier) setattr(subclass, _ID_FIELD_NAME, id_field.field_name) except StopIteration: # No Identity fields declared pass
def test_that_fields_in_base_classes_are_inherited(self): declared_fields_keys = list( OrderedDict(sorted(declared_fields(ConcreteRole).items())).keys()) assert declared_fields_keys == ["bar", "foo", "id"] role = ConcreteRole(id=3, foo="foo", bar="bar") assert role is not None assert role.foo == "foo"
def test_declared_has_one_fields_in_an_aggregate(self, test_domain): # `author` is a HasOne field, so it should be: # absent in attributes # present as `author` in declared_fields assert all(key in declared_fields(AccountWithId) for key in ["author", "email", "id", "password"]) assert all(key in attributes(AccountWithId) for key in ["email", "id", "password"]) assert "author" not in attributes(AccountWithId) # `account` is a Reference field, so it should be present as: # `account_id` in attributes # `account` in declared_fields assert all(key in declared_fields(ProfileWithAccountId) for key in ["about_me", "account"]) assert all(key in attributes(ProfileWithAccountId) for key in ["about_me", "account_id"])
def test_declared_has_many_fields_in_an_aggregate(self, test_domain): # `comments` is a HasMany field, so it should be: # absent in attributes # present as `comments` in declared_fields assert all(key in declared_fields(Post) for key in ["comments", "content", "id", "author"]) assert all(key in attributes(Post) for key in ["content", "id", "author_id"]) assert "comments" not in attributes(Post) # `post` is a Reference field, so it should be present as: # `post_id` in attributes # `post` in declared_fields assert all(key in declared_fields(Comment) for key in ["added_on", "content", "id", "post"]) assert all(key in attributes(Comment) for key in ["added_on", "content", "id", "post_id"])
def test_field_order_after_inheritance(self): class ChildCustomContainer(CustomContainer): baz = String() assert list(declared_fields(ChildCustomContainer).keys()) == [ "foo", "bar", "baz", ]
def test_that_abstract_aggregates_do_not_have_id_field(self, test_domain): @test_domain.aggregate class TimeStamped: created_at = DateTime(default=datetime.utcnow) updated_at = DateTime(default=datetime.utcnow) class Meta: abstract = True assert "id" not in declared_fields(TimeStamped)
def test_that_has_many_field_references_are_resolved( self, test_domain): class Post(BaseAggregate): content = Text(required=True) comments = HasMany("Comment") class Comment(BaseEntity): content = Text() added_on = DateTime() post = Reference("Post") class Meta: aggregate_cls = Post test_domain.register(Post) test_domain.register(Comment) assert declared_fields(Post)["comments"].to_cls == Comment assert declared_fields(Comment)["post"].to_cls == Post assert len(test_domain._pending_class_resolutions) == 0
def test_view_meta_attributes(self): assert hasattr(Person, "meta_") assert type(Person.meta_) is Options # Persistence attributes # FIXME Should these be present as part of Views, or a separate Model? assert hasattr(Person.meta_, "abstract") assert hasattr(Person.meta_, "schema_name") assert hasattr(Person.meta_, "provider") assert hasattr(Person.meta_, "cache") assert id_field(Person) is not None assert id_field(Person) == declared_fields(Person)["person_id"]
def test_that_unknown_class_reference_is_tracked_at_the_domain_level( self, test_domain): class Post(BaseAggregate): content = Text(required=True) comments = HasMany("Comment") test_domain.register(Post) assert "Comment" in test_domain._pending_class_resolutions # The content in _pending_class_resolutions is dict -> tuple array # key: field name # value: tuple of (Field Object, Owning Domain Element) assert (test_domain._pending_class_resolutions["Comment"][0][0] == declared_fields(Post)["comments"])
def create(self, *args, **kwargs) -> "BaseEntity": """Create a new record in the data store. Performs validations for unique attributes before creating the entity Returns the created entity object. Throws `ValidationError` for validation failures on attribute values or uniqueness constraints. :param args: Dictionary object containing the object's data. :param kwargs: named attribute names and values """ logger.debug( f"Creating new `{self.entity_cls.__name__}` object using data {kwargs}" ) try: # Build the entity from input arguments # Raises validation errors, if any, at this point entity_obj = self.entity_cls(*args, **kwargs) # Perform unique checks. Raises validation errors if unique constraints are violated. self._validate_unique(entity_obj) # Build the model object and persist into data store model_obj = self._create(self.model_cls.from_entity(entity_obj)) # Reverse update auto fields into entity for field_name, field_obj in declared_fields(entity_obj).items(): if isinstance(field_obj, Auto) and not getattr(entity_obj, field_name): if isinstance(model_obj, dict): field_val = model_obj[field_name] else: field_val = getattr(model_obj, field_name) setattr(entity_obj, field_name, field_val) # Set Entity status to saved to let everybody know it has been persisted entity_obj.state_.mark_saved() # Track aggregate at the UoW level, to be able to perform actions on UoW commit, # like persisting events raised by the aggregate. if current_uow and entity_obj.element_type == DomainObjects.AGGREGATE: current_uow._seen.add(entity_obj) return entity_obj except ValidationError as exc: logger.error(f"Failed creating entity because of {exc}") raise
def test_that_has_one_field_references_are_resolved(self, test_domain): class Account(BaseAggregate): email = String(required=True, max_length=255, unique=True, identifier=True) author = HasOne("Author") class Author(BaseEntity): first_name = String(required=True, max_length=25) last_name = String(max_length=25) account = Reference("Account") class Meta: aggregate_cls = Account test_domain.register(Account) test_domain.register(Author) assert declared_fields(Account)["author"].to_cls == Author assert declared_fields(Author)["account"].to_cls == Account assert len(test_domain._pending_class_resolutions) == 0
def _set_embedded_values(self, instance, value): if value is None: for field_name in self.embedded_fields: attribute_name = self.embedded_fields[ field_name].attribute_name instance.__dict__.pop(attribute_name, None) self.embedded_fields[field_name].value = None else: for field_name in declared_fields(value): self.embedded_fields[field_name].value = getattr( value, field_name) attribute_name = self.embedded_fields[ field_name].attribute_name instance.__dict__[attribute_name] = getattr(value, field_name)
def to_dict(self): """Return entity data as a dictionary""" # FIXME Memoize this function field_values = {} for field_name, field_obj in declared_fields(self).items(): if (not isinstance(field_obj, (ValueObject, Reference)) and getattr(self, field_name, None) is not None): field_values[field_name] = field_obj.as_dict( getattr(self, field_name, None)) elif isinstance(field_obj, ValueObject): value = field_obj.as_dict(getattr(self, field_name, None)) if value: field_values[field_name] = field_obj.as_dict( getattr(self, field_name, None)) return field_values
def __validate_id_field(subclass): """Lookup the id field for this view and assign""" # FIXME What does it mean when there are no declared fields? # Does it translate to an abstract view? if has_fields(subclass): try: id_field = next( field for _, field in declared_fields(subclass).items() if isinstance(field, (Field)) and field.identifier) setattr(subclass, _ID_FIELD_NAME, id_field.field_name) except StopIteration: raise IncorrectUsageError({ "_entity": [ f"Event Sourced Aggregate `{subclass.__name__}` needs to have at least one identifier" ] })
def __init__(self, value_object_cls, *args, **kwargs): super().__init__(*args, **kwargs) self._value_object_cls = value_object_cls self.embedded_fields = {} for ( field_name, field_obj, ) in declared_fields(self._value_object_cls).items(): self.embedded_fields[field_name] = _ShadowField( self, field_name, field_obj, # FIXME Pass all other kwargs here # Because we want the shadow field to mimic the behavior of the actual field # Which means that ShadowField somehow has to become an Integer, Float, String, etc. referenced_as=field_obj.referenced_as, )
def test_that_abstract_entities_can_be_created_with_annotations( self, test_domain): from protean import BaseAggregate from protean.fields import String class CustomBaseClass(BaseAggregate): foo = String(max_length=25) class Meta: abstract = True @test_domain.aggregate class ConcreateSubclass(CustomBaseClass): bar = String(max_length=25) assert all(key in declared_fields(ConcreateSubclass) for key in ["foo", "bar"]) concrete = ConcreateSubclass(foo="Saturn", bar="Titan") assert concrete is not None assert concrete.foo == "Saturn" assert concrete.bar == "Titan"
def _validate_unique(self, entity_obj, create=True): """Validate the unique constraints for the entity. Raise ValidationError, if constraints were violated. This method internally uses each field object's fail method to construct a valid error message. :param entity_obj: Entity object to be validated :param create: boolean value to indicate that the validation is part of a create operation """ # Build the filters from the unique constraints filters, excludes = {}, {} # Construct filter criteria based on unique fields defined in Entity class for field_name, field_obj in unique_fields(self.entity_cls).items(): lookup_value = getattr(entity_obj, field_name, None) # Ignore empty lookup values if lookup_value in Field.empty_values: continue # Ignore identifiers on updates if not create and field_obj.identifier: excludes[field_name] = lookup_value continue filters[field_name] = lookup_value # Lookup the objects by filters and raise error if objects exist for filter_key, lookup_value in filters.items(): if self.exists(excludes, **{filter_key: lookup_value}): field_obj = declared_fields(self.entity_cls)[filter_key] field_obj.fail( "unique", entity_name=self.entity_cls.__name__, field_name=filter_key, value=lookup_value, )
def test_that_domain_throws_exception_on_unknown_class_references_during_activation( self, ): domain = Domain("Inline Domain") class Post(BaseAggregate): content = Text(required=True) comments = HasMany("Comment") domain.register(Post) # Still a string assert isinstance(declared_fields(Post)["comments"].to_cls, str) class Comment(BaseEntity): content = Text() added_on = DateTime() post = Reference("Post") foo = Reference("Foo") class Meta: aggregate_cls = Post domain.register(Comment) with pytest.raises(ConfigurationError) as exc: with domain.domain_context(): pass assert (exc.value.args[0]["element"] == "Element Foo not registered in domain Inline Domain") # Remove domain context manually, as we lost it when the exception was raised from protean.globals import _domain_context_stack _domain_context_stack.pop()
def __init__(self, *template, **kwargs): # noqa: C901 """ Initialise the entity object. During initialization, set value on fields if validation passes. This initialization technique supports keyword arguments as well as dictionaries. The objects initialized in the following example have the same structure:: user1 = User({'first_name': 'John', 'last_name': 'Doe'}) user2 = User(first_name='John', last_name='Doe') You can also specify a template for initial data and override specific attributes:: base_user = User({'age': 15}) user = User(base_user.to_dict(), first_name='John', last_name='Doe') """ if self.meta_.abstract is True: raise NotSupportedError( f"{self.__class__.__name__} class has been marked abstract" f" and cannot be instantiated") self.errors = defaultdict(list) # Set up the storage for instance state self.state_ = _EntityState() # Placeholder for temporary association values self._temp_cache = defaultdict(lambda: defaultdict(dict)) # Collect Reference field attribute names to prevent accidental overwriting # of shadow fields. reference_attributes = { field_obj.attribute_name: field_obj.field_name for field_obj in declared_fields(self).values() if isinstance(field_obj, Reference) } # Load the attributes based on the template loaded_fields = [] for dictionary in template: if not isinstance(dictionary, dict): raise AssertionError( f'Positional argument "{dictionary}" passed must be a dict.' f"This argument serves as a template for loading common " f"values.", ) for field_name, val in dictionary.items(): if field_name not in kwargs: 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]) # Load Value Objects from associated fields # This block will dynamically construct value objects from field values # and associated the vo with the entity # If the value object was already provided, it will not be overridden. for field_name, field_obj in declared_fields(self).items(): if isinstance(field_obj, (ValueObject)) and not getattr(self, field_name): attrs = [ (embedded_field.field_name, embedded_field.attribute_name) for embedded_field in field_obj.embedded_fields.values() ] values = {name: kwargs.get(attr) for name, attr in attrs} try: # Pass the `required` option value as defined at the parent value_object = field_obj.value_object_cls( **values, required=field_obj.required) # Set VO value only if the value object is not None/Empty if value_object: setattr(self, field_name, value_object) loaded_fields.append(field_name) except ValidationError as err: for sub_field_name in err.messages: self.errors["{}_{}".format( field_name, sub_field_name)].extend( err.messages[sub_field_name]) # Load Identities for field_name, field_obj in declared_fields(self).items(): if 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) # Load Associations for field_name, field_obj in declared_fields(self).items(): if isinstance(field_obj, Association): getattr( self, field_name) # This refreshes the values in associations # Set up add and remove methods. These are pseudo methods, `add_*` and # `remove_*` that point to the HasMany field's `add` and `remove` # methods. They are wrapped to ensure we pass the object that holds # the values and temp_cache. if isinstance(field_obj, HasMany): setattr(self, f"add_{field_name}", partial(field_obj.add, self)) setattr(self, f"remove_{field_name}", partial(field_obj.remove, self)) setattr( self, f"_mark_changed_{field_name}", partial(field_obj._mark_changed, self), ) # Now load the remaining fields with a None value, which will fail # for required fields for field_name, field_obj in fields(self).items(): if field_name not in loaded_fields: if not isinstance(field_obj, Association): try: setattr(self, field_name, None) # If field is a VO, set underlying attributes to None as well if isinstance(field_obj, ValueObject): for embedded_field in field_obj.embedded_fields.values( ): setattr(self, embedded_field.attribute_name, None) except ValidationError as err: for field_name in err.messages: self.errors[field_name].extend( err.messages[field_name]) for field_name, field_obj in attributes(self).items(): if field_name not in loaded_fields and not hasattr( self, field_name): setattr(self, field_name, None) self.defaults() # `clean()` will return a `defaultdict(list)` if errors are to be raised custom_errors = self.clean() or {} for field in custom_errors: self.errors[field].extend(custom_errors[field]) # Raise any errors found during load if self.errors: logger.error(f"Error during initialization: {dict(self.errors)}") raise ValidationError(self.errors)
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)