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": "" } // | { "op": "and"|"or", "args": [, , ...] } // // 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() }