Skip to content

Association Fields

Association fields in Protean are designed to represent and manage relationships between different domain models. They facilitate the modeling of complex relationships, while encapsulating the technical aspects of persisting data.

Note that while Aggregates and Entities can manage associations, they can never link to another Aggregate directly. Aggregates are transaction boundaries and no transaction should span across aggregates. Read more in Aggregate concepts.

For a comprehensive guide on relationships, see Expressing Relationships.

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.

from protean import Domain
from protean.fields import HasOne, String

domain = Domain()


@domain.aggregate
class Book:
    title = String(required=True, max_length=100)
    author = HasOne("Author")


@domain.entity(part_of="Book")
class Author:
    name = String(required=True, max_length=50)

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:

In [1]: book = Book(
   ...:     title="The Great Gatsby",
   ...:     author=Author(name="F. Scott Fitzgerald")
   ...: )

In [2]: domain.repository_for(Book).add(book)
Out[2]: <Book: Book object (id: a4a642d9-87ed-44de-9889-c687466f171b)>

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.

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.

from protean import Domain
from protean.fields import Float, HasMany, String, Text

domain = Domain()


@domain.aggregate
class Post:
    title = String(required=True, max_length=100)
    body = Text()
    comments = HasMany("Comment")


@domain.entity(part_of=Post)
class Comment:
    content = String(required=True, max_length=50)
    rating = Float(max_value=5)

Protean provides helper methods that begin with add_ and remove_ to add and remove child entities from the HasMany relationship.

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'}

You can also use helper methods that begin with get_one_from_ and filter_ to filter for specific entities within the instances.

get_one_from_ returns a single entity. It raises ObjectNotFoundError if no matching entity for the criteria is found and TooManyObjectsError if more than one entity is found.

filter returns a list of zero or more matching entities.

In [1]: post = Post(
   ...:     title="Foo",
   ...:     comments=[
   ...:         Comment(content="Bar", rating=2.5),
   ...:         Comment(content="Baz", rating=5)
   ...:     ]
   ...: )

In [2]: post.filter_comments(content="Bar", rating=2.5)
Out[2]: [<Comment: Comment object (id: 3b7fd92e-be11-4b3b-96e9-1caf02779f14)>]

In [3]: comments = post.filter_comments(content="Bar", rating=2.5)

In [4]: comments[0].to_dict()
Out[4]: {'content': 'Bar', 'rating': 2.5, 'id': '3b7fd92e-be11-4b3b-96e9-1caf02779f14'}

Reference

A Reference field establishes the inverse relationship from child entities to their parent aggregate. While HasOne and HasMany define the forward relationship (parent to child), the Reference field enables navigation from child to parent.

Every entity associated with an aggregate automatically gets a Reference field created for it, unless explicitly defined. The field name is derived from the aggregate's name (e.g., Post becomes post).

@domain.entity(part_of=Post)
class Comment:
    content = String(max_length=500)
    post = Reference(Post)  # Explicit reference field

Shadow Fields

Reference fields automatically create shadow fields (foreign key attributes) that store the actual identifier values. These shadow fields follow the naming convention <field_name>_<id_field>:

In [1]: from protean.reflection import attributes
In [2]: attributes(Comment)
Out[2]: 
{'content': String(max_length=500),
 'id': Auto(identifier=True),
 'post': Reference(),
 'post_id': _ReferenceField()}

Custom Shadow Field Names

Use referenced_as to specify a custom name for the shadow field:

@domain.entity(part_of=Order)
class OrderItem:
    quantity = Integer()
    order = Reference(Order, referenced_as="order_number")
    # Creates shadow field 'order_number' instead of 'order_id'

The same name has to be specified on the HasOne or HasMany field with the via option, to establish the two-way relationship.

@domain.aggregate
class Order:
   ordered_at = DateTime()
   items = HasMany(OrderItem, via="order_number")

Customizing Foreign Keys with via

By default, association fields create foreign keys following the pattern <aggregate_name>_id. The via parameter allows you to specify a custom field name for the foreign key relationship:

@domain.aggregate  
class Product:
    name = String(max_length=100)
    reviews = HasMany("Review", via="product_sku")

@domain.entity(part_of=Product)
class Review:
    content = String(max_length=1000)
    product_sku = String()  # Custom foreign key field

This is particularly useful when you want to link entities using fields other than the default identifier, or when you need specific naming conventions for your foreign key relationships.