Foreign Keys¶
Foreign keys are currently experimental. Cascade behavior (save / delete / duplicate / TTL) and eager loading with depth control land in follow-up releases. The API may change based on feedback.
Reference[T] is a typed, lazy reference from one model to another. Instead of embedding the target document, the parent stores just the target's Redis key string (e.g. "Author:abc-123") inline in its own JSON. The referenced model is fetched on demand.
from rapyer import AtomicRedisModel
from rapyer.types import Reference
class Author(AtomicRedisModel):
name: str = "anon"
class Book(AtomicRedisModel):
title: str = "untitled"
author: Reference[Author]
Assigning a Reference¶
A reference field accepts several forms. Most often you assign a model instance directly:
alice = Author(name="alice")
await alice.asave()
book = Book(title="Redis in Action", author=alice)
await book.asave()
You can also assign a raw key string, another reference, or a {"$ref": ..., "$id": ...} reference dict:
book = Book(title="x", author="Author:abc-123")
book = Book(title="x", author={"$ref": "Author", "$id": "abc-123"})
Existence is not validated on save
Saving a model does not check that the referenced key actually exists in Redis. A reference is stored as a plain key string, so you can save a Book pointing at an Author that has never been created (or has since been deleted). The missing target only surfaces later, when you call afetch() and get a KeyNotFound. Referential-integrity enforcement may land in a follow-up release.
Lazy Resolution¶
When you load a model, its references come back unresolved โ the key is known, but the target is not yet fetched:
loaded = await Book.aget(book.key)
loaded.author.is_resolved # False
loaded.author.target_key # "Author:abc-123"
Call afetch() to load the target from Redis and cache it in place. Once resolved, read the target's fields directly on the reference:
await loaded.author.afetch()
loaded.author.is_resolved # True
loaded.author.name # "alice" โ read fields straight off the reference
afetch() is idempotent โ a second call returns the cached instance without hitting Redis.
Accessing a field before resolution raises NotResolvedError rather than triggering hidden I/O โ attribute access cannot await, so resolution is always explicit:
Releasing a Target¶
Drop the hydrated instance while keeping the key reference:
await loaded.author.aunload()
loaded.author.is_resolved # False
loaded.author.target_key # "Author:abc-123" โ preserved
Optional and List Fields¶
References work as optional fields and inside lists:
from typing import Optional
class Book(AtomicRedisModel):
title: str = "untitled"
author: Reference[Author]
publisher: Optional[Reference[Publisher]] = None
co_authors: list[Reference[Author]] = []
Each reference in a list resolves independently โ fetching one does not fetch the others:
loaded = await Book.aget(book.key)
await loaded.co_authors[0].afetch()
loaded.co_authors[0].is_resolved # True
loaded.co_authors[1].is_resolved # False
Self-References and Forward Refs¶
Use a string parameter to reference a model that isn't defined yet, including the model itself:
The name is resolved against the registered rapyer models at initialization time (by resolve_relational_targets); afetch() then uses that cached target class to load by key:
Missing Targets¶
If the referenced key no longer exists in Redis, afetch() raises KeyNotFound:
book = Book(title="x", author="Author:deleted")
await book.asave()
loaded = await Book.aget(book.key)
await loaded.author.afetch() # โ raises KeyNotFound
How It Works¶
Unlike special fields, which store data under a separate Redis key, a reference is stored inline in the parent's JSON as the target's key string. The target model lives at its own top-level key and is fetched independently โ so its nested and special fields read from their own paths, not through the parent.