CentauroDB Logo
Core Concepts

Relations

Link parent and child objects with parent_ref — build one-to-many relationships without foreign key migrations.

CentauroDB supports one-to-many relationships between models. A child declares a parent_ref field that stores the parent's row.id, and the collection provides helpers to link, write, and query children. This guide walks through the entire workflow with a practical example.

parent_ref vs nested models

CentauroDB gives you two ways to represent related data. Choosing the right one depends on whether the child data has an independent lifecycle.

Use parent_ref when:

  • The child entity has its own lifecycle — it can be created, updated, queried, or deleted independently of the parent
  • You need to query children without loading the parent (via children_of or read_objects with a filter)
  • The relationship is one-to-many and the child set can grow over time (e.g. Server → Logs, Project → Tasks)
  • You want the child visible in views with its own indexed columns
  • You want to keep the history of changes — each child is its own row with its own write_time and edit_time
from centaurodb import Engine, Collection, CentauroModel, parent_ref

class Server(CentauroModel):
    __centauro_name__ = "Server"
    hostname: str = ""

class Log(CentauroModel):
    __centauro_name__ = "Log"
    server: int | None = parent_ref(Server)
    level: str = "info"
    message: str = ""

engine = Engine()
coll = Collection(engine, "infra")

# Write the parent
srv = Server(hostname="pegasus-01")
coll.write_object(srv)

# Each log is written independently — its own row, its own timestamp
coll.write_child(Log(level="info", message="Started"), parent=srv)
coll.write_child(Log(level="error", message="Disk full"), parent=srv)
coll.write_child(Log(level="info", message="Recovered"), parent=srv)

# Query logs independently — no need to load the server
errors = coll.read_objects(
    Log.fields.server == srv.row.id,
    Log.fields.level == "error",
)

