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

@@ -36,6 +36,10 @@ def load_shared_components() -> None:
register_components(_SEARCH_DESKTOP)
register_components(_MOBILE_FILTER)
register_components(_ORDER_SUMMARY_CARD)
# Relation-driven components
register_components(_RELATION_NAV)
register_components(_RELATION_ATTACH)
register_components(_RELATION_DETACH)
# ---------------------------------------------------------------------------
@@ -768,3 +772,48 @@ _ORDER_SUMMARY_CARD = r'''
(str (or currency "GBP") " " total-amount)
"\u2013"))))
'''
# ---------------------------------------------------------------------------
# ~relation-nav
# ---------------------------------------------------------------------------
_RELATION_NAV = '''
(defcomp ~relation-nav (&key href name icon nav-class relation-type)
(a :href href :class (or nav-class "flex items-center gap-3 rounded-lg py-2 px-3 text-sm text-stone-700 hover:bg-stone-100 transition-colors")
(when icon
(div :class "w-8 h-8 rounded bg-stone-200 flex items-center justify-center flex-shrink-0"
(i :class icon :aria-hidden "true")))
(div :class "flex-1 min-w-0"
(div :class "font-medium truncate" name))))
'''
# ---------------------------------------------------------------------------
# ~relation-attach
# ---------------------------------------------------------------------------
_RELATION_ATTACH = '''
(defcomp ~relation-attach (&key create-url label icon)
(a :href create-url
:hx-get create-url
:hx-target "#main-panel"
:hx-swap "outerHTML"
:hx-push-url "true"
:class "flex items-center gap-2 text-sm p-2 rounded hover:bg-stone-100 text-stone-500 hover:text-stone-700 transition-colors"
(when icon (i :class icon))
(span (or label "Add"))))
'''
# ---------------------------------------------------------------------------
# ~relation-detach
# ---------------------------------------------------------------------------
_RELATION_DETACH = '''
(defcomp ~relation-detach (&key detach-url name)
(button :hx-delete detach-url
:hx-confirm (str "Remove " (or name "this item") "?")
:class "text-red-500 hover:text-red-700 text-sm p-1 rounded hover:bg-red-50 transition-colors"
(i :class "fa fa-times" :aria-hidden "true")))
'''

View File

