Common Arguments
description
A long form description of the field. This value can be used by database adapters to provide additional context to a field.
from protean import Domain
from protean.fields import List, String
domain = Domain(__file__, load_toml=False)
@domain.aggregate
class Building:
permit = List(
content_type=String, description="Licences and Approvals", required=True
)
required
Indicates if the field is required (must have a value). If True
, the field
is not allowed to be blank. Default is False
.
from protean import Domain
from protean.fields import String
domain = Domain(__file__, load_toml=False)
@domain.aggregate
class Person:
name = String(required=True)
Leaving the field blank or not specifying a value will raise a
ValidationError
:
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).
from protean import Domain
from protean.fields import String
domain = Domain(__file__, load_toml=False)
@domain.aggregate
class Person:
email = String(identifier=True)
name = String(required=True)
The field is validated to be unique and non-blank:
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
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 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.
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)
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:
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)
Initializing an Adult aggregate will populate the defaults correctly:
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:
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)
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.
from protean import Domain
from protean.fields import String
domain = Domain(__file__, load_toml=False)
@domain.aggregate
class Person:
name = String(required=True)
email = String(unique=True)
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:
In [1]: p1 = Person(name='John Doe', email='john.doe@example.com')
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='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.
from enum import Enum
from protean import Domain
from protean.fields import Integer, String
domain = Domain(__file__, load_toml=False)
class BuildingStatus(Enum):
WIP = "WIP"
DONE = "DONE"
@domain.aggregate
class Building:
name = String(max_length=50)
floors = Integer()
status = String(choices=BuildingStatus)
The choices are enforced when the field is initialized or updated:
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.
from protean import Domain
from protean.fields import String
domain = Domain(__file__, load_toml=False)
@domain.aggregate
class Person:
email = String(unique=True)
name = String(referenced_as="fullname", required=True)
Protean will now persist the value under fullname
instead of name
.
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
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.
from typing import Any
from protean import Domain
from protean.exceptions import ValidationError
from protean.fields import String
domain = Domain(__file__, load_toml=False)
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")])
If the value fails to satisfy the validation, a ValidationError
will be
thrown with the custom error message.
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.
from protean import Domain
from protean.fields import Integer
domain = Domain(__file__, load_toml=False)
@domain.aggregate
class Building:
doors = Integer(
required=True, error_messages={"required": "Every building needs some!"}
)
Now the custom message will be available in ValidationError
:
In [1]: Building()
ERROR: Error during initialization: {'doors': ['Every building needs doors.']}
...
ValidationError: {'doors': ['Every building needs some!']}