Relationship Field¶
The Relationship field creates references between model instances, similar
to foreign keys in SQL databases. Unlike SQL, Redis has no JOIN operation.
Popoto stores a reference (the related instance's Redis key) and lazy-loads
the full object on access. This keeps writes fast and avoids loading data you
never use, but multi-model queries work differently than in Django or
SQLAlchemy.
Basic Usage¶
A Relationship field points from one model to another. In a food delivery
system, a MenuItem belongs to a Restaurant and an Order references
a Customer, a Restaurant, and optionally a Driver.
from popoto import (
Model, KeyField, AutoKeyField, UniqueKeyField,
Field, SortedField, GeoField, Relationship, DatetimeField,
)
class Restaurant(Model):
name = KeyField()
cuisine = Field(type=str)
rating = SortedField(type=float)
location = GeoField()
class MenuItem(Model):
item_id = AutoKeyField()
name = Field(type=str)
price = SortedField(type=float)
restaurant = Relationship(model=Restaurant) # links to Restaurant
available = Field(type=bool, default=True)
class Customer(Model):
username = KeyField()
email = UniqueKeyField()
name = Field(type=str)
address = GeoField()
class Driver(Model):
driver_id = AutoKeyField()
name = Field(type=str)
phone = UniqueKeyField()
rating = SortedField(type=float)
location = GeoField()
class Order(Model):
order_id = AutoKeyField()
customer = Relationship(model=Customer)
restaurant = Relationship(model=Restaurant)
driver = Relationship(model=Driver, null=True) # nullable
total = SortedField(type=float)
status = Field(type=str, default="pending")
created_at = DatetimeField(auto_now_add=True)
updated_at = DatetimeField(auto_now=True)
class Meta:
order_by = "-created_at"
ttl = 2592000 # 30 days
MenuItem.restaurant is a required relationship. Order has three: customer
and restaurant are required, while driver is nullable because a driver is
assigned only after the order is accepted.
Creating Related Instances¶
Create the referenced model first, then pass it to the relationship field.
burger_palace = Restaurant.create(
name="Burger Palace", cuisine="American",
rating=4.5, location=(40.7128, -74.0060),
)
classic_burger = MenuItem.create(
name="Classic Burger", price=12.99, restaurant=burger_palace,
)
print(classic_burger.restaurant.name)
# => "Burger Palace"
For an Order, pass multiple related instances at once.
alice = Customer.create(username="alice", email="alice@example.com",
name="Alice Chen", address=(40.7128, -74.0060))
order = Order.create(customer=alice, restaurant=burger_palace,
total=25.98, status="pending")
print(order.customer.name)
# => "Alice Chen"
print(order.restaurant.name)
# => "Burger Palace"
You can reassign a relationship and call save() to update it.
sushi_house = Restaurant.create(name="Sushi House", cuisine="Japanese",
rating=4.8, location=(40.7580, -73.9855))
classic_burger.restaurant = sushi_house
classic_burger.save()
print(classic_burger.restaurant.cuisine)
# => "Japanese"
Querying by Relationship¶
Pass a model instance to filter for all objects that reference it.
items = MenuItem.query.filter(restaurant=burger_palace)
for item in items:
print(f"{item.name} - ${item.price}")
# => "Classic Burger - $12.99"
alice_orders = Order.query.filter(customer=alice)
print(len(alice_orders))
# => 1
Note
The filter value must be a model instance, not a string or key value.
Order.query.filter(customer=alice) works, but
Order.query.filter(customer="alice") raises a QueryException.
Nested Field Access¶
Use double-underscore notation to query by fields on the related model.
orders = Order.query.filter(restaurant__name="Burger Palace")
for order in orders:
print(f"Order {order.order_id}: ${order.total}")
# => "Order ...: $25.98"
# Combine nested filters with other parameters
big_orders = Order.query.filter(
restaurant__name="Burger Palace", total__gte=20.0,
)
print(len(big_orders))
# => 1
See Making Queries for the full list of filter operators.
Warning
Nested filters do not recurse through multiple relationship levels.
Order.query.filter(restaurant__name="Burger Palace") works because
name is a direct field on Restaurant. You cannot chain through
a second relationship.
Traversing Relationships¶
Because Redis has no JOINs, you traverse relationships with Python loops.
alice = Customer.query.get(username="alice")
alice_orders = Order.query.filter(customer=alice)
restaurants = [order.restaurant for order in alice_orders]
for r in restaurants:
print(r.name)
# => "Burger Palace"
N+1 Query Problem
Each order.restaurant access triggers a separate Redis call. With 100
orders, that is 100 additional round trips on top of the initial query.
# Anti-pattern: 1 query + N lazy loads
for order in Order.query.all():
print(order.customer.name) # Redis GET per iteration
print(order.restaurant.name) # Another Redis GET per iteration
For large result sets, consider denormalizing frequently accessed fields
(e.g., storing restaurant_name directly on Order).
How Relationships Work Internally¶
Storage as Redis Key¶
Popoto stores the related instance's Redis key as a string, not the full
serialized object. A MenuItem with restaurant=burger_palace stores
"Restaurant:Burger Palace" in the Redis hash.
Lazy Loading¶
When you load a model, relationship fields initially hold the raw Redis key string. Popoto resolves it to a full instance only when you access the field.
item = MenuItem.query.get(name="Fries")
# item.restaurant is internally "Restaurant:Burger Palace"
restaurant = item.restaurant # triggers Redis HGETALL
print(restaurant.cuisine) # => "American"
# Subsequent access uses the resolved instance -- no extra Redis call
Because the stored value is a key string rather than an embedded object, an import
that regenerates keys can repoint a relationship by rewriting that string, without
ever loading the target. That is what Relationship.remap_references does under
import_records(..., preserve_keys=False) and
popoto-transfer import --regenerate-keys; a target that is not part of the same
import keeps its old key and is reported as dangling. See
Regenerating keys on import.
Relationship Index¶
Popoto maintains a Redis set for each relationship value so that
filter(restaurant=instance) is an O(1) set lookup rather than a scan.
$RelationshipF:MenuItem:restaurant:Restaurant:Burger Palace
-> { MenuItem:<key1>, MenuItem:<key2>, ... }
Indexes are created on save(), updated when the relationship changes,
and cleaned up on delete(). When you reassign a relationship (e.g.,
moving a MenuItem from one Restaurant to another), save() removes
the instance from the old relationship's index set and adds it to the
new one. This ensures that filter(restaurant=old_restaurant) no longer
returns the moved item.
Sampling the Index: Relationship.sample_related_keys()¶
filter(restaurant=instance) reads that index set in full (SMEMBERS) and
hydrates every match. On a hub node — a restaurant with 50,000 menu items, a
memory that half the graph points at — that is exactly the unbounded fan-out
you do not want in a loop that is walking edges.
Relationship.sample_related_keys() is the bounded read of the same set. It
issues one SRANDMEMBER and returns the raw member keys as strings, without
loading a single instance:
from popoto import Relationship
# Up to 20 MenuItem keys pointing at this restaurant, sampled at random.
keys = Relationship.sample_related_keys(
MenuItem, "restaurant", burger_palace.db_key, 20
)
# => ["MenuItem:classic-burger", "MenuItem:fries", ...]
| Argument | Meaning |
|---|---|
model |
The model class holding the Relationship field (MenuItem, not Restaurant). |
field_name |
The name of the field on that model. |
related_key |
The key being pointed at. A DB_key is used as-is; a str is treated as a full Redis key and parsed with DB_key.from_redis_key(). |
count |
Passed straight to SRANDMEMBER. |
Three things to know:
- The sample is random and unordered.
SRANDMEMBERwith a positive count returns distinct members with no ordering guarantee, and two calls with the same arguments will generally return different members. It answers "give me some neighbors", not "give me the first N neighbors". - A
strrelated_keyis a Redis key, not a value to escape. Pass"Restaurant:Burger Palace", not"Burger Palace". ConstructingDB_key("Restaurant:Burger Palace")would escape the colon and address a key that does not exist, so the method parses rather than constructs. - It does not catch anything. A connection failure propagates. Callers
that want to degrade — continue the walk with no neighbors rather than
abort — own that policy and catch around the call.
popoto.recipes.graph_traversaldoes exactly this.
An unknown or never-saved related_key has no index set, so the method returns
[] rather than raising.
Circular Reference Protection¶
Popoto tracks which instances are currently being deserialized. If it encounters a cycle, it leaves the relationship as a Redis key string instead of recursing infinitely.
Null Relationships¶
An Order starts without a driver -- None until one is assigned.
order = Order.create(customer=alice, restaurant=burger_palace,
total=15.50, status="pending")
print(order.driver)
# => None
dave = Driver.create(name="Dave", phone="555-0199",
rating=4.9, location=(40.7300, -73.9950))
order.driver = dave
order.status = "assigned"
order.save()
print(order.driver.name)
# => "Dave"
Tip
Always check for None before accessing attributes on a nullable
relationship to avoid AttributeError.
Self-Referential Relationships¶
A model can reference itself -- useful for combo meals that include other menu items.
class MenuItem(Model):
item_id = AutoKeyField()
name = Field(type=str)
price = SortedField(type=float)
restaurant = Relationship(model=Restaurant)
combo_includes = Relationship(model='MenuItem', null=True)
burger = MenuItem.create(name="Classic Burger", price=12.99,
restaurant=burger_palace, combo_includes=None)
combo = MenuItem.create(name="Burger Combo", price=14.99,
restaurant=burger_palace, combo_includes=burger)
print(combo.combo_includes.name)
# => "Classic Burger"
Use a string ('MenuItem') when the class is not yet fully defined. Set
null=True so non-combo items can leave the field empty.
Best Practices¶
Query from the Model That Holds the Relationship¶
You can only filter on a relationship from the model that declares it.
orders = Order.query.filter(customer=alice) # Order has customer field
# Customer.query.filter(orders=...) is NOT possible
If you need reverse lookups, add a relationship field in both directions.
Denormalize for Frequent Access Patterns¶
When you repeatedly need a related field's value, store it directly on the
model. For example, adding restaurant_name to Order avoids lazy-loading
the Restaurant just to display its name.
Tip
Denormalization is a common Redis pattern. Duplicating a field is often cheaper than chaining lookups, but you must keep it in sync manually.
No Cascade Delete¶
Deleting a model does not cascade to related instances. If you delete a
Restaurant, any MenuItem or Order referencing it will hold a stale key.
Warning
Always delete or reassign dependent instances before removing the referenced model.
Keep Relationship Depth Shallow¶
Each relationship traversal costs a Redis round trip. Design your models so the most common access patterns require at most one or two hops.
See Making Queries for filter operators and
Model Meta Options for order_by and ttl configuration.