Option sub_category -> sub_categories[] (default []). Both pool filters (instance category_filter, nested @{} ref) become boolean expressions (and/or/not/parens; comma=or) matched against an option's tag set via the shared engine/syntax/subcat_filter matcher, applied BEFORE constraints. Exclude-null is a separate flag: instance exclude_null + the trailing !null ref marker, replacing the old 'null' keyword.
4-segment ref grammar @{uuid#name:expr!null} single-sourced across tokenize.py/scan.py/pipeline.py/_constraints.py (! excluded from name/expr captures); tokenizer REF meta now carries filter_expr + exclude_null instead of a comma-split list. scan.py findall unpack sites updated for the 4th group.
validate_payload: per-option sub_categories[] must be in the registry; optional tag_groups (UI-only axes, members in-registry, <=1 group); registry names validated via subcat_filter.validate_subcat_name (rejects whitespace, boolean/ref chars, reserved words incl. null). wp_api/_validators delegates to the same function.
Constraints stay alive via a primary-tag stopgap: _apply_constraint_to_options + _record_pick use sub_categories[0] as the matrix key; true multi-tag matrix matching is SP3. Cascade fix_subcat_delete/rename + orchestrator scrub the per-option list; instance_schemas category_filter -> string. Existing engine tests migrated to the v2 shape; added tests for the ref regex, payload validation, and multi-tag pool filtering.
268 lines
10 KiB
Python
268 lines
10 KiB
Python
"""End-to-end cascade-edit orchestrator.
|
|
|
|
apply_cascade dispatches scan + fixer + target-delete + undo-write in a
|
|
single SQLite transaction. The `with conn:` block is the rollback boundary
|
|
described in the fixers.py atomicity contract.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from typing import Any
|
|
|
|
from engine.cascade.fixers import (
|
|
fix_bundle_delete,
|
|
fix_category_delete,
|
|
fix_combine_output_var_rename,
|
|
fix_option_delete,
|
|
fix_subcat_delete,
|
|
fix_subcat_rename,
|
|
fix_wildcard_delete,
|
|
)
|
|
from engine.cascade.scan import scan_affected
|
|
from engine.cascade.undo import write_undo_entry
|
|
from engine.db.repositories import (
|
|
BundleRepository,
|
|
CategoryRepository,
|
|
ModuleRepository,
|
|
)
|
|
|
|
|
|
def _delete_target(conn: sqlite3.Connection, kind: str, target_id: str) -> dict[str, Any]:
|
|
"""Delete the primary target entity and return its BEFORE-snapshot.
|
|
|
|
Raises XNotFound (e.g. ModuleNotFound, CategoryNotFound) if the target
|
|
does not exist — propagates to the caller's try/except error envelope.
|
|
"""
|
|
if kind == "bundle":
|
|
repo: Any = BundleRepository(conn)
|
|
elif kind == "category":
|
|
repo = CategoryRepository(conn)
|
|
else:
|
|
repo = ModuleRepository(conn)
|
|
snapshot = repo.get(target_id) # raises XNotFound if missing → caller catches
|
|
repo.delete(target_id)
|
|
return snapshot
|
|
|
|
|
|
def _rename_subcat_target_only(
|
|
conn: sqlite3.Connection,
|
|
wildcard_id: str,
|
|
old_name: str,
|
|
new_name: str,
|
|
) -> dict[str, Any] | None:
|
|
"""Rename a subcat inside the source wildcard ONLY (no cascade to refs).
|
|
|
|
Touches both the top-level `payload.sub_categories` declared list and
|
|
each option's `sub_categories` membership list (SP1 multi-tag shape).
|
|
"""
|
|
mod = ModuleRepository(conn)
|
|
wc = mod.get(wildcard_id)
|
|
snapshot = dict(wc) # capture BEFORE
|
|
payload = dict(wc.get("payload") or {})
|
|
|
|
declared = payload.get("sub_categories") or []
|
|
if old_name in declared:
|
|
payload["sub_categories"] = [
|
|
new_name if s == old_name else s for s in declared
|
|
]
|
|
|
|
new_options = []
|
|
for opt in payload.get("options") or []:
|
|
new_opt = dict(opt)
|
|
subs = new_opt.get("sub_categories")
|
|
if isinstance(subs, list) and old_name in subs:
|
|
new_opt["sub_categories"] = [
|
|
new_name if s == old_name else s for s in subs
|
|
]
|
|
new_options.append(new_opt)
|
|
payload["options"] = new_options
|
|
|
|
mod.update(wildcard_id, payload=payload)
|
|
return snapshot
|
|
|
|
|
|
def apply_cascade(conn: sqlite3.Connection, req: dict[str, Any]) -> dict[str, Any]:
|
|
"""Dispatch scan + fixer + target-delete + undo-write atomically.
|
|
|
|
Parameters
|
|
----------
|
|
conn:
|
|
Open, migrated sqlite3.Connection.
|
|
req:
|
|
Operation descriptor dict — see module docstring for full shape.
|
|
|
|
Returns
|
|
-------
|
|
dict
|
|
Response envelope. Always has ``ok: bool``.
|
|
On error: ``{ok: False, error: str}``.
|
|
On dry_run: ``{ok: True, affected_count: int, affected_entities: list}``.
|
|
On commit: ``{ok: True, undo_entry_id: str, affected_count: int,
|
|
affected_entities: list, diff: list}``.
|
|
On opt-out rename: ``{ok: True, undo_entry_id: str, affected_count: 0,
|
|
broken_refs: list}``.
|
|
"""
|
|
kind = req.get("kind")
|
|
target_id = req.get("id")
|
|
action = req.get("action")
|
|
cascade_refs = req.get("cascade_refs", True)
|
|
dry_run = req.get("dry_run", False)
|
|
new_name = req.get("new_name")
|
|
extra = req.get("extra") or {}
|
|
|
|
# --- Validate required fields -------------------------------------------
|
|
if not kind:
|
|
return {"ok": False, "error": "kind required"}
|
|
if not target_id:
|
|
return {"ok": False, "error": "id required"}
|
|
if not action:
|
|
return {"ok": False, "error": "action required"}
|
|
|
|
# --- Scan (read-only, safe outside transaction) --------------------------
|
|
affected = scan_affected(conn, kind=kind, id=target_id, action=action, extra=extra)
|
|
|
|
if dry_run:
|
|
return {
|
|
"ok": True,
|
|
"affected_count": len(affected),
|
|
"affected_entities": affected,
|
|
}
|
|
|
|
# --- Opt-out rename: snapshot + rename target only, write undo ----------
|
|
if action == "rename" and cascade_refs is False:
|
|
if not new_name:
|
|
return {"ok": False, "error": "new_name required for rename"}
|
|
try:
|
|
with conn:
|
|
snapshot_before: list[dict[str, Any]] = []
|
|
if kind == "subcategory":
|
|
old_name = extra.get("subcat_name", "")
|
|
snap = _rename_subcat_target_only(conn, target_id, old_name, new_name)
|
|
if snap is not None:
|
|
snapshot_before.append(snap)
|
|
elif kind == "combine_output_var":
|
|
# Update the combine's output_var field only (not its name).
|
|
mod = ModuleRepository(conn)
|
|
cb = mod.get(target_id)
|
|
snapshot_before.append(cb)
|
|
new_payload = {**(cb.get("payload") or {}), "output_var": new_name}
|
|
mod.update(target_id, payload=new_payload)
|
|
else:
|
|
# Generic module rename: snapshot then update name.
|
|
repo = ModuleRepository(conn)
|
|
before = repo.get(target_id)
|
|
snapshot_before.append(before)
|
|
repo.update(target_id, name=new_name)
|
|
undo_id = write_undo_entry(
|
|
conn,
|
|
target_kind=kind,
|
|
target_id=target_id,
|
|
action=action,
|
|
snapshot_before=snapshot_before,
|
|
snapshot_after=[],
|
|
)
|
|
return {
|
|
"ok": True,
|
|
"undo_entry_id": undo_id,
|
|
"affected_count": 0,
|
|
"broken_refs": affected,
|
|
}
|
|
except Exception as exc:
|
|
return {"ok": False, "error": str(exc)}
|
|
|
|
# --- Cascade-on path: dispatch fixer + target delete + undo -------------
|
|
# Validate rename operands before opening the transaction.
|
|
if action == "rename" and kind in ("subcategory", "combine_output_var") and not new_name:
|
|
return {"ok": False, "error": "new_name required for rename"}
|
|
|
|
try:
|
|
with conn:
|
|
touched_before: list[dict[str, Any]] = []
|
|
diff: list[dict[str, Any]] = []
|
|
key = (kind, action)
|
|
|
|
if key == ("wildcard", "delete"):
|
|
touched_before, diff = fix_wildcard_delete(conn, target_id)
|
|
elif key == ("subcategory", "delete"):
|
|
touched_before, diff = fix_subcat_delete(
|
|
conn, target_id, extra.get("subcat_name", "")
|
|
)
|
|
elif key == ("subcategory", "rename"):
|
|
touched_before, diff = fix_subcat_rename(
|
|
conn, target_id, extra.get("subcat_name", ""), new_name, # type: ignore[arg-type]
|
|
)
|
|
elif key == ("combine_output_var", "rename"):
|
|
touched_before, diff = fix_combine_output_var_rename(
|
|
conn, target_id, extra.get("old_name", ""), new_name, # type: ignore[arg-type]
|
|
)
|
|
elif key == ("category", "delete"):
|
|
touched_before, diff = fix_category_delete(conn, target_id)
|
|
elif key == ("bundle", "delete"):
|
|
touched_before, diff = fix_bundle_delete(conn, target_id)
|
|
elif key == ("option", "delete"):
|
|
touched_before, diff = fix_option_delete(
|
|
conn, extra.get("wildcard_id", ""), target_id,
|
|
)
|
|
elif action == "delete" and kind in (
|
|
"fixed_values",
|
|
"combine",
|
|
"derivation",
|
|
"constraint",
|
|
):
|
|
# These module types are leaves from a reverse-ref
|
|
# perspective: nothing in another row's payload points
|
|
# at them by id the way `option.id` is referenced from
|
|
# constraint matrices or `wildcard.id` is referenced
|
|
# from constraint source/target. A whole-entity delete
|
|
# is just a row drop + undo snapshot — no fix step,
|
|
# no diff entries.
|
|
#
|
|
# Caveat: combine.output_var IS referenced by name
|
|
# from downstream payloads, but the cascade pipeline
|
|
# treats that as a "rename" concern (already wired as
|
|
# combine_output_var/rename above). Deleting a combine
|
|
# leaves any consumer referencing the output_var with
|
|
# an unresolved variable, surfaced separately by the
|
|
# integrity/broken-refs scan.
|
|
touched_before, diff = [], []
|
|
else:
|
|
return {
|
|
"ok": False,
|
|
"error": f"unsupported (kind, action) pair: ({kind}, {action})",
|
|
}
|
|
|
|
# Delete target entity for whole-entity delete ops. Module
|
|
# subtypes route through ModuleRepository via _delete_target
|
|
# (the else-branch of its dispatch).
|
|
if action == "delete" and kind in (
|
|
"wildcard",
|
|
"category",
|
|
"bundle",
|
|
"fixed_values",
|
|
"combine",
|
|
"derivation",
|
|
"constraint",
|
|
):
|
|
target_snapshot = _delete_target(conn, kind, target_id)
|
|
touched_before.append(target_snapshot)
|
|
diff.append({"entity_id": target_id, "removed": True})
|
|
|
|
undo_id = write_undo_entry(
|
|
conn,
|
|
target_kind=kind,
|
|
target_id=target_id,
|
|
action=action,
|
|
snapshot_before=touched_before,
|
|
snapshot_after=[],
|
|
)
|
|
|
|
return {
|
|
"ok": True,
|
|
"undo_entry_id": undo_id,
|
|
"affected_count": len(diff),
|
|
"affected_entities": affected,
|
|
"diff": diff,
|
|
}
|
|
except Exception as exc:
|
|
return {"ok": False, "error": str(exc)}
|