Skip to content

Commit

Permalink
Enhance support for different identity types in Identifier field (#414
Browse files Browse the repository at this point in the history
)

Enhance support for different identity types in `Identifier` field

- Accept a parameter called `identity_type` per Entity, that helps customize the type of identifier for itself
- Consider the default `IDENTITY_TYPE` configured at the domain level
- Auto-transform identifiers to relevant identity type value
  - **UUID** and **STRING** (with numerals) to **INT**
  - **INT** and **UUID** to **STRING**
  - **STRING** (with valid UUID string) to **UUID**

Fixes #413
  • Loading branch information
subhashb authored May 10, 2024
1 parent 731cfa1 commit 86e77c1
Show file tree
Hide file tree
Showing 17 changed files with 505 additions and 84 deletions.
170 changes: 168 additions & 2 deletions docs/guides/domain-definition/fields.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,173 @@
# Fields (draft)

Fields are attributes that define the structure of data container elements in
Protean.
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='[email protected]', 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='[email protected]')

In [2]: domain.repository_for(Person).add(p1)
Out[2]: <Person: Person object (id: b2c592d5-bd78-4e1e-a9d1-eea20ab5374a)>

In [3]: p2 = Person(name= 'Jane Doe', email='[email protected]')

In [4]: domain.repository_for(Person).add(p2)
ERROR: Failed saving entity because {'email': ["Person with email '[email protected]' is already present."]}
...
ValidationError: {'email': ["Person with email '[email protected]' is already present."]}
```

We will explore more about persistence in the Application Layer.
<!-- FIXME Add link to database persistence and aggregate lifecycle -->

## Simple Fields

## Container Fields

## Associations

## Embedded Fields

## Custom Fields

<!-- Be careful not to choose field names that conflict with the
[Data Container API](../../api/data-containers) like `clean`, `clone`, or
Expand Down
4 changes: 3 additions & 1 deletion docs/guides/domain-definition/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,6 @@ Brokers
## Architecture Patterns

CQRS
Event Sourcing
Event Sourcing

## Data Container Elements
2 changes: 1 addition & 1 deletion docs_src/guides/domain-definition/008.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def utc_now():
@publishing.aggregate
class Post:
title = String(max_length=50)
created_on = DateTime(default=utc_now)
created_at = DateTime(default=utc_now)

stats = HasOne("Statistic")
comments = HasMany("Comment")
Expand Down
9 changes: 9 additions & 0 deletions docs_src/guides/domain-definition/fields/001.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from protean import Domain
from protean.fields import String

domain = Domain(__file__)


@domain.aggregate
class Person:
name = String(required=True)
10 changes: 10 additions & 0 deletions docs_src/guides/domain-definition/fields/002.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from protean import Domain
from protean.fields import String

domain = Domain(__file__)


@domain.aggregate
class Person:
email = String(identifier=True)
name = String(required=True)
16 changes: 16 additions & 0 deletions docs_src/guides/domain-definition/fields/003.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from datetime import datetime, timezone

from protean.domain import Domain
from protean.fields import DateTime, String

publishing = Domain(__name__)


def utc_now():
return datetime.now(timezone.utc)


@publishing.aggregate
class Post:
title = String(max_length=50)
created_at = DateTime(default=utc_now)
14 changes: 14 additions & 0 deletions docs_src/guides/domain-definition/fields/004.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from protean.domain import Domain
from protean.fields import List, String

domain = Domain(__name__)


def standard_topics():
return ["Music", "Cinema", "Politics"]


@domain.aggregate
class Adult:
name = String(max_length=255)
topics = List(default=standard_topics)
16 changes: 16 additions & 0 deletions docs_src/guides/domain-definition/fields/005.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import random

from protean.domain import Domain
from protean.fields import Integer

domain = Domain(__name__)

dice_sides = [4, 6, 8, 10, 12, 20]


@domain.aggregate
class Dice:
sides = Integer(default=lambda: random.choice(dice_sides))

def throw(self):
return random.randint(1, self.sides)
10 changes: 10 additions & 0 deletions docs_src/guides/domain-definition/fields/006.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from protean import Domain
from protean.fields import String

domain = Domain(__file__)


@domain.aggregate
class Person:
name = String(required=True)
email = String(unique=True)
45 changes: 31 additions & 14 deletions src/protean/fields/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,32 @@ class FieldBase:


class Field(FieldBase, FieldDescriptorMixin, metaclass=ABCMeta):
"""Abstract field from which other fields should extend.
:param default: If set, this value will be used during entity loading
if the field value is missing.
:param required: if `True`, Raise a :exc:`ValidationError` if the field
value is `None`.
:param unique: Indicate if this field needs to be checked for uniqueness.
:param choices: Valid choices for this field, if value is not one of the
choices a `ValidationError` is raised.
:param validators: Optional list of validators to be applied for this field.
:param error_messages: Optional list of validators to be applied for
this field.
"""
Base class for all fields in the Protean framework.
Fields are used to define the structure and behavior of attributes in an entity or aggregate.
They handle the validation, conversion, and storage of attribute values.
:param referenced_as: The name of the field as referenced in the database or external systems.
:type referenced_as: str, optional
:param description: A description of the field.
:type description: str, optional
:param identifier: Indicates if the field is an identifier for the entity or aggregate.
:type identifier: bool, optional
:param default: The default value for the field if no value is provided.
:type default: Any, optional
:param required: Indicates if the field is required (must have a value).
:type required: bool, optional
:param unique: Indicates if the field values must be unique within the repository.
:type unique: bool, optional
:param choices: A set of allowed choices for the field value.
:type choices: enum.Enum, optional
:param validators: Additional validators to apply to the field value.
:type validators: Iterable, optional
:param error_messages: Custom error messages for different kinds of errors.
:type error_messages: dict, optional
"""

# Default error messages for various kinds of errors.
default_error_messages = {
"invalid": "Value is not a valid type for this field.",
"unique": "{entity_name} with {field_name} '{value}' is already present.",
Expand Down Expand Up @@ -99,8 +110,14 @@ def __init__(
def _generic_param_values_for_repr(self):
"""Return the generic parameter values for the Field's repr"""
values = []
if self.required:
if self.description:
values.append(f"description='{self.description}'")
if self.identifier:
values.append("identifier=True")
if not self.identifier and self.required:
values.append("required=True")
if self.referenced_as:
values.append(f"referenced_as='{self.referenced_as}'")
if self.default is not None:
# If default is a callable, use its name
if callable(self.default):
Expand Down
41 changes: 40 additions & 1 deletion src/protean/fields/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@

from dateutil.parser import parse as date_parser

from protean.exceptions import InvalidOperationError, ValidationError
from protean.exceptions import InvalidOperationError, OutOfContextError, ValidationError
from protean.fields import Field, validators
from protean.globals import current_domain
from protean.utils import IdentityType


class String(Field):
Expand Down Expand Up @@ -346,11 +348,48 @@ class Identifier(Field):
Values can be UUIDs, Integers or Strings.
"""

def __init__(self, identity_type=None, **kwargs):
# Validate the identity type
if identity_type and identity_type not in [
id_type.value for id_type in IdentityType
]:
raise ValidationError({"identity_type": ["Identity type not supported"]})

# Pick identity type from domain configuration if not provided
try:
if not identity_type:
identity_type = current_domain.config["IDENTITY_TYPE"]
except OutOfContextError: # Domain not active
identity_type = IdentityType.STRING.value

self.identity_type = identity_type
super().__init__(**kwargs)

def _cast_to_type(self, value):
"""Verify that value is either a UUID, a String or an Integer"""
# A Boolean value is tested for specifically because `isinstance(value, int)` is `True` for Boolean values
if not (isinstance(value, (UUID, str, int))) or isinstance(value, bool):
self.fail("invalid", value=value)

# Ensure that the value is of the right type
if self.identity_type == IdentityType.UUID.value:
if not isinstance(value, UUID):
try:
value = UUID(value)
except (ValueError, AttributeError):
self.fail("invalid", value=value)
elif self.identity_type == IdentityType.INTEGER.value:
if not isinstance(value, int):
try:
value = int(value)
except ValueError:
self.fail("invalid", value=value)
elif self.identity_type == IdentityType.STRING.value:
if not isinstance(value, str):
value = str(value)
else:
raise ValidationError({"identity_type": ["Identity type not supported"]})

return value

def __set__(self, instance, value):
Expand Down
Loading

0 comments on commit 86e77c1

Please sign in to comment.