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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user