Merge: t-paliad-331 P2 + P4 partial — condition_expr validator + trigger_events partial deprecation (m/paliad#149)
ritchie shipped the final two slices of the Phase 2 train.
P2 — condition_expr write-validator:
- New internal/services/condition_expr_validator.go (136 LoC) — locks the grammar to {flag:<str>} OR {op:'and'|'or', args:[<leaf>|<composite>]} per design §4.1
- RuleEditorService.Create + Update reject non-conforming expressions
- 166-LoC test coverage; all 18 existing condition_expr rows validate
P4 (partial) — trigger_events deprecation (mig 156):
- NULLs out the 2 hybrid rules' trigger_event_id (parent_id is the canonical edge per §2.1)
- Adds 'Deprecated: see m/paliad#149' header on the legacy /api/tools/event-deadlines route
- Does NOT drop paliad.trigger_events nor the 5 read sites — those are gated on the editorial reparenting of the 73 orphan globals (NULL proceeding_type_id, served only via trigger_event_id). Editorial work is m's, not coder scope.
Comment on m/paliad#149 (issuecomment-10436) enumerates the exact next steps for the eventual follow-up coder once editorial reparenting completes.
Phase 2 train: P0 + S1+S1a + P1 + P3 + P2 + P4 partial — ALL shipped. Final P4 step waits on editorial.
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
-- 156_trigger_event_id_partial_deprecation.down — t-paliad-331 / m/paliad#149
|
||||
|
||||
BEGIN;
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 156 down: restore trigger_event_id on the 2 hybrid rules',
|
||||
true
|
||||
);
|
||||
|
||||
-- Restore the trigger_event_id values from the same-TX snapshot.
|
||||
UPDATE paliad.sequencing_rules sr
|
||||
SET trigger_event_id = pre.trigger_event_id
|
||||
FROM paliad.sequencing_rules_pre_156 pre
|
||||
WHERE sr.id = pre.id
|
||||
AND sr.trigger_event_id IS NULL
|
||||
AND pre.trigger_event_id IS NOT NULL;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.sequencing_rules_pre_156;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,80 @@
|
||||
-- 156_trigger_event_id_partial_deprecation — t-paliad-331 / m/paliad#149 Phase 2 P4 (partial)
|
||||
--
|
||||
-- Partial deprecation step toward retiring paliad.trigger_events.
|
||||
-- The full table-drop (and the route + service + 5 read-site removals
|
||||
-- the design's §3.4 + §4.3 lay out) is gated on the editorial backfill
|
||||
-- of the 73 orphan globals — sequencing_rules rows that carry
|
||||
-- trigger_event_id NOT NULL AND proceeding_type_id IS NULL today. m
|
||||
-- drives that walk via /admin/procedural-events at his cadence (no
|
||||
-- coder time blocked); this mig prepares the way without breaking the
|
||||
-- legacy route the orphans still depend on.
|
||||
--
|
||||
-- What this mig does (live-DB audited 2026-05-27 pre-flight):
|
||||
--
|
||||
-- 1. NULL out the 2 hybrid rules that carry BOTH parent_id AND
|
||||
-- trigger_event_id. Per design §2.1 / m's Q1: parent_id is the
|
||||
-- canonical predecessor link; trigger_event_id on those 2 rows is
|
||||
-- redundant. The parent_id chain keeps the live edge — no data
|
||||
-- loss, no route disruption (the route only reads trigger_event_id
|
||||
-- for the 73 orphan globals, which have no parent_id).
|
||||
--
|
||||
-- 2. NOT-DROP the column or the table. Both stay live so the
|
||||
-- /api/tools/event-deadlines route continues to serve the 73
|
||||
-- orphan globals until editorial reparenting lands.
|
||||
--
|
||||
-- The full P4 (mig that DROPs paliad.trigger_events + the
|
||||
-- `sequencing_rules.trigger_event_id` column + the legacy route +
|
||||
-- EventDeadlineService + ExportService::1680 + cmd/gen-upc-snapshot/
|
||||
-- main.go:185-202) lands AFTER the 73 orphans are reparented. Until
|
||||
-- then, the legacy surface remains.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 156: trigger_event_id partial deprecation — NULL out 2 hybrid rules (t-paliad-331 / m/paliad#149 Phase 2 P4 partial)',
|
||||
true
|
||||
);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 1. Snapshot the 2 hybrid rows for audit + rollback.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.sequencing_rules_pre_156 AS
|
||||
SELECT * FROM paliad.sequencing_rules
|
||||
WHERE trigger_event_id IS NOT NULL
|
||||
AND parent_id IS NOT NULL
|
||||
AND is_active = true;
|
||||
|
||||
COMMENT ON TABLE paliad.sequencing_rules_pre_156 IS
|
||||
'Snapshot of the 2 hybrid rules (trigger_event_id NOT NULL AND '
|
||||
'parent_id NOT NULL) taken in the same TX as mig 156, before their '
|
||||
'trigger_event_id is NULL''ed. Rollback aid until P4 final lands.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 2. NULL out trigger_event_id on hybrid rules — parent_id is the
|
||||
-- canonical predecessor link per design §2.1.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.sequencing_rules
|
||||
SET trigger_event_id = NULL
|
||||
WHERE trigger_event_id IS NOT NULL
|
||||
AND parent_id IS NOT NULL
|
||||
AND is_active = true;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
remaining_hybrids int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO remaining_hybrids
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE trigger_event_id IS NOT NULL
|
||||
AND parent_id IS NOT NULL
|
||||
AND is_active = true;
|
||||
IF remaining_hybrids <> 0 THEN
|
||||
RAISE EXCEPTION '[mig 156] expected 0 active hybrid rules, found %', remaining_hybrids;
|
||||
END IF;
|
||||
RAISE NOTICE '[mig 156] hybrid-rule cleanup OK — 0 active rules carry both parent_id and trigger_event_id';
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -253,7 +253,26 @@ func handleTriggerEventsList(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// POST /api/tools/event-deadlines — compute all deadlines flowing from a
|
||||
// trigger event + date. Body: {"triggerEventId": <int>, "triggerDate": "YYYY-MM-DD"}.
|
||||
//
|
||||
// DEPRECATED (m/paliad#149 Phase 2 P4 partial, t-paliad-331). This route
|
||||
// serves the 73 orphan globals (sequencing_rules with proceeding_type_id
|
||||
// IS NULL, addressed only via trigger_event_id). The route is held live
|
||||
// until those 73 are reparented onto real proceeding-type chains via
|
||||
// /admin/procedural-events (editorial work; tracked separately).
|
||||
//
|
||||
// Once the orphan count hits zero, the planned final-P4 lands:
|
||||
// - DROP TABLE paliad.trigger_events
|
||||
// - ALTER TABLE paliad.sequencing_rules DROP COLUMN trigger_event_id
|
||||
// - remove this handler + EventDeadlineService + the 5 read sites
|
||||
// enumerated in the design (deadline_rule_service.go:226,
|
||||
// event_deadline_service.go:79+244, event_type_service.go:40+414,
|
||||
// export_service.go:1680, cmd/gen-upc-snapshot/main.go:185-202).
|
||||
//
|
||||
// The Deprecation + Sunset response headers below let callers see the
|
||||
// signal without breaking — see RFC 8594 / RFC 9745.
|
||||
func handleEventDeadlinesCalculate(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Deprecation", "true")
|
||||
w.Header().Set("Link", `<https://mgit.msbls.de/m/paliad/issues/149>; rel="deprecation"; type="text/html"`)
|
||||
if dbSvc == nil || dbSvc.eventDeadline == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "Fristenrechner ist vorübergehend nicht verfügbar (keine Datenbank).",
|
||||
|
||||
136
internal/services/condition_expr_validator.go
Normal file
136
internal/services/condition_expr_validator.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// condition_expr grammar per design §4.1 (m/paliad#149 Phase 2 P2):
|
||||
//
|
||||
// CondExpr := { "flag": "<known_flag>" }
|
||||
// | { "op": "and"|"or", "args": [<CondExpr>, <CondExpr>, ...] }
|
||||
//
|
||||
// Leaf nodes reference a flag in paliad.scenario_flag_catalog by key.
|
||||
// Composite nodes are recursive — and/or take ≥1 arg each. JSON null
|
||||
// (or empty bytes) is also accepted — that's the "no gate" shape and
|
||||
// stores as a NULL column.
|
||||
//
|
||||
// The validator is called from RuleEditorService.Create and
|
||||
// UpdateDraft before the row is written. Surfaces friendly errors
|
||||
// wrapping ErrInvalidInput so the handler maps cleanly to 400.
|
||||
|
||||
// ValidateConditionExpr parses the bytes as a CondExpr and verifies
|
||||
// every leaf flag is present in the scenario_flag_catalog (one DB
|
||||
// lookup, regardless of expression depth). Empty/null input is OK —
|
||||
// caller stores NULL.
|
||||
func ValidateConditionExpr(ctx context.Context, db *sqlx.DB, raw json.RawMessage) error {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil
|
||||
}
|
||||
var parsed condExprNode
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return fmt.Errorf("%w: condition_expr is not valid JSON: %v", ErrInvalidInput, err)
|
||||
}
|
||||
flagNames := map[string]struct{}{}
|
||||
if err := walkCondExpr(&parsed, flagNames); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(flagNames) == 0 {
|
||||
// Empty leaf set is impossible for a valid CondExpr — walkCondExpr
|
||||
// would have rejected it. Defensive belt-and-braces.
|
||||
return fmt.Errorf("%w: condition_expr resolved to zero leaf flags", ErrInvalidInput)
|
||||
}
|
||||
keys := make([]string, 0, len(flagNames))
|
||||
for k := range flagNames {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
known, err := loadCatalogFlagKeys(ctx, db, keys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range keys {
|
||||
if _, ok := known[k]; !ok {
|
||||
return fmt.Errorf("%w: condition_expr references unknown flag %q (not in paliad.scenario_flag_catalog)", ErrInvalidInput, k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// condExprNode is the loose-typed parse target. Either Flag is set
|
||||
// (leaf) or Op + Args (composite); the validator below enforces
|
||||
// mutual exclusivity.
|
||||
type condExprNode struct {
|
||||
Flag *string `json:"flag,omitempty"`
|
||||
Op *string `json:"op,omitempty"`
|
||||
Args []condExprNode `json:"args,omitempty"`
|
||||
// Extra catches stray keys so we can reject typos like "fla" or
|
||||
// "operator" loudly instead of silently treating them as composite.
|
||||
Extra map[string]json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
// walkCondExpr descends the tree, collecting flag names and validating
|
||||
// every node's shape.
|
||||
func walkCondExpr(n *condExprNode, flagNames map[string]struct{}) error {
|
||||
hasFlag := n.Flag != nil
|
||||
hasOp := n.Op != nil
|
||||
hasArgs := n.Args != nil
|
||||
|
||||
if hasFlag && (hasOp || hasArgs) {
|
||||
return fmt.Errorf("%w: condition_expr node has both 'flag' and 'op'/'args' — leaf and composite shapes are mutually exclusive", ErrInvalidInput)
|
||||
}
|
||||
if !hasFlag && !hasOp {
|
||||
return fmt.Errorf("%w: condition_expr node must carry either 'flag' (leaf) or 'op'+'args' (composite)", ErrInvalidInput)
|
||||
}
|
||||
|
||||
if hasFlag {
|
||||
if *n.Flag == "" {
|
||||
return fmt.Errorf("%w: condition_expr leaf has empty flag", ErrInvalidInput)
|
||||
}
|
||||
flagNames[*n.Flag] = struct{}{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Composite — op must be "and" or "or"; args must be non-empty.
|
||||
op := *n.Op
|
||||
if op != "and" && op != "or" {
|
||||
return fmt.Errorf("%w: condition_expr op=%q must be 'and' or 'or'", ErrInvalidInput, op)
|
||||
}
|
||||
if len(n.Args) == 0 {
|
||||
return fmt.Errorf("%w: condition_expr composite op=%q has empty args", ErrInvalidInput, op)
|
||||
}
|
||||
for i := range n.Args {
|
||||
if err := walkCondExpr(&n.Args[i], flagNames); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadCatalogFlagKeys returns the subset of `flagKeys` present in
|
||||
// paliad.scenario_flag_catalog. One round-trip regardless of how many
|
||||
// keys the expression carries.
|
||||
func loadCatalogFlagKeys(ctx context.Context, db *sqlx.DB, flagKeys []string) (map[string]struct{}, error) {
|
||||
if len(flagKeys) == 0 {
|
||||
return map[string]struct{}{}, nil
|
||||
}
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT flag_key FROM paliad.scenario_flag_catalog WHERE flag_key = ANY($1)`,
|
||||
pq.Array(flagKeys))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup scenario_flag_catalog: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]struct{}{}
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = struct{}{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
166
internal/services/condition_expr_validator_test.go
Normal file
166
internal/services/condition_expr_validator_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// openTestPool returns a sqlx.DB connected via TEST_DATABASE_URL.
|
||||
// Returns nil + skips the test when the env var is unset, mirroring
|
||||
// the pattern used by sibling live-DB tests in this package.
|
||||
func openTestPool(t *testing.T) *sqlx.DB {
|
||||
t.Helper()
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
// TestValidateConditionExprShapes covers the grammar shapes (leaf,
|
||||
// composite, nested composite) and the rejection paths. The catalog
|
||||
// lookup is exercised via the live DB in TestValidateConditionExpr_Live18
|
||||
// below; here we use json-only shape checks to keep the unit tests
|
||||
// independent of database availability.
|
||||
func TestValidateConditionExprShapes(t *testing.T) {
|
||||
// Bypass the DB-backed flag-existence check by passing nil db with
|
||||
// an expression that has no leaves once unmarshalled. Since the
|
||||
// grammar walker rejects empty/invalid shapes BEFORE the DB lookup,
|
||||
// shape-only assertions work without a pool. For the leaf-flag
|
||||
// existence check we'd need a fixture DB — that's the live test.
|
||||
ctx := context.Background()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantError string // empty = success-path placeholder
|
||||
wantInvalid bool
|
||||
}{
|
||||
{name: "empty input", input: ``, wantInvalid: false},
|
||||
{name: "JSON null", input: `null`, wantInvalid: false},
|
||||
{name: "bad JSON", input: `{flag:`, wantInvalid: true, wantError: "valid JSON"},
|
||||
{name: "leaf with empty flag", input: `{"flag":""}`, wantInvalid: true, wantError: "empty flag"},
|
||||
{name: "leaf AND op", input: `{"flag":"x","op":"and"}`, wantInvalid: true, wantError: "mutually exclusive"},
|
||||
{name: "neither flag nor op", input: `{}`, wantInvalid: true, wantError: "must carry either"},
|
||||
{name: "bad op", input: `{"op":"xor","args":[{"flag":"x"}]}`, wantInvalid: true, wantError: "must be 'and' or 'or'"},
|
||||
{name: "empty args", input: `{"op":"and","args":[]}`, wantInvalid: true, wantError: "empty args"},
|
||||
{name: "nested bad shape", input: `{"op":"and","args":[{"flag":"x"},{"flag":""}]}`, wantInvalid: true, wantError: "empty flag"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := ValidateConditionExpr(ctx, nil, json.RawMessage(c.input))
|
||||
if c.wantInvalid {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("error %v is not ErrInvalidInput", err)
|
||||
}
|
||||
if c.wantError != "" && !strings.Contains(err.Error(), c.wantError) {
|
||||
t.Errorf("error %q missing substring %q", err.Error(), c.wantError)
|
||||
}
|
||||
return
|
||||
}
|
||||
// success-path: empty/null inputs go through without an err.
|
||||
// Anything else hits the DB lookup with nil pool → nil-deref;
|
||||
// that path is covered by the live test below.
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for %q, got %v", c.input, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateConditionExpr_LiveCatalog runs the validator against the
|
||||
// real paliad.scenario_flag_catalog (the 3 seeded flags) using a sample
|
||||
// of each grammar shape. Skips when DATABASE_URL isn't set.
|
||||
func TestValidateConditionExpr_LiveCatalog(t *testing.T) {
|
||||
pool := openTestPool(t)
|
||||
if pool == nil {
|
||||
t.Skip("DATABASE_URL not set — skipping live-catalog validation")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
good := []string{
|
||||
`{"flag":"with_ccr"}`,
|
||||
`{"flag":"with_amend"}`,
|
||||
`{"flag":"with_cci"}`,
|
||||
`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
|
||||
`{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_cci"}]}`,
|
||||
`{"op":"and","args":[{"flag":"with_ccr"},{"op":"or","args":[{"flag":"with_amend"},{"flag":"with_cci"}]}]}`,
|
||||
}
|
||||
for _, g := range good {
|
||||
if err := ValidateConditionExpr(ctx, pool, json.RawMessage(g)); err != nil {
|
||||
t.Errorf("expected %q to validate, got %v", g, err)
|
||||
}
|
||||
}
|
||||
|
||||
bad := []struct{ in, contains string }{
|
||||
{`{"flag":"with_nonsense"}`, "unknown flag"},
|
||||
{`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"never_seen"}]}`, "unknown flag"},
|
||||
}
|
||||
for _, b := range bad {
|
||||
err := ValidateConditionExpr(ctx, pool, json.RawMessage(b.in))
|
||||
if err == nil {
|
||||
t.Errorf("expected %q to fail validation", b.in)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), b.contains) {
|
||||
t.Errorf("error %q for %q missing substring %q", err.Error(), b.in, b.contains)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConditionExpr_AllLiveRowsValidate exercises the validator on every
|
||||
// row currently in paliad.sequencing_rules. Per design §4.1: "all 18
|
||||
// existing rows must validate" — this test enforces the invariant on
|
||||
// every deploy so a new editorial entry that breaks the grammar fails
|
||||
// CI before it lands.
|
||||
func TestConditionExpr_AllLiveRowsValidate(t *testing.T) {
|
||||
pool := openTestPool(t)
|
||||
if pool == nil {
|
||||
t.Skip("DATABASE_URL not set — skipping live-rows test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
rows, err := pool.QueryContext(ctx,
|
||||
`SELECT id, condition_expr::text
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE condition_expr IS NOT NULL
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'`)
|
||||
if err != nil {
|
||||
t.Fatalf("load condition_expr rows: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var id, expr string
|
||||
if err := rows.Scan(&id, &expr); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
count++
|
||||
if err := ValidateConditionExpr(ctx, pool, json.RawMessage(expr)); err != nil {
|
||||
t.Errorf("rule %s carries non-conforming condition_expr %s: %v", id, expr, err)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("rows err: %v", err)
|
||||
}
|
||||
if count == 0 {
|
||||
t.Skip("no condition_expr rows in DB — nothing to validate")
|
||||
}
|
||||
t.Logf("validated %d live condition_expr rows", count)
|
||||
}
|
||||
@@ -213,6 +213,15 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// m/paliad#149 Phase 2 P2 (design §4.1) — lock the condition_expr
|
||||
// grammar to leaf {flag} or composite {op:'and'|'or', args:[…]}.
|
||||
// Surfaces an ErrInvalidInput before the row hits the DB so the
|
||||
// rule editor gets a friendly 400 instead of relying on a future
|
||||
// jsonb CHECK constraint that would land as a generic 500.
|
||||
if err := ValidateConditionExpr(ctx, s.db, input.ConditionExpr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
@@ -310,6 +319,15 @@ func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch
|
||||
}
|
||||
}
|
||||
|
||||
// m/paliad#149 Phase 2 P2 (design §4.1) — validate condition_expr
|
||||
// patches. Nil patch field = "don't change" (no validation needed);
|
||||
// non-nil = the new value must match the grammar.
|
||||
if patch.ConditionExpr != nil {
|
||||
if err := ValidateConditionExpr(ctx, s.db, patch.ConditionExpr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
|
||||
Reference in New Issue
Block a user