Use nested Pydantic models when:

  • The nested data has no meaning outside the parent — it is always loaded, saved, and deleted together with it
  • You never need to query or filter on the nested objects independently (they live inside the parent's JSON blob)
  • The data is bounded and small (e.g. user preferences, a config object inside a server)
  • You want atomic reads/writes — one read_object call returns the complete aggregate
  • You don't need change history — updating the parent overwrites the nested data in place
from pydantic import BaseModel
from centaurodb import Engine, Collection, CentauroModel

class Preferences(BaseModel):
    theme: str = "dark"
    language: str = "en"
    notifications: bool = True

class User(CentauroModel):
    __centauro_name__ = "User"
    name: str = ""
    prefs: Preferences = Preferences()   # embedded — no separate table
    tags: list[str] = []                 # simple embedded list

engine = Engine()
coll = Collection(engine, "app")

# Write the user — preferences are stored inside the same JSON blob
user = User(name="Chiron", prefs=Preferences(theme="light"))
coll.write_object(user)

# Update preferences by mutating the parent and updating
user.prefs.language = "el"
user.tags.append("admin")
coll.update_object(user)
# Previous preferences are overwritten — no history kept

Rules of thumb

  • If you'd put a foreign key in SQL (Server → Logs) → use parent_ref
  • If you'd embed a sub-document in MongoDB (User → Preferences) → use a nested model
  • If you need a trail of changes over time → use parent_ref (each child has its own timestamps)
  • If you're unsure, start nested — it's simpler. Promote to parent_ref only when you need independent queries or the child set becomes large
Nested models are stored inside the parent's JSON blob, so they don't appear in views or indexes. If you later need to query or filter on nested data, or keep a history of changes, promote it to a parent_ref relationship.

Defining a relationship

Imagine you're building a quest tracker. Quests have many tasks:

from centaurodb import CentauroModel, parent_ref

class Quest(CentauroModel):
    __centauro_name__ = "Quest"
    name: str = ""
    status: str = "active"

class Task(CentauroModel):
    __centauro_name__ = "Task"
    quest: int | None = parent_ref(Quest)
    title: str = ""
    done: bool = False

parent_ref(Quest) creates a nullable int field that defaults to None. CentauroDB enforces that it's set before the child can be written — you'll get an UnlinkedParentError if you try to persist an unlinked child.

You can forward any Pydantic field keyword arguments:

class Task(CentauroModel):
    __centauro_name__ = "Task"
    quest: int | None = parent_ref(Quest, description="FK to quest")

parent_ref also accepts an on_delete keyword (default "restrict") that controls what happens when you delete a parent that still has children — see Deleting parents and children below.

The field defaults to None to preserve the zero-migration guarantee. Enforcement happens at write/update time, not at model creation.

Linking children to parents

There are two approaches, depending on your workflow.

Approach 1: Pass the parent object directly

You can assign a persisted parent object directly — CentauroDB auto-resolves it to row.id:

from centaurodb import Engine, Collection

engine = Engine()
coll = Collection(engine, "quests")

quest = Quest(name="The Odyssey")
coll.write_object(quest)

# At construction
task = Task(title="Escape the Cyclops", quest=quest)
print(task.quest)  # quest.row.id (int)
coll.write_object(task)

# Or via assignment
task2 = Task(title="Resist the Sirens")
task2.quest = quest  # resolves to quest.row.id
coll.write_object(task2)

Approach 2: One-step with write_child

Link and write in a single call:

task3 = Task(title="String the great bow")
coll.write_child(task3, parent=quest)

# Both linked and persisted
print(task3.quest)   # quest.row.id
print(task3.row.id)    # auto-assigned

write_child auto-discovers the matching parent_ref field by inspecting the child's model class.

The parent must be persisted before you can use it. Passing an unpersisted parent raises an exception. See UnlinkedParentError for details.

Querying children

Using children_of

Read all children linked to a parent:

tasks = coll.children_of(quest, Task)

for t in tasks:
    print(t.title, "" if t.done else "")
# Escape the Cyclops ○
# Resist the Sirens ○
# String the great bow ○

Pass raw=True to get raw database rows instead of model instances.

Using read_objects with conditions

For more complex queries, combine the parent filter with other conditions:

# All tasks for this quest
results = coll.read_objects(
    Task.fields.quest == quest.row.id
)

# Unfinished tasks for this quest
results = coll.read_objects(
    Task.fields.quest == quest.row.id,
    Task.fields.done == False,
)

# Combine with OR: tasks involving monsters or navigation
results = coll.read_objects(
    Task.fields.quest == quest.row.id,
    (Task.fields.title.like("%Cyclops%")) | (Task.fields.title.like("%Sirens%")),
)

Update validation

update_object also validates parent refs. Nulling out a parent_ref and trying to update raises UnlinkedParentError:

task = coll.read_object_by_id(1, Task)
task.quest = None
coll.update_object(task)  # raises UnlinkedParentError

Deleting parents and children

By default, parent_ref uses on_delete="restrict": deleting a parent that still has children raises ParentHasChildrenError, and the parent row is left untouched. This prevents orphaned children pointing at an id that no longer exists.

Delete the children first, then the parent:

coll.delete_object(quest)  # raises ParentHasChildrenError — tasks still exist

# Correct order: children first
coll.delete_objects(Task.fields.quest == quest.row.id)
coll.delete_object(quest)  # works
"cascade" and "set_null" are accepted as on_delete values at class definition, but a delete that would trigger them raises NotImplementedError. The call shape is committed now so future releases can implement them without breaking your model definitions — don't rely on them yet.

This restrict-by-default behavior is a deliberate change from the never-published 0.5.0 internal snapshot, which silently orphaned children when their parent was deleted.

Inheritance

Subclasses inherit parent_ref fields from their parent class:

class UrgentTask(Task):
    __centauro_name__ = "UrgentTask"
    deadline: str = ""

# UrgentTask inherits the quest parent_ref from Task
urgent = UrgentTask(title="Defeat Scylla before dawn", deadline="2025-06-15")
coll.write_child(urgent, parent=quest)

children = coll.children_of(quest, UrgentTask)

Multiple parent refs

A child model can reference multiple parent types:

class Crew(CentauroModel):
    __centauro_name__ = "Crew"
    name: str = ""

class Assignment(CentauroModel):
    __centauro_name__ = "Assignment"
    quest: int | None = parent_ref(Quest)
    crew: int | None = parent_ref(Crew)
    role: str = ""

Pass both parents at construction, or assign them individually before writing:

coll.write_object(crew)

# At construction
assignment = Assignment(role="captain", quest=quest, crew=crew)
coll.write_object(assignment)

# Or assign individually
assignment2 = Assignment(role="oarsman")
assignment2.quest = quest
assignment2.crew = crew
coll.write_object(assignment2)
Allparent_ref fields must be set before writing or updating. If any remain None, it raises UnlinkedParentError.

When a child has multiple parent_ref fields pointing to the same parent type, write_child raises TypeError — set the fields directly instead.

Views and indexes on relations

parent_ref fields work with views and indexes like any other field:

coll.create_view("board", Task, include_fields=["quest", "title", "done"])
coll.create_index("quest")

Query parent-child relationships via SQL:

df = coll.sql_select("""
    SELECT title, done
    FROM quests_view_board
    WHERE quest = ?
    ORDER BY title
""", (quest.row.id,))

Parent-child models must live in the same collection. This is not an arbitrary rule — it follows directly from CentauroDB's single-collection query model: each query targets one {name}_objects table, and parent-child relationships are expressed as filters on that table's meta JSON, not as cross-table JOINs. Row IDs are per-table (an autoincrement counter inside each {name}_objects), so a child row pointing at "id 1" only makes sense relative to the one collection that owns both rows. Mixing collections would silently mismatch unrelated IDs.

# Good — parent and child in the same collection
coll = Collection(engine, "quests")
coll.write_object(quest)
coll.write_child(task, parent=quest)
children = coll.children_of(quest, Task)

# Bad — separate collections break the relationship
quests = Collection(engine, "quests")
tasks = Collection(engine, "tasks")
# IDs in quests_objects and tasks_objects are independent
# — a quest with id=1 and an unrelated object with id=1
#    in another collection could create false matches
See Choosing a collection name for guidance on when separate collections make sense — but never split parent-child models across them.

Async support

All relation methods are available on AsyncCollection and AsyncTimeSeriesCollection:

from centaurodb import AsyncCollection, Engine

engine = Engine(":memory:", check_same_thread=False)
coll = AsyncCollection(engine, "quests")

await coll.write_object(quest)
await coll.write_child(task, parent=quest)
tasks = await coll.children_of(quest, Task)

See the Async guide for setup and usage patterns.