@@ -13,6 +13,7 @@ Special forms:
(lambda (params...) body) or (fn (params...) body)
(define name value)
(defcomp ~name (&key param...) body)
(defrelation :name :from "type" :to "type" :cardinality :card ...)
(begin expr...)
(quote expr)
(do expr...) — alias for begin
@@ -32,7 +33,7 @@ from __future__ import annotations
from typing import Any
from .types import Component, Keyword, Lambda, NIL, Symbol
from .types import Component, Keyword, Lambda, NIL, RelationDef, Symbol
from .primitives import _PRIMITIVES
@@ -429,6 +430,75 @@ def _sf_set_bang(expr: list, env: dict) -> Any:
return value
_VALID_CARDINALITIES = {"one-to-one", "one-to-many", "many-to-many"}
_VALID_NAV = {"submenu", "tab", "badge", "inline", "hidden"}
def _sf_defrelation(expr: list, env: dict) -> RelationDef:
"""``(defrelation :name :from "t" :to "t" :cardinality :card ...)``"""
if len(expr) < 2:
raise EvalError("defrelation requires a name")
name_kw = expr[1]
if not isinstance(name_kw, Keyword):
raise EvalError(f"defrelation name must be a keyword, got {type(name_kw).__name__}")
rel_name = name_kw.name
# Parse keyword args from remaining elements
kwargs: dict[str, str | None] = {}
i = 2
while i < len(expr):
key = expr[i]
if isinstance(key, Keyword):
if i + 1 < len(expr):
val = expr[i + 1]
if isinstance(val, Keyword):
kwargs[key.name] = val.name
else:
kwargs[key.name] = _eval(val, env) if not isinstance(val, str) else val
i += 2
else:
kwargs[key.name] = None
i += 1
else:
i += 1
for field in ("from", "to", "cardinality"):
if field not in kwargs:
raise EvalError(f"defrelation {rel_name} missing required :{field}")
card = kwargs["cardinality"]
if card not in _VALID_CARDINALITIES:
raise EvalError(
f"defrelation {rel_name}: invalid cardinality {card!r}, "
f"expected one of {_VALID_CARDINALITIES}"
)
nav = kwargs.get("nav", "hidden")
if nav not in _VALID_NAV:
raise EvalError(
f"defrelation {rel_name}: invalid nav {nav!r}, "
f"expected one of {_VALID_NAV}"
)
defn = RelationDef(
name=rel_name,
from_type=kwargs["from"],
to_type=kwargs["to"],
cardinality=card,
inverse=kwargs.get("inverse"),
nav=nav,
nav_icon=kwargs.get("nav-icon"),
nav_label=kwargs.get("nav-label"),
)
from .relations import register_relation
register_relation(defn)
env[f"relation:{rel_name}"] = defn
return defn
_SPECIAL_FORMS: dict[str, Any] = {
"if": _sf_if,
"when": _sf_when,
@@ -442,6 +512,7 @@ _SPECIAL_FORMS: dict[str, Any] = {
"fn": _sf_lambda,
"define": _sf_define,
"defcomp": _sf_defcomp,
"defrelation": _sf_defrelation,
"begin": _sf_begin,
"do": _sf_begin,
"quote": _sf_quote,

View File

@@ -46,7 +46,7 @@ class Tokenizer:
COMMENT = re.compile(r";[^\n]*")
STRING = re.compile(r'"(?:[^"\\]|\\.)*"')
NUMBER = re.compile(r"-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?")
KEYWORD = re.compile(r":[a-zA-Z_][a-zA-Z0-9_-]*")
KEYWORD = re.compile(r":[a-zA-Z_][a-zA-Z0-9_>-]*")
# Symbols may start with alpha, _, or common operator chars, plus ~ for components,
# <> for the fragment symbol, and & for &key/&rest.
SYMBOL = re.compile(r"[a-zA-Z_~*+\-><=/!?&][a-zA-Z0-9_~*+\-><=/!?.:&]*")

101
shared/sexp/relations.py Normal file
View File

@@ -0,0 +1,101 @@
"""
Relation registry — declarative entity relationship definitions.
Relations are defined as s-expressions using ``defrelation`` and stored
in a global registry. All services load the same definitions at startup
via ``load_relation_registry()``.
"""
from __future__ import annotations
from shared.sexp.types import RelationDef
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
_RELATION_REGISTRY: dict[str, RelationDef] = {}
def register_relation(defn: RelationDef) -> None:
"""Add a RelationDef to the global registry."""
_RELATION_REGISTRY[defn.name] = defn
def get_relation(name: str) -> RelationDef | None:
"""Look up a relation by name (e.g. ``"page->market"``)."""
return _RELATION_REGISTRY.get(name)
def relations_from(entity_type: str) -> list[RelationDef]:
"""All relations where *entity_type* is the ``from`` side."""
return [d for d in _RELATION_REGISTRY.values() if d.from_type == entity_type]
def relations_to(entity_type: str) -> list[RelationDef]:
"""All relations where *entity_type* is the ``to`` side."""
return [d for d in _RELATION_REGISTRY.values() if d.to_type == entity_type]
def all_relations() -> list[RelationDef]:
"""Return all registered relations."""
return list(_RELATION_REGISTRY.values())
def clear_registry() -> None:
"""Clear all registered relations (for testing)."""
_RELATION_REGISTRY.clear()
# ---------------------------------------------------------------------------
# Built-in relation definitions (s-expression source)
# ---------------------------------------------------------------------------
_BUILTIN_RELATIONS = '''
(begin
(defrelation :page->market
:from "page"
:to "market"
:cardinality :one-to-many
:inverse :market->page
:nav :submenu
:nav-icon "fa fa-shopping-bag"
:nav-label "markets")
(defrelation :page->calendar
:from "page"
:to "calendar"
:cardinality :one-to-many
:inverse :calendar->page
:nav :submenu
:nav-icon "fa fa-calendar"
:nav-label "calendars")
(defrelation :post->calendar_entry
:from "post"
:to "calendar_entry"
:cardinality :many-to-many
:inverse :calendar_entry->post
:nav :inline
:nav-icon "fa fa-file-alt"
:nav-label "events")
(defrelation :page->menu_node
:from "page"
:to "menu_node"
:cardinality :one-to-one
:nav :hidden)
)
'''
def load_relation_registry() -> None:
"""Parse built-in defrelation s-expressions and populate the registry."""
from shared.sexp.evaluator import evaluate
from shared.sexp.parser import parse
tree = parse(_BUILTIN_RELATIONS)
evaluate(tree)

View File

@@ -264,6 +264,80 @@ class TestErrorPage:
assert "<img" not in html
# ---------------------------------------------------------------------------
# ~relation-nav
# ---------------------------------------------------------------------------
class TestRelationNav:
def test_renders_link(self):
html = sexp(
'(~relation-nav :href "/market/farm/" :name "Farm Shop" :icon "fa fa-shopping-bag")',
)
assert 'href="/market/farm/"' in html
assert "Farm Shop" in html
assert "fa fa-shopping-bag" in html
def test_no_icon(self):
html = sexp(
'(~relation-nav :href "/cal/" :name "Events")',
)
assert 'href="/cal/"' in html
assert "Events" in html
assert "fa " not in html
def test_custom_nav_class(self):
html = sexp(
'(~relation-nav :href "/" :name "X" :nav-class "custom-class")',
**{"nav-class": "custom-class"},
)
assert 'class="custom-class"' in html
# ---------------------------------------------------------------------------
# ~relation-attach
# ---------------------------------------------------------------------------
class TestRelationAttach:
def test_renders_button(self):
html = sexp(
'(~relation-attach :create-url "/market/create/" :label "Add Market" :icon "fa fa-plus")',
**{"create-url": "/market/create/"},
)
assert 'href="/market/create/"' in html
assert 'hx-get="/market/create/"' in html
assert "Add Market" in html
assert "fa fa-plus" in html
def test_default_label(self):
html = sexp(
'(~relation-attach :create-url "/create/")',
**{"create-url": "/create/"},
)
assert "Add" in html
# ---------------------------------------------------------------------------
# ~relation-detach
# ---------------------------------------------------------------------------
class TestRelationDetach:
def test_renders_button(self):
html = sexp(
'(~relation-detach :detach-url "/api/unrelate" :name "Farm Shop")',
**{"detach-url": "/api/unrelate"},
)
assert 'hx-delete="/api/unrelate"' in html
assert 'hx-confirm="Remove Farm Shop?"' in html
assert "fa fa-times" in html
def test_default_name(self):
html = sexp(
'(~relation-detach :detach-url "/api/unrelate")',
**{"detach-url": "/api/unrelate"},
)
assert "this item" in html
# ---------------------------------------------------------------------------
# render_page() helper
# ---------------------------------------------------------------------------

View File

@@ -0,0 +1,245 @@
"""Tests for the relation registry (Phase A)."""
import pytest
from shared.sexp.evaluator import evaluate, EvalError
from shared.sexp.parser import parse
from shared.sexp.relations import (
_RELATION_REGISTRY,
clear_registry,
get_relation,
load_relation_registry,
relations_from,
relations_to,
all_relations,
)
from shared.sexp.types import RelationDef
@pytest.fixture(autouse=True)
def _clean_registry():
"""Clear registry before each test."""
clear_registry()
# ---------------------------------------------------------------------------
# defrelation parsing
# ---------------------------------------------------------------------------
class TestDefrelation:
def test_basic_defrelation(self):
tree = parse('''
(defrelation :page->market
:from "page"
:to "market"
:cardinality :one-to-many
:inverse :market->page
:nav :submenu
:nav-icon "fa fa-shopping-bag"
:nav-label "markets")
''')
result = evaluate(tree)
assert isinstance(result, RelationDef)
assert result.name == "page->market"
assert result.from_type == "page"
assert result.to_type == "market"
assert result.cardinality == "one-to-many"
assert result.inverse == "market->page"
assert result.nav == "submenu"
assert result.nav_icon == "fa fa-shopping-bag"
assert result.nav_label == "markets"
def test_defrelation_registered(self):
tree = parse('''
(defrelation :a->b
:from "a" :to "b" :cardinality :one-to-one :nav :hidden)
''')
evaluate(tree)
assert get_relation("a->b") is not None
assert get_relation("a->b").cardinality == "one-to-one"
def test_defrelation_one_to_one(self):
tree = parse('''
(defrelation :page->menu_node
:from "page" :to "menu_node"
:cardinality :one-to-one :nav :hidden)
''')
result = evaluate(tree)
assert result.cardinality == "one-to-one"
assert result.inverse is None
assert result.nav == "hidden"
def test_defrelation_many_to_many(self):
tree = parse('''
(defrelation :post->entry
:from "post" :to "calendar_entry"
:cardinality :many-to-many
:inverse :entry->post
:nav :inline
:nav-icon "fa fa-file-alt"
:nav-label "events")
''')
result = evaluate(tree)
assert result.cardinality == "many-to-many"
def test_default_nav_is_hidden(self):
tree = parse('''
(defrelation :x->y
:from "x" :to "y" :cardinality :one-to-many)
''')
result = evaluate(tree)
assert result.nav == "hidden"
def test_invalid_cardinality_raises(self):
tree = parse('''
(defrelation :bad
:from "a" :to "b" :cardinality :wrong)
''')
with pytest.raises(EvalError, match="invalid cardinality"):
evaluate(tree)
def test_invalid_nav_raises(self):
tree = parse('''
(defrelation :bad
:from "a" :to "b" :cardinality :one-to-one :nav :bogus)
''')
with pytest.raises(EvalError, match="invalid nav"):
evaluate(tree)
def test_missing_from_raises(self):
tree = parse('''
(defrelation :bad :to "b" :cardinality :one-to-one)
''')
with pytest.raises(EvalError, match="missing required :from"):
evaluate(tree)
def test_missing_to_raises(self):
tree = parse('''
(defrelation :bad :from "a" :cardinality :one-to-one)
''')
with pytest.raises(EvalError, match="missing required :to"):
evaluate(tree)
def test_missing_cardinality_raises(self):
tree = parse('''
(defrelation :bad :from "a" :to "b")
''')
with pytest.raises(EvalError, match="missing required :cardinality"):
evaluate(tree)
def test_name_must_be_keyword(self):
tree = parse('(defrelation "not-keyword" :from "a" :to "b" :cardinality :one-to-one)')
with pytest.raises(EvalError, match="must be a keyword"):
evaluate(tree)
# ---------------------------------------------------------------------------
# Registry queries
# ---------------------------------------------------------------------------
class TestRegistry:
def _load_sample(self):
tree = parse('''
(begin
(defrelation :page->market
:from "page" :to "market" :cardinality :one-to-many
:nav :submenu :nav-icon "fa fa-shopping-bag" :nav-label "markets")
(defrelation :page->calendar
:from "page" :to "calendar" :cardinality :one-to-many
:nav :submenu :nav-icon "fa fa-calendar" :nav-label "calendars")
(defrelation :post->entry
:from "post" :to "calendar_entry" :cardinality :many-to-many
:nav :inline)
(defrelation :page->menu_node
:from "page" :to "menu_node" :cardinality :one-to-one
:nav :hidden))
''')
evaluate(tree)
def test_get_relation(self):
self._load_sample()
rel = get_relation("page->market")
assert rel is not None
assert rel.to_type == "market"
def test_get_relation_not_found(self):
assert get_relation("nonexistent") is None
def test_relations_from_page(self):
self._load_sample()
rels = relations_from("page")
names = {r.name for r in rels}
assert names == {"page->market", "page->calendar", "page->menu_node"}
def test_relations_from_post(self):
self._load_sample()
rels = relations_from("post")
assert len(rels) == 1
assert rels[0].name == "post->entry"
def test_relations_from_empty(self):
self._load_sample()
assert relations_from("nonexistent") == []
def test_relations_to_market(self):
self._load_sample()
rels = relations_to("market")
assert len(rels) == 1
assert rels[0].name == "page->market"
def test_relations_to_calendar_entry(self):
self._load_sample()
rels = relations_to("calendar_entry")
assert len(rels) == 1
assert rels[0].name == "post->entry"
def test_all_relations(self):
self._load_sample()
assert len(all_relations()) == 4
def test_clear_registry(self):
self._load_sample()
assert len(all_relations()) == 4
clear_registry()
assert len(all_relations()) == 0
# ---------------------------------------------------------------------------
# load_relation_registry() — built-in definitions
# ---------------------------------------------------------------------------
class TestLoadBuiltins:
def test_loads_builtin_relations(self):
load_relation_registry()
assert get_relation("page->market") is not None
assert get_relation("page->calendar") is not None
assert get_relation("post->calendar_entry") is not None
assert get_relation("page->menu_node") is not None
def test_builtin_page_market(self):
load_relation_registry()
rel = get_relation("page->market")
assert rel.from_type == "page"
assert rel.to_type == "market"
assert rel.cardinality == "one-to-many"
assert rel.inverse == "market->page"
assert rel.nav == "submenu"
assert rel.nav_icon == "fa fa-shopping-bag"
def test_builtin_post_entry(self):
load_relation_registry()
rel = get_relation("post->calendar_entry")
assert rel.cardinality == "many-to-many"
assert rel.nav == "inline"
def test_builtin_page_menu_node(self):
load_relation_registry()
rel = get_relation("page->menu_node")
assert rel.cardinality == "one-to-one"
assert rel.nav == "hidden"
def test_frozen_dataclass(self):
load_relation_registry()
rel = get_relation("page->market")
with pytest.raises(AttributeError):
rel.name = "changed"

View File

@@ -148,9 +148,29 @@ class Component:
return f"<Component ~{self.name}({', '.join(self.params)})>"
# ---------------------------------------------------------------------------
# RelationDef
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class RelationDef:
"""A declared relation between two entity types.
Created by ``(defrelation :name ...)`` s-expressions.
"""
name: str # "page->market"
from_type: str # "page"
to_type: str # "market"
cardinality: str # "one-to-one" | "one-to-many" | "many-to-many"
inverse: str | None # "market->page"
nav: str # "submenu" | "tab" | "badge" | "inline" | "hidden"
nav_icon: str | None # "fa fa-shopping-bag"
nav_label: str | None # "markets"
# ---------------------------------------------------------------------------
# Type alias
# ---------------------------------------------------------------------------
# An s-expression value after evaluation
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Component | list | dict | _Nil | None
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Component | RelationDef | list | dict | _Nil | None