Implement flexible entity relation system (Phases A–E)

Declarative relation registry via defrelation s-expressions with
cardinality enforcement (one-to-one, one-to-many, many-to-many),
registry-aware relate/unrelate/can-relate API endpoints, generic
container-nav fragment, and relation-driven UI components.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 08:35:17 +00:00
parent 6f1d5bac3c
commit a0a0f5ebc2
17 changed files with 928 additions and 28 deletions

View File

@@ -15,6 +15,8 @@ async def attach_child(
child_id: int,
label: str | None = None,
sort_order: int | None = None,
relation_type: str | None = None,
metadata: dict | None = None,
) -> ContainerRelation:
"""
Create a ContainerRelation and emit container.child_attached event.
@@ -39,6 +41,10 @@ async def attach_child(
existing.sort_order = sort_order
if label is not None:
existing.label = label
if relation_type is not None:
existing.relation_type = relation_type
if metadata is not None:
existing.metadata_ = metadata
await session.flush()
await emit_activity(
session,
@@ -50,12 +56,25 @@ async def attach_child(
"parent_id": parent_id,
"child_type": child_type,
"child_id": child_id,
**({"relation_type": relation_type} if relation_type else {}),
},
source_type="container_relation",
source_id=existing.id,
)
return existing
# Already attached and active — no-op
# Already attached and active — update mutable fields if provided
changed = False
if relation_type is not None and existing.relation_type != relation_type:
existing.relation_type = relation_type
changed = True
if metadata is not None and existing.metadata_ != metadata:
existing.metadata_ = metadata
changed = True
if label is not None and existing.label != label:
existing.label = label
changed = True
if changed:
await session.flush()
return existing
if sort_order is None:
@@ -75,6 +94,8 @@ async def attach_child(
child_id=child_id,
label=label,
sort_order=sort_order,
relation_type=relation_type,
metadata_=metadata,
)
session.add(rel)
await session.flush()
@@ -89,6 +110,7 @@ async def attach_child(
"parent_id": parent_id,
"child_type": child_type,
"child_id": child_id,
**({"relation_type": relation_type} if relation_type else {}),
},
source_type="container_relation",
source_id=rel.id,
@@ -102,8 +124,9 @@ async def get_children(
parent_type: str,
parent_id: int,
child_type: str | None = None,
relation_type: str | None = None,
) -> list[ContainerRelation]:
"""Query children of a container, optionally filtered by child_type."""
"""Query children of a container, optionally filtered by child_type or relation_type."""
stmt = select(ContainerRelation).where(
ContainerRelation.parent_type == parent_type,
ContainerRelation.parent_id == parent_id,
@@ -111,6 +134,8 @@ async def get_children(
)
if child_type is not None:
stmt = stmt.where(ContainerRelation.child_type == child_type)
if relation_type is not None:
stmt = stmt.where(ContainerRelation.relation_type == relation_type)
stmt = stmt.order_by(
ContainerRelation.sort_order.asc(), ContainerRelation.id.asc()
@@ -119,23 +144,49 @@ async def get_children(
return list(result.scalars().all())
async def get_parents(
session: AsyncSession,
child_type: str,
child_id: int,
parent_type: str | None = None,
relation_type: str | None = None,
) -> list[ContainerRelation]:
"""Query parents of an entity, optionally filtered by parent_type or relation_type."""
stmt = select(ContainerRelation).where(
ContainerRelation.child_type == child_type,
ContainerRelation.child_id == child_id,
ContainerRelation.deleted_at.is_(None),
)
if parent_type is not None:
stmt = stmt.where(ContainerRelation.parent_type == parent_type)
if relation_type is not None:
stmt = stmt.where(ContainerRelation.relation_type == relation_type)
stmt = stmt.order_by(ContainerRelation.id.asc())
result = await session.execute(stmt)
return list(result.scalars().all())
async def detach_child(
session: AsyncSession,
parent_type: str,
parent_id: int,
child_type: str,
child_id: int,
relation_type: str | None = None,
) -> bool:
"""Soft-delete a ContainerRelation and emit container.child_detached event."""
result = await session.execute(
select(ContainerRelation).where(
ContainerRelation.parent_type == parent_type,
ContainerRelation.parent_id == parent_id,
ContainerRelation.child_type == child_type,
ContainerRelation.child_id == child_id,
ContainerRelation.deleted_at.is_(None),
)
stmt = select(ContainerRelation).where(
ContainerRelation.parent_type == parent_type,
ContainerRelation.parent_id == parent_id,
ContainerRelation.child_type == child_type,
ContainerRelation.child_id == child_id,
ContainerRelation.deleted_at.is_(None),
)
if relation_type is not None:
stmt = stmt.where(ContainerRelation.relation_type == relation_type)
result = await session.execute(stmt)
rel = result.scalar_one_or_none()
if not rel:
return False
@@ -153,6 +204,7 @@ async def detach_child(
"parent_id": parent_id,
"child_type": child_type,
"child_id": child_id,
**({"relation_type": rel.relation_type} if rel.relation_type else {}),
},
source_type="container_relation",
source_id=rel.id,