feat(t-paliad-183): mig 084 — backfill condition_expr per design §2.4
Phase 3 Slice 2 Step B-3. Convert condition_flag text[] →
condition_expr jsonb per DESIGN §2.4 long form (NOT msg 1746's
short {"and":[...]} form — head clarified in msg 1750 that
design §2.4 wins because long form parses uniformly across
and/or/not, matching what the Slice-4 calculator + Slice-11 rule
editor will emit).
Mapping:
['with_ccr'] → {"flag":"with_ccr"} (5 rows)
['with_amend'] → {"flag":"with_amend"} (4 rows)
['with_cci'] → {"flag":"with_cci"} (4 rows)
['with_ccr', 'with_amend'] → {"op":"and","args":[
{"flag":"with_ccr"},
{"flag":"with_amend"}
]} (4 rows)
NULL or {} → NULL (155 rows)
Total translated: 17 rows.
Single-flag is unwrapped (no AND wrapper) per design §2.4 — a
shortcut equivalent to a 1-arg AND that saves a layer of nesting
without losing semantics. The calculator's parser treats
{"flag":"<name>"} as the leaf and {"op":"<and|or|not>","args":[…]}
as the canonical boolean node.
jsonb construction uses jsonb_build_object + a LATERAL unnest…WITH
ORDINALITY over the flag array so args[] order matches the source
array exactly (load-bearing if a future migration adds order-
sensitive ops).
Idempotent via WHERE condition_expr IS NULL — re-running doesn't
double-write audit rows for already-translated rules. Migration
ends with a DO block that RAISE EXCEPTION if any non-empty
condition_flag row still has NULL condition_expr (catches a
broken translation path before it reaches Slice 4).
This commit is contained in:
14
internal/db/migrations/084_backfill_condition_expr.down.sql
Normal file
14
internal/db/migrations/084_backfill_condition_expr.down.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- t-paliad-183 down — reverts the condition_expr translations written
|
||||
-- by 084_backfill_condition_expr.up.sql. Mig 078 created the column
|
||||
-- with NULL on every row; resetting non-NULL values to NULL undoes the
|
||||
-- backfill cleanly (condition_flag is the source of truth for the
|
||||
-- legacy code path and stays untouched).
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 084: reset condition_expr to mig 078 default (NULL)',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET condition_expr = NULL
|
||||
WHERE condition_expr IS NOT NULL;
|
||||
111
internal/db/migrations/084_backfill_condition_expr.up.sql
Normal file
111
internal/db/migrations/084_backfill_condition_expr.up.sql
Normal file
@@ -0,0 +1,111 @@
|
||||
-- t-paliad-183 / Fristen Phase 3 Slice 2 Step B-3 — backfill
|
||||
-- paliad.deadline_rules.condition_expr from the legacy
|
||||
-- condition_flag text[] column per DESIGN §2.4 long form (NOT the
|
||||
-- short {"and":[...]} form sketched in head's msg 1746 — head's
|
||||
-- clarification msg 1750 rules in favour of the design doc).
|
||||
--
|
||||
-- Mapping (design §2.4):
|
||||
--
|
||||
-- condition_flag IS NULL OR array_length(_, 1) = 0
|
||||
-- → condition_expr stays NULL (unconditional, every rule renders)
|
||||
--
|
||||
-- array_length = 1, e.g. ['with_ccr']
|
||||
-- → condition_expr = jsonb '{"flag": "with_ccr"}'
|
||||
-- (single flag unwrapped — saves a layer of nesting that
|
||||
-- parses as the same boolean expression)
|
||||
--
|
||||
-- array_length >= 2, e.g. ['with_ccr', 'with_amend']
|
||||
-- → condition_expr = jsonb '{"op":"and","args":[
|
||||
-- {"flag":"with_ccr"},
|
||||
-- {"flag":"with_amend"}
|
||||
-- ]}'
|
||||
-- (long form — same shape the rule editor will emit for OR /
|
||||
-- NOT in future rules so the calculator's parser is uniform)
|
||||
--
|
||||
-- Why long form on >=2: the calculator (Slice 4) reads
|
||||
-- {"op":"<and|or|not>","args":[...]} as the canonical boolean node and
|
||||
-- {"flag":"<name>"} as the leaf. Single-flag unwrap is a parse-time
|
||||
-- shortcut equivalent to a 1-arg AND. The short {"and":[...]} form in
|
||||
-- msg 1746 would require a per-key parser that doesn't generalise to
|
||||
-- OR / NOT. Design §2.4 long form is the load-bearing decision.
|
||||
--
|
||||
-- Live-data expected delta (172 rules total):
|
||||
--
|
||||
-- ['with_ccr'] × 5 rows → {"flag":"with_ccr"}
|
||||
-- ['with_amend'] × 4 rows → {"flag":"with_amend"}
|
||||
-- ['with_cci'] × 4 rows → {"flag":"with_cci"}
|
||||
-- ['with_ccr', 'with_amend'] × 4 rows → {"op":"and","args":[
|
||||
-- {"flag":"with_ccr"},
|
||||
-- {"flag":"with_amend"}
|
||||
-- ]}
|
||||
-- NULL or {} × 155 rows → stays NULL
|
||||
--
|
||||
-- Total touched: 17 rows.
|
||||
--
|
||||
-- Idempotent: WHERE condition_expr IS NULL guards re-runs against
|
||||
-- double-writing audit rows for already-translated rules.
|
||||
--
|
||||
-- jsonb construction: jsonb_build_object + jsonb_agg + a CASE on
|
||||
-- array_length keeps the long-form / unwrapped-flag split inline in
|
||||
-- one UPDATE. Per-flag jsonb leaf is built by a LATERAL unnest over
|
||||
-- the flag array so the args[] order matches the source array.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'backfill 084: condition_expr from condition_flag text[] per design §2.4 — '
|
||||
|| 'single flag unwrapped, multi flag long form {op:and, args:[...]}',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET condition_expr = sub.expr
|
||||
FROM (
|
||||
SELECT dr_inner.id AS rule_id,
|
||||
CASE
|
||||
-- Single flag: unwrapped leaf.
|
||||
WHEN array_length(dr_inner.condition_flag, 1) = 1
|
||||
THEN jsonb_build_object('flag', dr_inner.condition_flag[1])
|
||||
|
||||
-- >=2 flags: long-form AND with args[] preserving order.
|
||||
WHEN array_length(dr_inner.condition_flag, 1) >= 2
|
||||
THEN jsonb_build_object(
|
||||
'op', 'and',
|
||||
'args', (
|
||||
SELECT jsonb_agg(jsonb_build_object('flag', f) ORDER BY ord)
|
||||
FROM unnest(dr_inner.condition_flag) WITH ORDINALITY AS u(f, ord)
|
||||
)
|
||||
)
|
||||
|
||||
-- Empty array (array_length=0) or NULL: leave NULL.
|
||||
ELSE NULL
|
||||
END AS expr
|
||||
FROM paliad.deadline_rules dr_inner
|
||||
WHERE dr_inner.condition_flag IS NOT NULL
|
||||
AND array_length(dr_inner.condition_flag, 1) > 0
|
||||
) AS sub
|
||||
WHERE dr.id = sub.rule_id
|
||||
AND dr.condition_expr IS NULL;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_total int;
|
||||
n_with_flag int;
|
||||
n_with_expr int;
|
||||
n_with_both int;
|
||||
BEGIN
|
||||
SELECT count(*),
|
||||
count(*) FILTER (WHERE condition_flag IS NOT NULL AND array_length(condition_flag, 1) > 0),
|
||||
count(*) FILTER (WHERE condition_expr IS NOT NULL),
|
||||
count(*) FILTER (WHERE condition_flag IS NOT NULL AND array_length(condition_flag, 1) > 0
|
||||
AND condition_expr IS NOT NULL)
|
||||
INTO n_total, n_with_flag, n_with_expr, n_with_both
|
||||
FROM paliad.deadline_rules;
|
||||
RAISE NOTICE 'backfill 084: total=%, with_condition_flag=%, with_condition_expr=%, both=%',
|
||||
n_total, n_with_flag, n_with_expr, n_with_both;
|
||||
-- Hard assertion: every rule with a non-empty condition_flag now
|
||||
-- has a non-NULL condition_expr (the inverse of the legacy column).
|
||||
IF n_with_flag <> n_with_both THEN
|
||||
RAISE EXCEPTION 'backfill 084: % rules carry condition_flag but no condition_expr — '
|
||||
'translation incomplete',
|
||||
n_with_flag - n_with_both;
|
||||
END IF;
|
||||
END $$;
|
||||
Reference in New Issue
Block a user