diff --git a/docs/guides/compose-a-domain/activate-domain.md b/docs/guides/compose-a-domain/activate-domain.md index 4d36eb77..d63f0bc6 100644 --- a/docs/guides/compose-a-domain/activate-domain.md +++ b/docs/guides/compose-a-domain/activate-domain.md @@ -1,5 +1,7 @@ # Activate the domain +Once a domain object is defined, the next step is to activate it. + A `Domain` in protean is always associated with a domain context, which can be used to bind an domain object implicitly to the current thread or greenlet. We refer to the act of binding the domain object as **activating the domain**. diff --git a/docs/guides/domain-definition/fields.md b/docs/guides/domain-definition/fields.md deleted file mode 100644 index c4991978..00000000 --- a/docs/guides/domain-definition/fields.md +++ /dev/null @@ -1,174 +0,0 @@ -# Fields (draft) - -As we saw with Aggregates, Entities, and Value Objects, fields are attributes -that define the structure of data container elements. - -This document lists all field types and their options available in Protean, -and their built-in capabilities. - -## Options - -### `required` - -If `True`, the field is not allowed to be blank. Default is `False`. - -```python hl_lines="9" -{! docs_src/guides/domain-definition/fields/001.py !} -``` - -Leaving the field blank or not specifying a value will raise a -`ValidationError`: - -```shell hl_lines="4" -In [1]: p = Person() -ERROR: Error during initialization: {'name': ['is required']} -... -ValidationError: {'name': ['is required']} -``` - -### `identifier` - -If True, the field is the primary identifier for the entity (a la _primary key_ -in RDBMS). - -```python hl_lines="9" -{! docs_src/guides/domain-definition/fields/002.py !} -``` - -The field is validated to be unique and non-blank: - -```shell hl_lines="6 11" -In [1]: from protean.reflection import declared_fields - -In [2]: p = Person(email='john.doe@example.com', name='John Doe') - -In [3]: declared_fields(p) -Out[3]: {'email': String(identifier=True), 'name': String(required=True)} - -In [4]: p = Person(name='John Doe') -ERROR: Error during initialization: {'email': ['is required']} -... -ValidationError: {'email': ['is required']} -``` - -Aggregates and Entities need at least one field to be marked as an identifier. -If you don’t specify one, Protean will automatically add a field called `id` -to act as the primary identifier. This means that you don’t need to explicitly -set `identifier=True` on any of your fields unless you want to override the -default behavior or the name of the field. - -Alternatively, you can use [`Identifier`](#identifier-field) field type for -primary identifier fields. - -By default, Protean dynamically generates UUIDs as values of identifier fields -unless explicitly provided. You can customize the type of value accepted with -`identity-strategy` config parameter. More details are in -[Configuration](../compose-a-domain/configuration.md) section. - -### `default` - -The default value assigned to the field on initialization. - -This can be a value or a callable object. If callable, the function will be -called every time a new object is created. - -```python hl_lines="16" -{! docs_src/guides/domain-definition/fields/003.py !} -``` - -```shell hl_lines="6" -In [1]: post = Post(title='Foo') - -In [2]: post.to_dict() -Out[2]: -{'title': 'Foo', - 'created_at': '2024-05-09 00:58:10.781744+00:00', - 'id': '4f6b1fef-bc60-44c2-9ba6-6f844e0d31b0'} -``` - -#### Mutable object defaults - -**IMPORTANT**: The default cannot be a mutable object (list, set, dict, entity -instance, etc.), because the reference to the same object would be used as the -default in all instances. Instead, wrap the desired default in a callable. - -For example, to specify a default `list` for `List` field, use a function: - -```python hl_lines="12" -{! docs_src/guides/domain-definition/fields/004.py !} -``` - -Initializing an Adult aggregate will populate the defaults correctly: - -```shell -In [1]: adult = Adult(name="John Doe") - -In [2]: adult.to_dict() -Out[2]: -{'name': 'John Doe', - 'topics': ['Music', 'Cinema', 'Politics'], - 'id': '14381a6f-b62a-4135-a1d7-d50f68e2afba'} -``` - -#### Lambda expressions - -You can use lambda expressions to specify an anonymous function: - -```python hl_lines="13" -{! docs_src/guides/domain-definition/fields/005.py !} -``` - -```shell hl_lines="4" -In [1]: dice = Dice() - -In [2]: dice.to_dict() -Out[2]: {'sides': 6, 'id': '0536ade5-f3a4-4e94-8139-8024756659a7'} - -In [3]: dice.throw() -Out[3]: 3 -``` - -This is a great option when you want to pass parameters to a function. - -### `unique` - -If True, this field's value must be unique among all entities. - -```python hl_lines="10" -{! docs_src/guides/domain-definition/fields/006.py !} -``` - -Obviously, this field's integrity is enforced at the database layer when an -entity is persisted. If an entity instance specifies a duplicate value in a -field marked `unique`, a `ValidationError` will be raised: - -```shell hl_lines="11" -In [1]: p1 = Person(name='John Doe', email='john.doe@example.com') - -In [2]: domain.repository_for(Person).add(p1) -Out[2]: - -In [3]: p2 = Person(name= 'Jane Doe', email='john.doe@example.com') - -In [4]: domain.repository_for(Person).add(p2) -ERROR: Failed saving entity because {'email': ["Person with email 'john.doe@example.com' is already present."]} -... -ValidationError: {'email': ["Person with email 'john.doe@example.com' is already present."]} -``` - -We will explore more about persistence in the Application Layer. - - -## Simple Fields - -## Container Fields - -## Associations - -## Embedded Fields - -## Custom Fields - - \ No newline at end of file diff --git a/docs/guides/domain-definition/fields/association-fields.md b/docs/guides/domain-definition/fields/association-fields.md new file mode 100644 index 00000000..99d306c0 --- /dev/null +++ b/docs/guides/domain-definition/fields/association-fields.md @@ -0,0 +1,101 @@ +# Association Fields + +## `HasOne` + +Represents an one-to-one association between an aggregate and its entities. +This field is used to define a relationship where an aggregate is associated +with at most one instance of a child entity. + +```python hl_lines="10 13" +{! docs_src/guides/domain-definition/fields/association-fields/001.py !} +``` + +!!!note + If you carefully observe the `HasOne` field declaration, the child entity's + name is a string value! This is usually the way to avoid circular references. + It applies to all aspects of Protean that link two entities - the string + value will be resolved to the class at runtime. + +The `Author` entity can now be persisted along with the `Book` aggregate: + +```shell hl_lines="3 12-13" +In [1]: book = Book( + ...: title="The Great Gatsby", + ...: author=Author(name="F. Scott Fitzgerald") + ...: ) + +In [2]: domain.repository_for(Book).add(book) +Out[2]: + +In [3]: domain.repository_for(Book)._dao.query.all().items[0].to_dict() +Out[3]: +{'title': 'The Great Gatsby', + 'author': {'name': 'F. Scott Fitzgerald', + 'id': '1f275e92-9872-4d96-b999-4ef0fbe61013'}, + 'id': 'a4a642d9-87ed-44de-9889-c687466f171b'} +``` + +!!!note + Protean adds a `Reference` field to child entities to preserve the inverse + relationship - from child entity to aggregate - when persisted. This is + visible if you introspect the fields of the Child Entity. + + ```shell hl_lines="7 13" + In [1]: from protean.reflection import declared_fields, attributes + + In [2]: declared_fields(Author) + Out[2]: + {'name': String(required=True, max_length=50), + 'id': Auto(identifier=True), + 'book': Reference()} + + In [3]: attributes(Author) + Out[3]: + {'name': String(required=True, max_length=50), + 'id': Auto(identifier=True), + 'book_id': _ReferenceField()} + ``` + +We will further review persistence related aspects around associations in the +Repository section. + + +## `HasMany` + +Represents a one-to-many association between two entities. This field is used +to define a relationship where an aggregate has multiple instances of a child +entity. + +```python hl_lines="11" +{! docs_src/guides/domain-definition/fields/association-fields/002.py !} +``` + +Protean provides helper methods that begin with `add_` and `remove_` to add +and remove child entities from the `HasMany` relationship. + +```shell hl_lines="4-5 12-13 16 23" +In [1]: post = Post( + ...: title="Foo", + ...: comments=[ + ...: Comment(content="Bar"), + ...: Comment(content="Baz") + ...: ] + ...: ) + +In [2]: post.to_dict() +Out[2]: +{'title': 'Foo', + 'comments': [{'content': 'Bar', 'id': '085ed011-15b3-48e3-9363-99a53bc9362a'}, + {'content': 'Baz', 'id': '4790cf87-c234-42b6-bb03-1e0599bd6c0f'}], + 'id': '29943ac9-a9eb-497b-b6d2-466b30ecd5f5'} + +In [3]: post.add_comments(Comment(content="Qux")) + +In [4]: post.to_dict() +Out[4]: +{'title': 'Foo', + 'comments': [{'content': 'Bar', 'id': '085ed011-15b3-48e3-9363-99a53bc9362a'}, + {'content': 'Baz', 'id': '4790cf87-c234-42b6-bb03-1e0599bd6c0f'}, + {'content': 'Qux', 'id': 'b1a7aeda-81ca-4d0b-9d7e-6fe0c000b8af'}], + 'id': '29943ac9-a9eb-497b-b6d2-466b30ecd5f5'} +``` diff --git a/docs/guides/domain-definition/fields/container-fields.md b/docs/guides/domain-definition/fields/container-fields.md new file mode 100644 index 00000000..ea1b5938 --- /dev/null +++ b/docs/guides/domain-definition/fields/container-fields.md @@ -0,0 +1,105 @@ +# Container Fields + +## `List` + +A field that represents a list of values. + +**Optional Arguments** + +- **`content_type`**: The type of items in the list. Defaults to `String`. +Accepted field types are `Boolean`, `Date`, `DateTime`, `Float`, `Identifier`, +`Integer`, `String`, and `Text`. +- **`pickled`**: Whether the list should be pickled when stored. Defaults to +`False`. + +!!!note + Some database implementations (like Postgresql) can store lists by default. + You can force it to store the pickled value as a Python object by + specifying `pickled=True`. Databases that don’t support lists simply store + the field as a python object. + +```python hl_lines="9" +{! docs_src/guides/domain-definition/fields/simple-fields/001.py !} +``` + +The value is provided as a `list`, and the values in the `list` are validated +to be of the right type. + +```shell hl_lines="6 12" +In [1]: user = User(email="john.doe@gmail.com", roles=['ADMIN', 'EDITOR']) + +In [2]: user.to_dict() +Out[2]: +{'email': 'john.doe@gmail.com', + 'roles': ['ADMIN', 'EDITOR'], + 'id': '582d946b-409b-4b15-b3be-6a90284264b3'} + +In [3]: user2 = User(email="jane.doe@gmail.com", roles=[1, 2]) +ERROR: Error during initialization: {'roles': ['Invalid value [1, 2]']} +... +ValidationError: {'roles': ['Invalid value [1, 2]']} +``` + +## `Dict` + +A field that represents a dictionary. + +**Optional Arguments** + +- **`pickled`**: Whether the dict should be pickled when stored. Defaults to +`False`. + +```python hl_lines="10" +{! docs_src/guides/domain-definition/fields/container-fields/002.py !} +``` + +A regular dictionary can be supplied as value to payload: + + +```shell hl_lines="3 9" +In [1]: event=UserEvent( + ...: name="UserRegistered", + ...: payload={'name': 'John Doe', 'email': 'john.doe@example.com'} + ...: ) + +In [2]: event.to_dict() +Out[2]: +{'name': 'UserRegistered', + 'payload': {'name': 'John Doe', 'email': 'john.doe@example.com'}, + 'id': '44e9143f-f4a6-40da-9128-4b6c013420d4'} +``` + +!!!note + Some database implementations (like Postgresql) can store dicts as JSON + by default. You can force it to store the pickled value as a Python object + by specifying pickled=True. Databases that don’t support lists simply store + the field as a python object. + +## `ValueObject` + +Represents a field that holds a value object. This field is used to embed a +Value Object within an entity. + +**Arguments** + +- **`value_object_cls`**: The class of the value object to be embedded. + +```python hl_lines="7-15 20" +{! docs_src/guides/domain-definition/fields/container-fields/003.py !} +``` + +You can provide an instance of the Value Object as input to the value object +field: + +```shell hl_lines="2 8" +In [1]: account = Account( + ...: balance=Balance(currency="USD", amount=100.0), + ...: name="Checking" + ...: ) + +In [2]: account.to_dict() +Out[2]: +{'balance': {'currency': 'USD', 'amount': 100.0}, + 'name': 'Checking', + 'id': '513b8a78-e00f-45ce-bb6f-11ef0cccbec6'} +``` \ No newline at end of file diff --git a/docs/guides/domain-definition/fields/index.md b/docs/guides/domain-definition/fields/index.md new file mode 100644 index 00000000..9a1077d7 --- /dev/null +++ b/docs/guides/domain-definition/fields/index.md @@ -0,0 +1,50 @@ +# Fields + +Fields are fundamental components that define the structure and behavior of +data within domain models such as Aggregates, Entities, and Value Objects. +This section provides a comprehensive guide to the various types of fields +available in Protean, detailing their attributes, options, and built-in +functionalities that help in the effective management of domain data. + +Fields in Protean are designed to encapsulate data properties in domain models, +ensuring data integrity and aligning with the business domain's rules and logic. +They play a critical role in defining the schema of data containers and are +pivotal in enforcing validation, defaults, associations, and more. + +## Field Options + +Protean fields come with various options that enhance their functionality and +integration with the backend systems. These include required, default, choices, +and unique, among others, which allow for a highly customizable and robust +domain model definition. These options provide the necessary tools for +you to handle various real-world scenarios effectively. + +## Types of Fields + +### Simple Fields + +Simple fields handle basic data types like strings, integers, and dates. +They are the building blocks for defining straightforward data attributes in +models. Options like max_length for String or max_value and min_value for +numeric fields like Integer and Float allow developers to specify constraints +directly in the model's definition. + +### Container Fields + +Container fields are used for data types that hold multiple values, such as +lists and dictionaries. These fields support complex structures and provide +options such as `content_type` for `List` fields to ensure type consistency +within the collection. Protean optimizes storage and retrieval operations for +these fields by leveraging database-specific features when available. + +### Association Fields + +Association fields define relationships between different domain models, +such as one-to-one, one-to-many, and many-to-many associations. These fields +help in mapping complex domain relationships and include functionalities to +manage related objects efficiently, preserving data integrity across the domain. + + + \ No newline at end of file diff --git a/docs/guides/domain-definition/fields/options.md b/docs/guides/domain-definition/fields/options.md new file mode 100644 index 00000000..abf847ff --- /dev/null +++ b/docs/guides/domain-definition/fields/options.md @@ -0,0 +1,269 @@ +# Options + +## `description` + +A long form description of the field. This value can be used by database +adapters to provide additional context to a field. + +```python hl_lines="9" +{! docs_src/guides/domain-definition/fields/options/009.py !} +``` + +## `required` + +Indicates if the field is required (must have a value). If `True`, the field +is not allowed to be blank. Default is `False`. + +```python hl_lines="9" +{! docs_src/guides/domain-definition/fields/options/001.py !} +``` + +Leaving the field blank or not specifying a value will raise a +`ValidationError`: + +```shell hl_lines="4" +In [1]: p = Person() +ERROR: Error during initialization: {'name': ['is required']} +... +ValidationError: {'name': ['is required']} +``` + +## `identifier` + +If True, the field is an identifier for the entity (a la _primary key_ +in RDBMS). + +```python hl_lines="9" +{! docs_src/guides/domain-definition/fields/options/002.py !} +``` + +The field is validated to be unique and non-blank: + +```shell hl_lines="6 11" +In [1]: from protean.reflection import declared_fields + +In [2]: p = Person(email='john.doe@example.com', name='John Doe') + +In [3]: declared_fields(p) +Out[3]: {'email': String(identifier=True), 'name': String(required=True)} + +In [4]: p = Person(name='John Doe') +ERROR: Error during initialization: {'email': ['is required']} +... +ValidationError: {'email': ['is required']} +``` + +Aggregates and Entities need at least one field to be marked as an identifier. +If you don’t specify one, Protean will automatically add a field called `id` +to act as the primary identifier. This means that you don’t need to explicitly +set `identifier=True` on any of your fields unless you want to override the +default behavior or the name of the field. + +Alternatively, you can use [`Identifier`](#identifier-field) field type for +primary identifier fields. + +By default, Protean dynamically generates UUIDs as values of identifier fields +unless explicitly provided. You can customize the type of value accepted with +`identity-strategy` config parameter. More details are in +[Configuration](../compose-a-domain/configuration.md) section. + +## `default` + +The default value for the field if no value is provided. + +This can be a value or a callable object. If callable, the function will be +called every time a new object is created. + +```python hl_lines="16" +{! docs_src/guides/domain-definition/fields/options/003.py !} +``` + +```shell hl_lines="6" +In [1]: post = Post(title='Foo') + +In [2]: post.to_dict() +Out[2]: +{'title': 'Foo', + 'created_at': '2024-05-09 00:58:10.781744+00:00', + 'id': '4f6b1fef-bc60-44c2-9ba6-6f844e0d31b0'} +``` + +### Mutable object defaults + +**IMPORTANT**: The default cannot be a mutable object (list, set, dict, entity +instance, etc.), because the reference to the same object would be used as the +default in all instances. Instead, wrap the desired default in a callable. + +For example, to specify a default `list` for `List` field, use a function: + +```python hl_lines="12" +{! docs_src/guides/domain-definition/fields/options/004.py !} +``` + +Initializing an Adult aggregate will populate the defaults correctly: + +```shell +In [1]: adult = Adult(name="John Doe") + +In [2]: adult.to_dict() +Out[2]: +{'name': 'John Doe', + 'topics': ['Music', 'Cinema', 'Politics'], + 'id': '14381a6f-b62a-4135-a1d7-d50f68e2afba'} +``` + +### Lambda expressions + +You can use lambda expressions to specify an anonymous function: + +```python hl_lines="13" +{! docs_src/guides/domain-definition/fields/options/005.py !} +``` + +```shell hl_lines="4" +In [1]: dice = Dice() + +In [2]: dice.to_dict() +Out[2]: {'sides': 6, 'id': '0536ade5-f3a4-4e94-8139-8024756659a7'} + +In [3]: dice.throw() +Out[3]: 3 +``` + +This is a great option when you want to pass parameters to a function. + +## `unique` + +Indicates if the field values must be unique within the repository. If `True`, +this field's value is validated to be unique among all entities. + +```python hl_lines="10" +{! docs_src/guides/domain-definition/fields/options/006.py !} +``` + +Obviously, this field's integrity is enforced at the database layer when an +entity is persisted. If an entity instance specifies a duplicate value in a +field marked `unique`, a `ValidationError` will be raised: + +```shell hl_lines="11" +In [1]: p1 = Person(name='John Doe', email='john.doe@example.com') + +In [2]: domain.repository_for(Person).add(p1) +Out[2]: + +In [3]: p2 = Person(name= 'Jane Doe', email='john.doe@example.com') + +In [4]: domain.repository_for(Person).add(p2) +ERROR: Failed saving entity because {'email': ["Person with email 'john.doe@example.com' is already present."]} +... +ValidationError: {'email': ["Person with email 'john.doe@example.com' is already present."]} +``` + +We will explore more about persistence in Application Layer guide. + + +## `choices` + +A set of allowed choices for the field value. When supplied as an `Enum`, the +value of the field is validated to be one among the specified options. + +```python hl_lines="9-11 18" +{! docs_src/guides/domain-definition/fields/options/007.py !} +``` + +The choices are enforced when the field is initialized or updated: + +```shell hl_lines="7 13" +In [1]: building = Building(name="Atlantis", floors=3, status="WIP") + +In [2]: building.to_dict() +Out[2]: +{'name': 'Atlantis', + 'floors': 3, + 'status': 'WIP', + 'id': 'c803c763-32d7-403f-b432-8835a258430e'} + +In [3]: building.status = "COMPLETED" +ERROR: Error during initialization: {'status': ["Value `'COMPLETED'` is not a valid choice. Must be among ['WIP', 'DONE']"]} +... +ValidationError: {'status': ["Value `'COMPLETED'` is not a valid choice. Must be among ['WIP', 'DONE']"]} +``` + +## `referenced_as` + +The name of the field as referenced in the database or external systems. +Defaults to the field's name. + +```python hl_lines="10" +{! docs_src/guides/domain-definition/fields/options/008.py !} +``` + +Protean will now persist the value under `fullname` instead of `name`. + +```shell hl_lines="6 13" +In [1]: from protean.reflection import declared_fields, attributes + +In [2]: declared_fields(Person) +Out[2]: +{'email': String(), + 'name': String(required=True, referenced_as='fullname'), + 'id': Auto(identifier=True)} + +In [3]: attributes(Person) +Out[3]: +{'_version': Integer(default=-1), + 'email': String(), + 'fullname': String(required=True, referenced_as='fullname'), + 'id': Auto(identifier=True)} +``` + +## `validators` + +Additional validators to apply to the field value. + +Validators are +[callable `Class` instances](https://docs.python.org/3/reference/datamodel.html#class-instances) +that are invoked whenever a field's value is changed. Protean's `String` field, +for example, has two default validators: `MinLengthValidator` and +`MaxLenghtValidator` classes associated with `min_length` and `max_length` +attributes. + +```python hl_lines="9-16 21" +{! docs_src/guides/domain-definition/fields/options/010.py !} +``` + +If the value fails to satisfy the validation, a `ValidationError` will be +thrown with the custom error message. + +```shell hl_lines="9" +In [1]: e = Employee(email="john@mydomain.com") + +In [2]: e.to_dict() +Out[2]: {'email': 'john@mydomain.com'} + +In [3]: e2 = Employee(email="john@otherdomain.com") +ERROR: Error during initialization: {'email': ['Email does not belong to mydomain.com']} +... +ValidationError: {'email': ['Email does not belong to mydomain.com']} +``` + +## `error_messages` + +Custom error messages for different kinds of errors. If supplied, the default +messages that the field will raise will be overridden. Default error message +keys that apply to all field types are `required`, `invalid`, `unique`, and +`invalid_choice`. Each field may have additional error message keys as +detailed in their documentation. + +```python hl_lines="9-12" +{! docs_src/guides/domain-definition/fields/options/011.py !} +``` + +Now the custom message will be available in `ValidationError`: + +```shell hl_lines="4" +In [1]: Building() +ERROR: Error during initialization: {'doors': ['Every building needs doors.']} +... +ValidationError: {'doors': ['Every building needs some!']} +``` diff --git a/docs/guides/domain-definition/fields/simple-fields.md b/docs/guides/domain-definition/fields/simple-fields.md new file mode 100644 index 00000000..7f24ff0e --- /dev/null +++ b/docs/guides/domain-definition/fields/simple-fields.md @@ -0,0 +1,176 @@ +# Simple Fields + +## String + +A string field, for small- to large-sized strings. For large amounts of text, +use [Text](#text). + +```python hl_lines="9" +{! docs_src/guides/domain-definition/fields/simple-fields/001.py !} +``` + +**Optional Arguments** + +- **`max_length`**: The maximum length (in characters) of the field. +Defaults to 255. +- **`min_length`**: The minimum length (in characters) of the field. +Defaults to 255. +- **`sanitize`**: Optionally turn off HTML sanitization. Default is `True`. + +## Text + +A large text field, to hold large amounts of text. Text fields do not have +size constraints. + +```python hl_lines="10" +{! docs_src/guides/domain-definition/fields/simple-fields/002.py !} +``` + +**Optional Arguments** + +- **`sanitize`**: Optionally turn off HTML sanitization. Default is `True`. + +## Integer + +An integer. + +```python hl_lines="10" +{! docs_src/guides/domain-definition/fields/simple-fields/003.py !} +``` + +**Optional Arguments** + +- **`max_value`**: The maximum numeric value of the field. +- **`min_value`**: The minimum numeric value of the field. + +## Float + +A floating-point number represented in Python by a float instance. + +```python hl_lines="10" +{! docs_src/guides/domain-definition/fields/simple-fields/004.py !} +``` + +**Optional Arguments** + +- **`max_value`**: The maximum numeric value of the field. +- **`min_value`**: The minimum numeric value of the field. + +## Date + +A date, represented in Python by a `datetime.date` instance. + +```python hl_lines="12" +{! docs_src/guides/domain-definition/fields/simple-fields/005.py !} +``` + +```shell hl_lines="6" +In [1]: p = Post(title="It") + +In [2]: p.to_dict() +Out[2]: +{'title': 'It', + 'published_on': '2024-05-09', + 'id': '88a21815-7d9b-4138-9cac-5a06889d4318'} +``` + +## DateTime + +A date and time, represented in Python by a `datetime.datetime` instance. + +```python hl_lines="12" +{! docs_src/guides/domain-definition/fields/simple-fields/006.py !} +``` + +```shell +In [1]: p = Post(title="It") + +In [2]: p.to_dict() +Out[2]: +{'title': 'It', + 'created_at': '2024-05-09 17:12:11.373300+00:00', + 'id': '3a96e434-06ab-4244-80a8-76edbd621a27'} +``` + +## Boolean + +A `True`/`False` field. + +```python hl_lines="10" +{! docs_src/guides/domain-definition/fields/simple-fields/007.py !} +``` + +```shell hl_lines="6" +In [1]: u = User(name="John Doe") + +In [2]: u.to_dict() +Out[2]: +{'name': 'John Doe', + 'subscribed': False, + 'id': '69190dd4-12a6-4666-a799-9409ddab39cd'} +``` + +## Auto + +Automatically-generated unique identifiers. By default, all entities and +aggregates create an `Auto` field named `id` that represents their unique +identifier. + +**Optional Arguments** + +- **`increment`**: Auto-increment field value. Defaults to `False`. Only valid +when `IDENTITY_TYPE` is `INTEGER` and `IDENTITY_STRATEGY` is set to `DATABASE`. + +!!!note + Not all databases support this `increment` feature. Cross-verify with the + Protean adapter's documentation to confirm if this functionality is + supported. + +You cannot supply values explicitly to Auto fields - they are self-generated. +If you want to supply values, use [`Identifier`](#identifier) fields. + +```python hl_lines="10" +{! docs_src/guides/domain-definition/fields/simple-fields/001.py !} +``` + +By default, Protean depends on UUIDs as identifier values. You can use the +`IDENTITY_TYPE` config attributes to customize this behavior and choose +another type (like integer values). + +```shell hl_lines="6" +In [1]: p = Person(name='John Doe') + +In [2]: p.to_dict() +Out[2]: +{'name': 'John Doe', + 'id': '7d32e929-e5c5-4856-a6e7-1ebf12e6259e'} +``` + +Refer to [Identity](../identity.md) section for a deep dive into identities +in Protean. + +## Identifier + +An Identifier. The identity type is String type by default, but can be changed +with `IDENTITY_TYPE` configuration attribute for all entities, or can be set +per entity with the `identity_type` parameter. + +**Optional Arguments** + +- **`identity_type`**: The type of the identifier field. If not provided, it +will be picked from the domain configuration. Defaults to `STRING`. Raises +`ValidationError` if the provided identity type is not supported. + +```python hl_lines="14" +{! docs_src/guides/domain-definition/fields/simple-fields/008.py !} +``` + +```shell hl_lines="4" +In [1]: user = User(user_id=1, name="John Doe") + +In [2]: user.to_dict() +Out[2]: {'user_id': 1, 'name': 'John Doe', 'subscribed': False} +``` + +Refer to [Identity](../identity.md) section for a deep dive into identities +in Protean. diff --git a/docs/intro/whitepaper.md b/docs/intro/whitepaper.md index bceb2ae9..77192aa2 100644 --- a/docs/intro/whitepaper.md +++ b/docs/intro/whitepaper.md @@ -1,2 +1,47 @@ # Whitepaper +## Abstract or Executive Summary + +A brief overview of the topic covered in the whitepaper, the problem being addressed, and the key findings or conclusions. + +## Introduction + +Background information or the context of the topic +The specific problem or challenge the whitepaper addresses +Objectives of the whitepaper + +## Problem Description + +A detailed explanation of the problem or issue at hand +Data or evidence that supports the existence and severity of the problem + +## Solution + +A detailed description of the proposed solution or approach +How the solution addresses the problem +Benefits and potential outcomes of implementing the solution + +# Case Studies or Examples + +Real-life examples or case studies that demonstrate the effectiveness of the proposed solution +Data and analysis supporting the success of these examples + +## Methodology + +An explanation of the methods used to gather data and develop the whitepaper +This may include research methods, analytical techniques, and sources of data + +## Conclusion + +Summary of the key points presented in the whitepaper +Reiteration of the importance of the issue and the efficacy of the proposed solution + +## Call to Action + +Suggested actions for readers or stakeholders +This might include steps to implement the solution or contact information for further engagement + +## Appendices and References + +Supporting information that is too detailed for the main body of the document +Citations of sources and additional resources for readers who want further information diff --git a/docs_src/guides/domain-definition/fields/association-fields/001.py b/docs_src/guides/domain-definition/fields/association-fields/001.py new file mode 100644 index 00000000..fa00c0be --- /dev/null +++ b/docs_src/guides/domain-definition/fields/association-fields/001.py @@ -0,0 +1,15 @@ +from protean import Domain +from protean.fields import HasOne, String + +domain = Domain(__file__) + + +@domain.aggregate +class Book: + title = String(required=True, max_length=100) + author = HasOne("Author") + + +@domain.entity(aggregate_cls="Book") +class Author: + name = String(required=True, max_length=50) diff --git a/docs_src/guides/domain-definition/fields/association-fields/002.py b/docs_src/guides/domain-definition/fields/association-fields/002.py new file mode 100644 index 00000000..cc0ea69e --- /dev/null +++ b/docs_src/guides/domain-definition/fields/association-fields/002.py @@ -0,0 +1,16 @@ +from protean import Domain +from protean.fields import HasMany, String, Text + +domain = Domain(__file__) + + +@domain.aggregate +class Post: + title = String(required=True, max_length=100) + body = Text() + comments = HasMany("Comment") + + +@domain.entity(aggregate_cls=Post) +class Comment: + content = String(required=True, max_length=50) diff --git a/docs_src/guides/domain-definition/fields/container-fields/001.py b/docs_src/guides/domain-definition/fields/container-fields/001.py new file mode 100644 index 00000000..34e395ab --- /dev/null +++ b/docs_src/guides/domain-definition/fields/container-fields/001.py @@ -0,0 +1,10 @@ +from protean import Domain +from protean.fields import List, String + +domain = Domain(__file__) + + +@domain.aggregate +class User: + email = String(max_length=255, required=True, unique=True) + roles = List() diff --git a/docs_src/guides/domain-definition/fields/container-fields/002.py b/docs_src/guides/domain-definition/fields/container-fields/002.py new file mode 100644 index 00000000..6cd2ec70 --- /dev/null +++ b/docs_src/guides/domain-definition/fields/container-fields/002.py @@ -0,0 +1,10 @@ +from protean import Domain +from protean.fields import Dict, String + +domain = Domain(__file__) + + +@domain.aggregate +class UserEvent: + name = String(max_length=255) + payload = Dict() diff --git a/docs_src/guides/domain-definition/fields/container-fields/003.py b/docs_src/guides/domain-definition/fields/container-fields/003.py new file mode 100644 index 00000000..bebc7ab8 --- /dev/null +++ b/docs_src/guides/domain-definition/fields/container-fields/003.py @@ -0,0 +1,21 @@ +from protean import Domain +from protean.fields import Float, String, ValueObject + +domain = Domain(__file__) + + +@domain.value_object +class Balance: + """A composite amount object, containing two parts: + * currency code - a three letter unique currency code + * amount - a float value + """ + + currency = String(max_length=3, required=True) + amount = Float(required=True, min_value=0.0) + + +@domain.aggregate +class Account: + balance = ValueObject(Balance) + name = String(max_length=30) diff --git a/docs_src/guides/domain-definition/fields/001.py b/docs_src/guides/domain-definition/fields/options/001.py similarity index 100% rename from docs_src/guides/domain-definition/fields/001.py rename to docs_src/guides/domain-definition/fields/options/001.py diff --git a/docs_src/guides/domain-definition/fields/002.py b/docs_src/guides/domain-definition/fields/options/002.py similarity index 100% rename from docs_src/guides/domain-definition/fields/002.py rename to docs_src/guides/domain-definition/fields/options/002.py diff --git a/docs_src/guides/domain-definition/fields/003.py b/docs_src/guides/domain-definition/fields/options/003.py similarity index 100% rename from docs_src/guides/domain-definition/fields/003.py rename to docs_src/guides/domain-definition/fields/options/003.py diff --git a/docs_src/guides/domain-definition/fields/004.py b/docs_src/guides/domain-definition/fields/options/004.py similarity index 100% rename from docs_src/guides/domain-definition/fields/004.py rename to docs_src/guides/domain-definition/fields/options/004.py diff --git a/docs_src/guides/domain-definition/fields/005.py b/docs_src/guides/domain-definition/fields/options/005.py similarity index 100% rename from docs_src/guides/domain-definition/fields/005.py rename to docs_src/guides/domain-definition/fields/options/005.py diff --git a/docs_src/guides/domain-definition/fields/006.py b/docs_src/guides/domain-definition/fields/options/006.py similarity index 100% rename from docs_src/guides/domain-definition/fields/006.py rename to docs_src/guides/domain-definition/fields/options/006.py diff --git a/docs_src/guides/domain-definition/fields/options/007.py b/docs_src/guides/domain-definition/fields/options/007.py new file mode 100644 index 00000000..32bff4c8 --- /dev/null +++ b/docs_src/guides/domain-definition/fields/options/007.py @@ -0,0 +1,18 @@ +from enum import Enum + +from protean import Domain +from protean.fields import Integer, String + +domain = Domain(__file__) + + +class BuildingStatus(Enum): + WIP = "WIP" + DONE = "DONE" + + +@domain.aggregate +class Building: + name = String(max_length=50) + floors = Integer() + status = String(choices=BuildingStatus) diff --git a/docs_src/guides/domain-definition/fields/options/008.py b/docs_src/guides/domain-definition/fields/options/008.py new file mode 100644 index 00000000..74f109e8 --- /dev/null +++ b/docs_src/guides/domain-definition/fields/options/008.py @@ -0,0 +1,10 @@ +from protean import Domain +from protean.fields import String + +domain = Domain(__file__) + + +@domain.aggregate +class Person: + email = String(unique=True) + name = String(referenced_as="fullname", required=True) diff --git a/docs_src/guides/domain-definition/fields/options/009.py b/docs_src/guides/domain-definition/fields/options/009.py new file mode 100644 index 00000000..34808de0 --- /dev/null +++ b/docs_src/guides/domain-definition/fields/options/009.py @@ -0,0 +1,11 @@ +from protean import Domain +from protean.fields import List, String + +domain = Domain(__file__) + + +@domain.aggregate +class Building: + permit = List( + content_type=String, description="Licences and Approvals", required=True + ) diff --git a/docs_src/guides/domain-definition/fields/options/010.py b/docs_src/guides/domain-definition/fields/options/010.py new file mode 100644 index 00000000..b4902a26 --- /dev/null +++ b/docs_src/guides/domain-definition/fields/options/010.py @@ -0,0 +1,21 @@ +from typing import Any +from protean import Domain +from protean.exceptions import ValidationError +from protean.fields import String + +domain = Domain(__file__) + + +class EmailDomainValidator: + def __init__(self, domain="example.com"): + self.domain = domain + self.message = f"Email does not belong to {self.domain}" + + def __call__(self, value: str) -> Any: + if not value.endswith(self.domain): + raise ValidationError(self.message) + + +@domain.aggregate +class Employee: + email = String(identifier=True, validators=[EmailDomainValidator("mydomain.com")]) diff --git a/docs_src/guides/domain-definition/fields/options/011.py b/docs_src/guides/domain-definition/fields/options/011.py new file mode 100644 index 00000000..793ef4ab --- /dev/null +++ b/docs_src/guides/domain-definition/fields/options/011.py @@ -0,0 +1,11 @@ +from protean import Domain +from protean.fields import Integer + +domain = Domain(__file__) + + +@domain.aggregate +class Building: + doors = Integer( + required=True, error_messages={"required": "Every building needs some!"} + ) diff --git a/docs_src/guides/domain-definition/fields/simple-fields/001.py b/docs_src/guides/domain-definition/fields/simple-fields/001.py new file mode 100644 index 00000000..c2b6ca58 --- /dev/null +++ b/docs_src/guides/domain-definition/fields/simple-fields/001.py @@ -0,0 +1,9 @@ +from protean import Domain +from protean.fields import String + +domain = Domain(__file__) + + +@domain.aggregate +class Person: + name = String(required=True, min_length=2, max_length=50, sanitize=True) diff --git a/docs_src/guides/domain-definition/fields/simple-fields/002.py b/docs_src/guides/domain-definition/fields/simple-fields/002.py new file mode 100644 index 00000000..5cfb9d37 --- /dev/null +++ b/docs_src/guides/domain-definition/fields/simple-fields/002.py @@ -0,0 +1,10 @@ +from protean import Domain +from protean.fields import String, Text + +domain = Domain(__file__) + + +@domain.aggregate +class Book: + title = String(max_length=255) + content = Text(required=True) diff --git a/docs_src/guides/domain-definition/fields/simple-fields/003.py b/docs_src/guides/domain-definition/fields/simple-fields/003.py new file mode 100644 index 00000000..40ae1190 --- /dev/null +++ b/docs_src/guides/domain-definition/fields/simple-fields/003.py @@ -0,0 +1,10 @@ +from protean import Domain +from protean.fields import Integer, String + +domain = Domain(__file__) + + +@domain.aggregate +class Person: + name = String(max_length=255) + age = Integer(required=True) diff --git a/docs_src/guides/domain-definition/fields/simple-fields/004.py b/docs_src/guides/domain-definition/fields/simple-fields/004.py new file mode 100644 index 00000000..e02563ef --- /dev/null +++ b/docs_src/guides/domain-definition/fields/simple-fields/004.py @@ -0,0 +1,10 @@ +from protean import Domain +from protean.fields import Float, String + +domain = Domain(__file__) + + +@domain.aggregate +class Account: + name = String(max_length=255) + balance = Float(default=0.0) diff --git a/docs_src/guides/domain-definition/fields/simple-fields/005.py b/docs_src/guides/domain-definition/fields/simple-fields/005.py new file mode 100644 index 00000000..d9d88f44 --- /dev/null +++ b/docs_src/guides/domain-definition/fields/simple-fields/005.py @@ -0,0 +1,12 @@ +from datetime import datetime + +from protean import Domain +from protean.fields import Date, String + +domain = Domain(__file__) + + +@domain.aggregate +class Post: + title = String(max_length=255) + published_on = Date(default=lambda: datetime.today().date()) diff --git a/docs_src/guides/domain-definition/fields/simple-fields/006.py b/docs_src/guides/domain-definition/fields/simple-fields/006.py new file mode 100644 index 00000000..0d69a504 --- /dev/null +++ b/docs_src/guides/domain-definition/fields/simple-fields/006.py @@ -0,0 +1,12 @@ +from datetime import datetime, timezone + +from protean import Domain +from protean.fields import DateTime, String + +domain = Domain(__file__) + + +@domain.aggregate +class Post: + title = String(max_length=255) + created_at = DateTime(default=lambda: datetime.now(timezone.utc)) diff --git a/docs_src/guides/domain-definition/fields/simple-fields/007.py b/docs_src/guides/domain-definition/fields/simple-fields/007.py new file mode 100644 index 00000000..6713937b --- /dev/null +++ b/docs_src/guides/domain-definition/fields/simple-fields/007.py @@ -0,0 +1,10 @@ +from protean import Domain +from protean.fields import Boolean, String + +domain = Domain(__file__) + + +@domain.aggregate +class User: + name = String(required=True) + subscribed = Boolean(default=False) diff --git a/docs_src/guides/domain-definition/fields/simple-fields/008.py b/docs_src/guides/domain-definition/fields/simple-fields/008.py new file mode 100644 index 00000000..12bf492a --- /dev/null +++ b/docs_src/guides/domain-definition/fields/simple-fields/008.py @@ -0,0 +1,16 @@ +from protean import Domain +from protean.fields import Boolean, Identifier, String +from protean.utils import IdentityType + +domain = Domain(__file__) + +# Customize Identity Strategy and Type and activate +domain.config["IDENTITY_TYPE"] = IdentityType.INTEGER.value +domain.domain_context().push() + + +@domain.aggregate +class User: + user_id = Identifier(identifier=True) + name = String(required=True) + subscribed = Boolean(default=False) diff --git a/mkdocs.yml b/mkdocs.yml index a21cef88..c18cca2d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -89,15 +89,20 @@ nav: - Compose a Domain: - guides/compose-a-domain/index.md - guides/compose-a-domain/register-elements.md - - guides/compose-a-domain/initialize-domain.md - guides/compose-a-domain/activate-domain.md + - guides/compose-a-domain/initialize-domain.md - guides/compose-a-domain/when-to-compose.md - guides/compose-a-domain/element-decorators.md - guides/compose-a-domain/object-model.md # - guides/compose-a-domain/configuration.md - Defining Domain Concepts: - guides/domain-definition/index.md - - guides/domain-definition/fields.md + - Fields: + - guides/domain-definition/fields/index.md + - guides/domain-definition/fields/options.md + - guides/domain-definition/fields/simple-fields.md + - guides/domain-definition/fields/container-fields.md + - guides/domain-definition/fields/association-fields.md - guides/domain-definition/aggregates.md - guides/domain-definition/entities.md - guides/domain-definition/value-objects.md diff --git a/src/protean/container.py b/src/protean/container.py index 6ac29ae8..c0534c5f 100644 --- a/src/protean/container.py +++ b/src/protean/container.py @@ -321,7 +321,7 @@ def __setattr__(self, name, value): name in attributes(self) or name in fields(self) or name in ["errors", "state_", "_temp_cache", "_events", "_initialized"] - or name.startswith(("add_", "remove_", "_mark_changed_")) + or name.startswith(("add_", "remove_")) ): super().__setattr__(name, value) else: diff --git a/src/protean/core/entity.py b/src/protean/core/entity.py index 37c97be6..b07f80ee 100644 --- a/src/protean/core/entity.py +++ b/src/protean/core/entity.py @@ -258,11 +258,6 @@ def __init__(self, *template, **kwargs): # noqa: C901 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 diff --git a/src/protean/fields/association.py b/src/protean/fields/association.py index 52c3bcf9..1caa7478 100644 --- a/src/protean/fields/association.py +++ b/src/protean/fields/association.py @@ -354,10 +354,10 @@ def has_changed(self): class HasOne(Association): """ - Represents a one-to-one association between two entities. + Represents an one-to-one association between an aggregate and its entities. - This class is used to define a relationship where an instance of one entity - is associated with at most one instance of another entity. + This field is used to define a relationship where an aggregate is associated + with at most one instance of a child entity. """ def __set__(self, instance, value): @@ -374,14 +374,18 @@ def __set__(self, instance, value): current_value = getattr(instance, self.field_name) if current_value is None: + # Entity was not associated earlier self.change = "ADDED" elif value is None: + # Entity was associated earlier, but now being removed self.change = "DELETED" self.change_old_value = self.value elif current_value.id != value.id: + # A New Entity is being associated replacing the old one self.change = "UPDATED" self.change_old_value = self.value elif current_value.id == value.id and value.state_.is_changed: + # Entity was associated earlier, but now being updated self.change = "UPDATED" else: self.change = None # The same object has been assigned, No-Op @@ -411,10 +415,13 @@ def as_dict(self, value): class HasMany(Association): """ - Provide a HasMany relation to a remote entity. + Represents a one-to-many association between two entities. This field is used to define a relationship where an + aggregate has multiple instances of a chil entity. - 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. + Args: + to_cls (class): The class of the target entity. + via (str, optional): The name of the attribute on the target entity that links back to the source entity. + **kwargs: Additional keyword arguments to be passed to the base field class. """ def __init__(self, to_cls, via=None, **kwargs): @@ -424,7 +431,14 @@ def __set__(self, instance, value): if value is not None: self.add(instance, value) - def add(self, instance, items): + def add(self, instance, items) -> None: + """ + Add one or more linked entities to the source entity. + + Args: + instance: The source entity instance. + items: The linked entity or entities to be added. + """ data = getattr(instance, self.field_name) # Convert a single item into a list of items, if necessary @@ -463,7 +477,15 @@ def add(self, instance, items): # Reset Cache self.delete_cached_value(instance) - def remove(self, instance, items): + def remove(self, instance, items) -> None: + """ + Remove one or more linked entities from the source entity. + + Args: + instance: The source entity instance. + items: The linked entity or entities to be removed. + + """ data = getattr(instance, self.field_name) # Convert a single item into a list of items, if necessary @@ -479,47 +501,54 @@ def remove(self, instance, items): # Reset Cache self.delete_cached_value(instance) - def _fetch_objects(self, instance, key, value): - """Fetch linked entities. + def _fetch_objects(self, instance, key, value) -> list: + """ + Fetch linked entities. - This method returns a well-formed query, containing the foreign-key constraint. + Args: + instance: The source entity instance. + key (str): The name of the attribute on the target entity that links back to the source entity. + value: The value of the foreign key. + + Returns: + list: A list of linked entity instances. """ children_repo = current_domain.repository_for(self.to_cls) - temp_data = children_repo._dao.query.filter(**{key: value}).all().items + data = children_repo._dao.query.filter(**{key: value}).all().items # Set up linkage with owner element - for item in temp_data: + for item in data: setattr(item, key, value) # Add objects in temporary cache for _, item in instance._temp_cache[self.field_name]["added"].items(): - temp_data.append(item) + data.append(item) - # Update objects in temporary cache - new_temp_data = [] - for value in temp_data: + # Update objects from temporary cache if present + updated_objects = [] + for value in data: if value.id in instance._temp_cache[self.field_name]["updated"]: - new_temp_data.append( + updated_objects.append( instance._temp_cache[self.field_name]["updated"][value.id] ) else: - new_temp_data.append(value) - temp_data = new_temp_data + updated_objects.append(value) + data = updated_objects - # Remove objects in temporary cache + # Remove objects marked as removed in temporary cache for _, item in instance._temp_cache[self.field_name]["removed"].items(): - temp_data[:] = [value for value in temp_data if value.id != item.id] + data[:] = [value for value in data if value.id != item.id] - return temp_data + return data - def as_dict(self, value): - """Return JSON-compatible value of self""" - return [item.to_dict() for item in value] + def as_dict(self, value) -> list: + """ + Return JSON-compatible value of self. - # FIXME This has been added for applications to explicit mark a `HasMany` - # as changed. Should be removed with better design. - def _mark_changed(self, instance, item): - instance._temp_cache[self.field_name]["updated"][item.id] = item + Args: + value: The value to be converted to a JSON-compatible format. - # Reset Cache - self.delete_cached_value(instance) + Returns: + list: A list of dictionaries representing the linked entities. + """ + return [item.to_dict() for item in value]