feat(db,models,crud): m002 menu tree + node CRUD with shim
Replace the fixed two-level (categories + subcategories) menu shape
with an arbitrary-depth tree, capped at 4 levels. The legacy
Atitlan.io project this carries forward already used a self-FK tree;
real menus need the depth (Drinks -> Hot Beverages -> Coffee-based
-> Espressos). As a side benefit, the current CMS has no UI for
subcategories at all, so this refactor incidentally fixes that gap.
Pattern: adjacency list (parent_id self-FK) + denormalized
materialized path (TEXT, '/'-separated) + denormalized depth.
Rejected closure table (overkill at n=5..50) and Postgres ltree
(not portable to SQLite). Subtree queries become
'WHERE path LIKE :p || '%''; subtree moves are a single
SUBSTR + concat UPDATE; max-depth and cycle checks are O(1).
migrations.py
m002_menu_tree:
- CREATE TABLE menu_nodes (id, restaurant_id, parent_id,
name, description, sort_order, image_url, depth, path, time)
with indexes on (restaurant_id), (parent_id), (path).
- Backfill depth-0 from categories; depth-1 from subcategories
with path = parent.id || '/' || own.id.
- ALTER menu_items ADD COLUMN node_id; backfill via
COALESCE(subcategory_id, category_id). Index on node_id.
- DROP subcategory_id, category_id; DROP TABLE subcategories,
categories.
models.py
- New MAX_MENU_DEPTH = 3 (zero-indexed; 4 levels total).
- New MenuNodeRow (DB I/O shape) + MenuNode (extends with
children + items for hydrated tree responses; never persisted).
- New CreateMenuNode.
- MenuItem.node_id is Optional (orphans allowed when a parent
is deleted with cascade=False); CreateMenuItem.node_id is
required (newly created items must land somewhere).
- Category / Subcategory / Create* kept temporarily as
transitional shim shapes for the old endpoints; dropped in
commit 3.
crud.py
- New: create/update/get/get_all/move/delete_menu_node and
get_menu_tree. move_menu_node uses single-statement subtree
rewrite (path = new_prefix || SUBSTR(path, old_len + 1)).
Cycle check: new_parent's path must not contain node_id.
Depth check: max descendant depth + delta_depth <= MAX_MENU_DEPTH.
delete_menu_node default cascade=False (block on children/items);
cascade=True detaches items (sets node_id NULL) rather than
hard-deletes, since items carry nostr_event_ids and are
revenue-bearing.
- get_menu_tree fetches nodes + items in two queries and assembles
the tree in O(n+m) Python — no recursive CTEs, identical on
SQLite + Postgres.
- Old create_category / get_categories / create_subcategory etc.
rewritten as thin shims that translate to/from menu_nodes.
Old endpoints keep working.
- delete_restaurant cascade now deletes from menu_nodes
(single statement) instead of categories + subcategories.
views_api.py
- GET /restaurants/{id}/menu temporarily sources from menu_nodes
via the shims; surfaces items only at depth-0 nodes for now
(commit 2 replaces the whole block with a real tree response).
static/js/menu.js + templates/restaurant/menu.html
- Rename category_id -> node_id in the item dialog payload so
POST /menu_items satisfies the new CreateMenuItem schema.
The CMS still renders against the depth-0 'categories'
projection; full q-tree rewrite lands in commit 4.
This commit is contained in:
parent
c2ea0297f9
commit
6272df1288
6 changed files with 516 additions and 63 deletions
125
migrations.py
125
migrations.py
|
|
@ -331,3 +331,128 @@ async def m001_initial(db):
|
|||
ON CONFLICT (id) DO NOTHING;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m002_menu_tree(db):
|
||||
"""
|
||||
Replace the fixed `categories` + `subcategories` two-level model
|
||||
with a single self-referential `menu_nodes` table (adjacency list
|
||||
+ denormalized materialized path).
|
||||
|
||||
Why adjacency + path (not closure table, not Postgres ltree):
|
||||
|
||||
* Scale: 5–50 nodes per restaurant, depth ≤ 4. Closure table
|
||||
is overhead at this size.
|
||||
* Backend portability: works identically on SQLite + Postgres
|
||||
with no extensions. ltree is Postgres-only.
|
||||
* `path` ('rootid' or 'rootid/childid' / ...) gives O(1)
|
||||
subtree queries (`WHERE path LIKE :p || '%'`), trivial
|
||||
cycle detection on move, and a single-statement subtree
|
||||
rewrite (substring + concat).
|
||||
* `depth` is denormalized so we can reject "would exceed 4"
|
||||
without walking the tree.
|
||||
|
||||
Items can attach to ANY node (not just leaves). On node delete,
|
||||
the default cascade detaches items (sets node_id NULL) rather
|
||||
than hard-deleting them; items are revenue-bearing and carry
|
||||
nostr_event_ids, so orphaning them so the operator can re-home
|
||||
via the CMS is friendlier than wiping.
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# New menu_nodes table #
|
||||
# ---------------------------------------------------------------- #
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE restaurant.menu_nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
restaurant_id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
image_url TEXT,
|
||||
depth INTEGER NOT NULL DEFAULT 0,
|
||||
path TEXT NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"CREATE INDEX restaurant.idx_menu_nodes_restaurant "
|
||||
"ON menu_nodes(restaurant_id);"
|
||||
)
|
||||
await db.execute(
|
||||
"CREATE INDEX restaurant.idx_menu_nodes_parent "
|
||||
"ON menu_nodes(parent_id);"
|
||||
)
|
||||
await db.execute(
|
||||
"CREATE INDEX restaurant.idx_menu_nodes_path "
|
||||
"ON menu_nodes(path);"
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# Backfill: top-level (depth 0) from categories #
|
||||
# ---------------------------------------------------------------- #
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO restaurant.menu_nodes
|
||||
(id, restaurant_id, parent_id, name, description, sort_order,
|
||||
image_url, depth, path, time)
|
||||
SELECT id, restaurant_id, NULL, name, description, sort_order,
|
||||
image_url, 0, id, time
|
||||
FROM restaurant.categories;
|
||||
"""
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# Backfill: depth-1 from subcategories #
|
||||
# ---------------------------------------------------------------- #
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO restaurant.menu_nodes
|
||||
(id, restaurant_id, parent_id, name, description, sort_order,
|
||||
image_url, depth, path, time)
|
||||
SELECT s.id, c.restaurant_id, s.category_id, s.name, NULL,
|
||||
s.sort_order, NULL, 1, c.id || '/' || s.id, s.time
|
||||
FROM restaurant.subcategories s
|
||||
JOIN restaurant.categories c ON c.id = s.category_id;
|
||||
"""
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# Add menu_items.node_id and backfill #
|
||||
# subcategory wins if both set #
|
||||
# ---------------------------------------------------------------- #
|
||||
await db.execute(
|
||||
"ALTER TABLE restaurant.menu_items ADD COLUMN node_id TEXT;"
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE restaurant.menu_items
|
||||
SET node_id = COALESCE(subcategory_id, category_id);
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"CREATE INDEX restaurant.idx_menu_items_node "
|
||||
"ON menu_items(node_id);"
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# Drop old columns + tables #
|
||||
# #
|
||||
# `ALTER TABLE ... DROP COLUMN` requires SQLite ≥ 3.35 (2021-03). #
|
||||
# LNbits's pinned dependencies are on a modern SQLite, but if a #
|
||||
# downstream user is on something older the column drops will #
|
||||
# fail loudly and they'll need to upgrade SQLite — preferable to #
|
||||
# the table-rebuild dance which has more failure modes. #
|
||||
# ---------------------------------------------------------------- #
|
||||
await db.execute(
|
||||
"ALTER TABLE restaurant.menu_items DROP COLUMN subcategory_id;"
|
||||
)
|
||||
await db.execute(
|
||||
"ALTER TABLE restaurant.menu_items DROP COLUMN category_id;"
|
||||
)
|
||||
await db.execute("DROP INDEX restaurant.idx_menu_items_category;")
|
||||
await db.execute("DROP TABLE restaurant.subcategories;")
|
||||
await db.execute("DROP TABLE restaurant.categories;")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue