@consiliency/agent-board-schema 1.51.0 → 1.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/already-applied.json +4 -0
- package/migrations/20261003000100_agent_board_room_roster.sql +115 -0
- package/package.json +1 -1
- package/schema-manifest.json +12 -3
- package/src/tooling.test.mjs +4 -2
- package/tests/agent_board_agent_liveness.test.sql +3 -1
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +33 -36
- package/tests/agent_board_authbound_boundary.test.sql +4 -3
- package/tests/agent_board_board_claim_policy.test.sql +17 -6
- package/tests/agent_board_claim_subject_gate.test.sql +7 -8
- package/tests/agent_board_claimkey.test.sql +9 -6
- package/tests/agent_board_claimpolicy_race.test.sql +1466 -342
- package/tests/agent_board_consent_emission.test.sql +5 -4
- package/tests/agent_board_consent_gating.test.sql +20 -9
- package/tests/agent_board_enroll_resumption.test.sql +1 -1
- package/tests/agent_board_enrollment_audit_content.test.sql +5 -4
- package/tests/agent_board_federated_identity_injective_slug.test.sql +3 -1
- package/tests/agent_board_federated_lock_order_class.test.sql +69 -52
- package/tests/agent_board_fedsession.test.sql +3 -1
- package/tests/agent_board_fedsession_provider_absence_gate.test.sql +9 -4
- package/tests/agent_board_fedsession_stored_identity_gate.test.sql +18 -6
- package/tests/agent_board_fk_lock_edges.test.sql +64 -5
- package/tests/agent_board_fleet_domain_reclaim.test.sql +2 -4
- package/tests/agent_board_fleet_machine_hijack.test.sql +2 -4
- package/tests/agent_board_fleet_machine_reactivation.test.sql +5 -1
- package/tests/agent_board_fleet_machine_reclaim.test.sql +2 -4
- package/tests/agent_board_invite_no_downgrade.test.sql +24 -18
- package/tests/agent_board_ledger_authority.test.sql +29 -21
- package/tests/agent_board_mint_harden.test.sql +5 -5
- package/tests/agent_board_outbox_wake_ordering.test.sql +4 -3
- package/tests/agent_board_ownerless_renew_quota.test.sql +7 -2
- package/tests/agent_board_portal_offline_invariant.test.sql +5 -4
- package/tests/agent_board_receipt_subject_gate.test.sql +7 -8
- package/tests/agent_board_rolereset.test.sql +5 -3
- package/tests/agent_board_room_roster.test.sql +466 -0
- package/tests/agent_board_rowid.test.sql +39 -19
- package/tests/agent_board_signer_lifecycle.test.sql +24 -13
- package/tests/agent_board_source_strip_helper.test.sql +2 -1
- package/tests/agent_board_task_event_origin_posture.test.sql +21 -9
- package/tests/agent_board_wake_recovery.test.sql +53 -26
- package/tests/agent_board_write_enforcement_class.test.sql +130 -12
- package/tests/catalog/function_execute_privileges.json +1932 -0
- package/tests/catalog/p_metadata_parameters.json +4 -0
- package/tests/fixtures/test-prereqs.sql +644 -237
|
@@ -658,211 +658,12 @@ AS $$
|
|
|
658
658
|
END;
|
|
659
659
|
$$;
|
|
660
660
|
|
|
661
|
-
-- One row per (function, relation, acquisition). `own` distinguishes an
|
|
662
|
-
-- acquisition written in the function's own body from one reached through a
|
|
663
|
-
-- callee; `origin_name` names the function the acquisition actually lives in.
|
|
664
|
-
CREATE OR REPLACE FUNCTION agent_board_test.lock_edges()
|
|
665
|
-
RETURNS TABLE (
|
|
666
|
-
fn_oid oid,
|
|
667
|
-
fn_name text,
|
|
668
|
-
fn_sig text,
|
|
669
|
-
rel_oid oid,
|
|
670
|
-
rel_name text,
|
|
671
|
-
lock_mode text,
|
|
672
|
-
via text,
|
|
673
|
-
path int[],
|
|
674
|
-
own boolean,
|
|
675
|
-
origin_oid oid,
|
|
676
|
-
origin_name text
|
|
677
|
-
)
|
|
678
|
-
LANGUAGE sql STABLE
|
|
679
|
-
SET search_path = pg_catalog
|
|
680
|
-
AS $lockedges$
|
|
681
|
-
WITH RECURSIVE
|
|
682
|
-
fns AS (
|
|
683
|
-
SELECT p.oid,
|
|
684
|
-
p.proname,
|
|
685
|
-
p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')' AS sig,
|
|
686
|
-
agent_board_test.stripped_body(p.oid) AS body,
|
|
687
|
-
p.proconfig
|
|
688
|
-
FROM pg_proc p
|
|
689
|
-
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
690
|
-
WHERE n.nspname = 'agent_board'
|
|
691
|
-
AND p.prokind IN ('f', 'p')
|
|
692
|
-
AND p.prolang <> (SELECT oid FROM pg_language WHERE lanname = 'internal')
|
|
693
|
-
),
|
|
694
|
-
-- Resolve BARE names the way PostgreSQL would. A function pinning search_path
|
|
695
|
-
-- WITHOUT agent_board cannot reach these tables unqualified, so bare matches are
|
|
696
|
-
-- not attributed to it. No search_path setting at all is treated as bare-resolving,
|
|
697
|
-
-- which over-approximates membership rather than under-approximating it.
|
|
698
|
-
q AS (
|
|
699
|
-
SELECT f.oid, f.proname, f.sig, f.body,
|
|
700
|
-
CASE WHEN NOT EXISTS (SELECT 1 FROM unnest(coalesce(f.proconfig, '{}')) c
|
|
701
|
-
WHERE c LIKE 'search\_path=%')
|
|
702
|
-
OR EXISTS (SELECT 1 FROM unnest(f.proconfig) c
|
|
703
|
-
WHERE c LIKE 'search\_path=%'
|
|
704
|
-
AND ',' || replace(replace(split_part(c, '=', 2), ' ', ''), '"', '') || ','
|
|
705
|
-
LIKE '%,agent\_board,%')
|
|
706
|
-
THEN '(?:agent_board\.)?' ELSE 'agent_board\.' END AS q
|
|
707
|
-
FROM fns f
|
|
708
|
-
),
|
|
709
|
-
rels AS (
|
|
710
|
-
SELECT c.oid AS rel_oid, c.relname, n.nspname
|
|
711
|
-
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
712
|
-
WHERE c.relkind IN ('r', 'p')
|
|
713
|
-
AND (n.nspname = 'agent_board' OR (n.nspname = 'auth' AND c.relname = 'users'))
|
|
714
|
-
),
|
|
715
|
-
-- Prefilter: a function can only touch a relation whose name its body contains.
|
|
716
|
-
cand AS (
|
|
717
|
-
SELECT f.oid AS fn_oid, f.body, f.q, r.rel_oid, r.relname,
|
|
718
|
-
CASE WHEN r.nspname = 'agent_board' THEN f.q ELSE r.nspname || '\.' END AS rq
|
|
719
|
-
FROM q f CROSS JOIN rels r
|
|
720
|
-
WHERE f.body ~ ('\m' || r.relname || '\M')
|
|
721
|
-
),
|
|
722
|
-
lit AS (
|
|
723
|
-
SELECT c.fn_oid, c.body, c.q, c.rel_oid, c.relname, c.rq,
|
|
724
|
-
substring(c.body FROM
|
|
725
|
-
'(?:insert\s+into\s+(?:only\s+)?' || c.rq || '\m' || c.relname || '\M)') AS ins_hit,
|
|
726
|
-
substring(c.body FROM
|
|
727
|
-
'(?:update\s+(?:only\s+)?' || c.rq || '\m' || c.relname || '\M[^;]{0,250}?\mset\M)') AS upd_hit,
|
|
728
|
-
substring(c.body FROM
|
|
729
|
-
'(?:delete\s+from\s+(?:only\s+)?' || c.rq || '\m' || c.relname || '\M)') AS del_hit,
|
|
730
|
-
substring(c.body FROM
|
|
731
|
-
'(?:' || c.rq || '\m' || c.relname
|
|
732
|
-
|| '\M[^;]{0,250}?for\s+(?:update|no\s+key\s+update|share|key\s+share))') AS lock_hit
|
|
733
|
-
FROM cand c
|
|
734
|
-
),
|
|
735
|
-
-- FK columns by NAME, from pg_attribute over conkey. Shape (v) is gated on the
|
|
736
|
-
-- SET naming one of these; hand-listing them, or skipping the gate, is what
|
|
737
|
-
-- turns this detector into a cycle-fabricator.
|
|
738
|
-
fkcols AS (
|
|
739
|
-
SELECT k.conname, k.conrelid, k.confrelid, a.attname
|
|
740
|
-
FROM pg_constraint k
|
|
741
|
-
CROSS JOIN LATERAL unnest(k.conkey) AS ck(attnum)
|
|
742
|
-
JOIN pg_attribute a ON a.attrelid = k.conrelid AND a.attnum = ck.attnum
|
|
743
|
-
WHERE k.contype = 'f'
|
|
744
|
-
),
|
|
745
|
-
upd AS (
|
|
746
|
-
SELECT l.fn_oid, fc.conname, fc.confrelid,
|
|
747
|
-
min(strpos(l.body, substring(l.body FROM
|
|
748
|
-
'(?:update\s+(?:only\s+)?' || l.rq || '\m' || l.relname
|
|
749
|
-
|| '\M[^;]{0,250}?\mset\M[^;]{0,250}?\m' || fc.attname || '\M\s*=)'))) AS pos
|
|
750
|
-
FROM lit l
|
|
751
|
-
JOIN fkcols fc ON fc.conrelid = l.rel_oid
|
|
752
|
-
WHERE l.body ~ ('(?:update\s+(?:only\s+)?' || l.rq || '\m' || l.relname
|
|
753
|
-
|| '\M[^;]{0,250}?\mset\M[^;]{0,250}?\m' || fc.attname || '\M\s*=)')
|
|
754
|
-
GROUP BY l.fn_oid, fc.conname, fc.confrelid
|
|
755
|
-
),
|
|
756
|
-
direct AS (
|
|
757
|
-
-- (i) an explicit row lock written in the body
|
|
758
|
-
SELECT l.fn_oid, l.rel_oid,
|
|
759
|
-
regexp_replace(substring(l.lock_hit FROM
|
|
760
|
-
'for\s+(update|no\s+key\s+update|share|key\s+share)'), '\s+', ' ', 'g') AS lock_mode,
|
|
761
|
-
'textual' AS via,
|
|
762
|
-
strpos(l.body, l.lock_hit) AS pos
|
|
763
|
-
FROM lit l WHERE l.lock_hit IS NOT NULL
|
|
764
|
-
UNION ALL
|
|
765
|
-
-- (ii) UPDATE row-locks the updated rows
|
|
766
|
-
SELECT l.fn_oid, l.rel_oid, 'update', 'write-update', strpos(l.body, l.upd_hit)
|
|
767
|
-
FROM lit l WHERE l.upd_hit IS NOT NULL
|
|
768
|
-
UNION ALL
|
|
769
|
-
-- (iii) DELETE row-locks the deleted rows
|
|
770
|
-
SELECT l.fn_oid, l.rel_oid, 'update', 'write-delete', strpos(l.body, l.del_hit)
|
|
771
|
-
FROM lit l WHERE l.del_hit IS NOT NULL
|
|
772
|
-
UNION ALL
|
|
773
|
-
-- (iv) INSERT takes FOR KEY SHARE on every referenced parent row
|
|
774
|
-
SELECT l.fn_oid, k.confrelid, 'key share', 'fk-insert:' || k.conname, strpos(l.body, l.ins_hit)
|
|
775
|
-
FROM lit l
|
|
776
|
-
JOIN pg_constraint k ON k.conrelid = l.rel_oid AND k.contype = 'f'
|
|
777
|
-
WHERE l.ins_hit IS NOT NULL
|
|
778
|
-
UNION ALL
|
|
779
|
-
-- (v) an UPDATE that re-points an FK column locks the NEW parent row
|
|
780
|
-
SELECT u.fn_oid, u.confrelid, 'key share', 'fk-update:' || u.conname, u.pos
|
|
781
|
-
FROM upd u
|
|
782
|
-
UNION ALL
|
|
783
|
-
-- (vi) DELETE of a referenced row locks the referencing child rows
|
|
784
|
-
SELECT l.fn_oid, k.conrelid,
|
|
785
|
-
CASE k.confdeltype WHEN 'c' THEN 'update'
|
|
786
|
-
WHEN 'n' THEN 'no key update'
|
|
787
|
-
WHEN 'd' THEN 'no key update'
|
|
788
|
-
ELSE 'key share' END,
|
|
789
|
-
'fk-delete:' || k.conname, strpos(l.body, l.del_hit)
|
|
790
|
-
FROM lit l
|
|
791
|
-
JOIN pg_constraint k ON k.confrelid = l.rel_oid AND k.contype = 'f'
|
|
792
|
-
WHERE l.del_hit IS NOT NULL
|
|
793
|
-
),
|
|
794
|
-
calls AS (
|
|
795
|
-
SELECT c.oid AS caller, e.oid AS callee, e.proname AS callee_name,
|
|
796
|
-
strpos(c.body, substring(c.body FROM c.q || '\m' || e.proname || '\M\s*\(')) AS pos
|
|
797
|
-
FROM q c JOIN q e ON e.oid <> c.oid
|
|
798
|
-
WHERE c.body ~ (c.q || '\m' || e.proname || '\M\s*\(')
|
|
799
|
-
),
|
|
800
|
-
reach AS (
|
|
801
|
-
SELECT d.fn_oid, d.rel_oid, d.lock_mode, d.via, array[d.pos] AS path,
|
|
802
|
-
true AS own, d.fn_oid AS origin_oid
|
|
803
|
-
FROM direct d
|
|
804
|
-
UNION ALL
|
|
805
|
-
SELECT c.caller, r.rel_oid, r.lock_mode, 'callee:' || c.callee_name,
|
|
806
|
-
c.pos || r.path, false, r.origin_oid
|
|
807
|
-
FROM reach r JOIN calls c ON c.callee = r.fn_oid
|
|
808
|
-
) CYCLE fn_oid SET is_cycle USING cycle_path
|
|
809
|
-
SELECT r.fn_oid, f.proname, f.sig, r.rel_oid,
|
|
810
|
-
(SELECT n.nspname || '.' || cl.relname FROM pg_class cl
|
|
811
|
-
JOIN pg_namespace n ON n.oid = cl.relnamespace WHERE cl.oid = r.rel_oid),
|
|
812
|
-
r.lock_mode, r.via, r.path, r.own, r.origin_oid,
|
|
813
|
-
(SELECT proname FROM q WHERE oid = r.origin_oid)
|
|
814
|
-
FROM reach r JOIN q f ON f.oid = r.fn_oid
|
|
815
|
-
WHERE NOT r.is_cycle;
|
|
816
|
-
$lockedges$;
|
|
817
|
-
|
|
818
|
-
-- The name-resolved call closure (reflexive), so a test can ask "does this route
|
|
819
|
-
-- reach <serialising helper>" without re-deriving the graph.
|
|
820
|
-
CREATE OR REPLACE FUNCTION agent_board_test.fn_call_closure()
|
|
821
|
-
RETURNS TABLE (root_oid oid, root_name text, reached_oid oid, reached_name text)
|
|
822
|
-
LANGUAGE sql STABLE
|
|
823
|
-
SET search_path = pg_catalog
|
|
824
|
-
AS $closure$
|
|
825
|
-
WITH RECURSIVE
|
|
826
|
-
fns AS (
|
|
827
|
-
SELECT p.oid, p.proname, agent_board_test.stripped_body(p.oid) AS body, p.proconfig
|
|
828
|
-
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
829
|
-
WHERE n.nspname = 'agent_board' AND p.prokind IN ('f','p')
|
|
830
|
-
AND p.prolang <> (SELECT oid FROM pg_language WHERE lanname = 'internal')
|
|
831
|
-
),
|
|
832
|
-
q AS (
|
|
833
|
-
SELECT f.oid, f.proname, f.body,
|
|
834
|
-
CASE WHEN NOT EXISTS (SELECT 1 FROM unnest(coalesce(f.proconfig,'{}')) c
|
|
835
|
-
WHERE c LIKE 'search\_path=%')
|
|
836
|
-
OR EXISTS (SELECT 1 FROM unnest(f.proconfig) c
|
|
837
|
-
WHERE c LIKE 'search\_path=%'
|
|
838
|
-
AND ','||replace(replace(split_part(c,'=',2),' ',''),'"','')||','
|
|
839
|
-
LIKE '%,agent\_board,%')
|
|
840
|
-
THEN '(?:agent_board\.)?' ELSE 'agent_board\.' END AS q
|
|
841
|
-
FROM fns f
|
|
842
|
-
),
|
|
843
|
-
calls AS (
|
|
844
|
-
SELECT c.oid AS caller, e.oid AS callee
|
|
845
|
-
FROM q c JOIN q e ON e.oid <> c.oid
|
|
846
|
-
WHERE c.body ~ (c.q || '\m' || e.proname || '\M\s*\(')
|
|
847
|
-
),
|
|
848
|
-
closure AS (
|
|
849
|
-
SELECT oid AS root, oid AS reached FROM q
|
|
850
|
-
UNION
|
|
851
|
-
SELECT c.root, e.callee FROM closure c JOIN calls e ON e.caller = c.reached
|
|
852
|
-
)
|
|
853
|
-
SELECT c.root, (SELECT proname FROM q WHERE oid = c.root),
|
|
854
|
-
c.reached, (SELECT proname FROM q WHERE oid = c.reached)
|
|
855
|
-
FROM closure c;
|
|
856
|
-
$closure$;
|
|
857
|
-
|
|
858
|
-
COMMENT ON FUNCTION agent_board_test.lock_edges() IS
|
|
859
|
-
'FK-aware per-function row-lock edge set: explicit locks, write locks, and the FOR KEY SHARE / child locks PostgreSQL takes through foreign keys. Test-only; see message-board#281.';
|
|
860
|
-
|
|
861
661
|
GRANT USAGE ON SCHEMA agent_board_test TO PUBLIC;
|
|
862
662
|
|
|
863
663
|
-- ---------------------------------------------------------------------------
|
|
864
|
-
-- IDENTIFIER MATCHERS (Consiliency/message-board#405, #417). ONE definition of
|
|
865
|
-
-- "does this body reference agent_board.<name>",
|
|
664
|
+
-- IDENTIFIER MATCHERS (Consiliency/message-board#405, #416, #417, #424). ONE definition of
|
|
665
|
+
-- "does this body reference agent_board.<name>", and of "does this statement WRITE <rel>" and
|
|
666
|
+
-- "does this statement ASSIGN <rel>.<col>", used by every assertion that asks.
|
|
866
667
|
--
|
|
867
668
|
-- WHY THESE EXIST. The same lookup was hand-written at ~20 sites and drifted twice in
|
|
868
669
|
-- opposite directions in consecutive review rounds: first too STRICT (an abutting-paren,
|
|
@@ -880,11 +681,48 @@ GRANT USAGE ON SCHEMA agent_board_test TO PUBLIC;
|
|
|
880
681
|
-- * a QUOTED identifier is case-SENSITIVE, so `"AGENT_BOARD".boards` is a different object
|
|
881
682
|
-- (`to_regclass('"AGENT_BOARD".boards')` returns NULL);
|
|
882
683
|
-- * a reference is ours only if it is UNQUALIFIED or qualified by exactly `agent_board` — any
|
|
883
|
-
-- other qualifier names someone else's object
|
|
684
|
+
-- other qualifier names someone else's object — or by `<database>.agent_board`, the three-part
|
|
685
|
+
-- spelling PostgreSQL accepts for the current database;
|
|
686
|
+
-- * a statement writes a column only where the GRAMMAR puts an assignment target: the SET list
|
|
687
|
+
-- of an UPDATE or a MERGE update action, an INSERT / MERGE-insert column list, a no-column-list
|
|
688
|
+
-- INSERT (every column), a COPY FROM whose column list names it (or has no list), a
|
|
689
|
+
-- SELECT INTO a new table. A column named in a WHERE, RETURNING, ON or a SET value is READ.
|
|
690
|
+
--
|
|
691
|
+
-- WHAT THIS FAMILY CANNOT SEE, stated here because the residue belongs where the matcher lives:
|
|
692
|
+
-- * DOLLAR-QUOTE DESYNC. The subject is a literal-stripped body; a body whose apostrophes are
|
|
693
|
+
-- unbalanced inside a dollar-quoted string desynchronises the stripper and everything after
|
|
694
|
+
-- the desync is read as a literal (memory: a live function survived only on an even apostrophe
|
|
695
|
+
-- count). `same_stmt()` skips the literal forms it knows (`'…'` with `''`, `E'…'` with `\'`);
|
|
696
|
+
-- it does not know a nested `$tag$…$tag$` and treats its contents as code.
|
|
697
|
+
-- * DYNAMIC SQL. `execute format('update agent_board.%I …', v_rel)` writes a relation no pattern
|
|
698
|
+
-- over the body can name. `mark_retention_metadata` is the one such writer today; change 9 of
|
|
699
|
+
-- the #416 plan names it positively, and a second one fails there by name.
|
|
700
|
+
-- * INDIRECTION. A relation reached through a VIEW with an INSTEAD OF trigger or a rule, a
|
|
701
|
+
-- function variable holding a relation name, or a callee in another schema is not a write of
|
|
702
|
+
-- the base relation to these patterns. `lock_edges` closes over `agent_board` callees only.
|
|
703
|
+
-- * STATEMENT SPAN. `same_stmt()` ends at the first `;` outside a string literal. A `;` inside a
|
|
704
|
+
-- nested dollar-quoted string or a comment the stripper did not remove ends the span early —
|
|
705
|
+
-- that is under-matching (a miss), the silent direction, and it is bounded only by the
|
|
706
|
+
-- stripper's own tests.
|
|
707
|
+
-- * NESTING DEPTH. `set_run()` consumes a parenthesised sub-expression whole to TWO levels; a
|
|
708
|
+
-- SET-list terminator keyword at the third level ends the run early (a miss). CPR-0 row
|
|
709
|
+
-- `k1-three-level-paren-KNOWN-MISS` records the bound as a measured value.
|
|
710
|
+
-- * ITSELF. CPR-0b's tokenizer walks the family's own bodies for regex operands; those six
|
|
711
|
+
-- sites carry hand literals by construction and are annotated `loud-literal` for the lint,
|
|
712
|
+
-- because a matcher cannot be the scanner that judges whether it is a matcher.
|
|
713
|
+
-- * THE PARSER'S VIEW. `agent_board_write_enforcement_class` WEC-24 asks plpgsql_check for the
|
|
714
|
+
-- relations each plpgsql function depends on and requires every regex-found (writer, relation)
|
|
715
|
+
-- pair to be among them. That bounds this family's FALSE POSITIVES against the real parser;
|
|
716
|
+
-- it cannot bound the misses, because a dependency is a read or a write alike.
|
|
884
717
|
--
|
|
885
718
|
-- CALLERS MUST USE `~` / regexp_instr WITHOUT THE 'i' FLAG. The case rules are inside these
|
|
886
719
|
-- patterns; adding 'i' would fold the quoted alternative and reintroduce the second defect.
|
|
887
720
|
-- Keywords that should stay case-insensitive get agent_board_test.ci_re() for the same reason.
|
|
721
|
+
--
|
|
722
|
+
-- COMPOSITION RULE. Only a `*_re` carries the `(?c)` director, and a director is legal ONLY at
|
|
723
|
+
-- position 0 of a regular expression (`b|(?c)a` and `(?:(?c)a)` both raise `invalid regular
|
|
724
|
+
-- expression`). So no site concatenates anything onto, or two of, the `*_re` outputs. A site that
|
|
725
|
+
-- needs a union composes `*_frag`s and wraps ONCE with `agent_board_test.re(...)`.
|
|
888
726
|
|
|
889
727
|
-- A SPACE IN p_text BECOMES `\s+`, because every space this is ever given separates the words of a
|
|
890
728
|
-- multiword SQL keyword (`insert into`, `no key`, `key share`) and PostgreSQL accepts any whitespace
|
|
@@ -914,6 +752,13 @@ $$;
|
|
|
914
752
|
create or replace function agent_board_test.ident_chars() returns text
|
|
915
753
|
language sql immutable set search_path to pg_catalog as $$ select 'A-Za-z0-9_$' $$;
|
|
916
754
|
|
|
755
|
+
-- ANY identifier token, unquoted or quoted, with no case rule: the database component of a
|
|
756
|
+
-- three-part name and an INSERT alias are matched by SHAPE, since neither names our object.
|
|
757
|
+
create or replace function agent_board_test.ident_any_frag() returns text
|
|
758
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
759
|
+
select '(?:[A-Za-z_][' || agent_board_test.ident_chars() || ']*|"[^"]*")'
|
|
760
|
+
$$;
|
|
761
|
+
|
|
917
762
|
-- THE BOUNDARY GUARDS. Three rejections, each a defect that shipped:
|
|
918
763
|
-- * an ASCII identifier character or a quote immediately before/after — `dashboards`, `x$boards`;
|
|
919
764
|
-- * a NON-ASCII letter, which PostgreSQL also admits in an unquoted identifier, so
|
|
@@ -934,18 +779,94 @@ language sql immutable set search_path to pg_catalog as $$
|
|
|
934
779
|
select '(?![' || agent_board_test.ident_chars() || '])(?![^\x00-\x7F])'
|
|
935
780
|
$$;
|
|
936
781
|
|
|
782
|
+
-- THE STATEMENT SPAN. Replaces every hand-rolled `[^;]*` / `[^;]{0,250}?` join: a run of non-`;`
|
|
783
|
+
-- characters in which a STRING LITERAL is consumed whole, so a `;` inside `'a;b'` does not end the
|
|
784
|
+
-- statement and a `where` inside `'where'` is not a keyword. Two literal forms: `E'…'` (with `\'`
|
|
785
|
+
-- and `''` escapes; the `E` must be a standalone token, because `line'x'` lexes as an identifier
|
|
786
|
+
-- followed by a PLAIN literal) and the plain `'…'` form (`''` escapes only; a backslash is an
|
|
787
|
+
-- ordinary character under standard_conforming_strings). No live agent_board body uses an E-string
|
|
788
|
+
-- today, so that arm pins the NEXT writer, not a current miss. Greedy: a caller that needs a
|
|
789
|
+
-- position takes the leftmost match, which is unchanged; only its extent grows.
|
|
790
|
+
create or replace function agent_board_test.same_stmt() returns text
|
|
791
|
+
language sql immutable set search_path to pg_catalog as $q$
|
|
792
|
+
select $s$(?:[^;']|(?<![A-Za-z0-9_$])[Ee]'(?:[^'\\]|\\.|'')*'|'(?:[^']|'')*')*$s$
|
|
793
|
+
$q$;
|
|
794
|
+
|
|
795
|
+
-- THE SET-CLAUSE SPAN: same_stmt()'s token set MINUS the keywords that end THIS statement's SET
|
|
796
|
+
-- list (p_terms, an alternation, refused as a whole word by a lookahead on every step) PLUS a
|
|
797
|
+
-- parenthesised sub-expression consumed whole to two levels of nesting — so a `where` inside
|
|
798
|
+
-- `set slug = (select 1 from t where q = 1), claim_policy = 2` does not end the run while
|
|
799
|
+
-- `set slug = x where claim_policy = y` does. The terminator set is a PARAMETER because it differs
|
|
800
|
+
-- by statement: UPDATE passes `where|from|returning`; MERGE passes `when\s+(?:not\s+)?matched|
|
|
801
|
+
-- returning` — the next WHEN CLAUSE, never a bare `when`, so `case when` survives in both. A shared
|
|
802
|
+
-- set with `when` in it was measured to hide `set slug = case when x then y else z end,
|
|
803
|
+
-- claim_policy = p`. Letters in p_terms fold to either case unless they follow a backslash
|
|
804
|
+
-- (`\s` stays `\s`). A paren group cannot be entered partially: `(` is not in the character class,
|
|
805
|
+
-- so the run either consumes a balanced group or stops before it.
|
|
806
|
+
create or replace function agent_board_test.set_run(p_terms text) returns text
|
|
807
|
+
language sql immutable set search_path to pg_catalog as $q$
|
|
808
|
+
with chars as (
|
|
809
|
+
select c, ord, coalesce(lag(c) over (order by ord), '') as prev
|
|
810
|
+
from regexp_split_to_table(p_terms, '') with ordinality as x(c, ord)
|
|
811
|
+
),
|
|
812
|
+
t as (
|
|
813
|
+
select string_agg(case when c ~ '[A-Za-z]' and prev <> '\' then '[' || upper(c) || lower(c) || ']'
|
|
814
|
+
else c end, '' order by ord) as folded
|
|
815
|
+
from chars
|
|
816
|
+
),
|
|
817
|
+
lit as (select $s$(?<![A-Za-z0-9_$])[Ee]'(?:[^'\\]|\\.|'')*'|'(?:[^']|'')*'$s$ as l),
|
|
818
|
+
p0 as (select '\((?:[^();'']|' || l || ')*\)' as p from lit),
|
|
819
|
+
p1 as (select '\((?:[^();'']|' || lit.l || '|' || p0.p || ')*\)' as p from lit, p0)
|
|
820
|
+
select '(?:(?!\m(?:' || t.folded || ')\M)(?:[^;''()]|' || lit.l || '|' || p1.p || '))*'
|
|
821
|
+
from t, lit, p1
|
|
822
|
+
$q$;
|
|
823
|
+
|
|
824
|
+
-- THE ONLY PLACE A DIRECTOR IS ATTACHED TO A COMPOSED PATTERN. Sites that need a union of frags
|
|
825
|
+
-- wrap once here; see the composition rule above.
|
|
826
|
+
create or replace function agent_board_test.re(p_frag text) returns text
|
|
827
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
828
|
+
select '(?c)(?:' || p_frag || ')'
|
|
829
|
+
$$;
|
|
830
|
+
|
|
937
831
|
-- The un-prefixed fragments. Public matchers add the (?c) director exactly once; these exist so a
|
|
938
832
|
-- matcher can embed another without producing a mid-pattern director, which PostgreSQL rejects.
|
|
833
|
+
--
|
|
834
|
+
-- p_name is `[schema.]name`; the default schema is agent_board. A dotted name matches ONLY the
|
|
835
|
+
-- schema-qualified spelling of that other schema's object (`lock_edges` asks for `auth.users`).
|
|
836
|
+
-- p_bare_ok = false REQUIRES the schema qualifier: `lock_edges` / `fn_call_closure` derive it from
|
|
837
|
+
-- the function's search_path (a function pinning search_path WITHOUT agent_board cannot reach these
|
|
838
|
+
-- names unqualified), and routing them through an always-optional qualifier would have widened
|
|
839
|
+
-- their acceptance — a consolidation that is not the union of what it replaces (#417).
|
|
840
|
+
-- A three-part name `<db>.agent_board.<name>` is accepted; the database component is admitted only
|
|
841
|
+
-- when the schema component follows it, so `mb_db.other.boards` is still refused.
|
|
842
|
+
create or replace function agent_board_test.qname_frag(p_name text, p_bare_ok boolean) returns text
|
|
843
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
844
|
+
select agent_board_test.lead_guard()
|
|
845
|
+
|| '(?:(?:' || agent_board_test.ident_any_frag() || '\s*\.\s*)?'
|
|
846
|
+
|| agent_board_test.ident_re(case when p_name like '%.%' then split_part(p_name, '.', 1) else 'agent_board' end)
|
|
847
|
+
|| '\s*\.\s*)' || case when p_bare_ok then '?' else '' end
|
|
848
|
+
|| agent_board_test.ident_re(case when p_name like '%.%' then split_part(p_name, '.', 2) else p_name end)
|
|
849
|
+
|| agent_board_test.trail_guard()
|
|
850
|
+
$$;
|
|
851
|
+
|
|
939
852
|
create or replace function agent_board_test.qname_frag(p_name text) returns text
|
|
940
853
|
language sql immutable set search_path to pg_catalog as $$
|
|
941
|
-
select agent_board_test.
|
|
942
|
-
|
|
854
|
+
select agent_board_test.qname_frag(p_name, true)
|
|
855
|
+
$$;
|
|
856
|
+
|
|
857
|
+
create or replace function agent_board_test.call_frag(p_name text, p_bare_ok boolean) returns text
|
|
858
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
859
|
+
select agent_board_test.lead_guard()
|
|
860
|
+
|| '(?:(?:' || agent_board_test.ident_any_frag() || '\s*\.\s*)?'
|
|
861
|
+
|| agent_board_test.ident_re(case when p_name like '%.%' then split_part(p_name, '.', 1) else 'agent_board' end)
|
|
862
|
+
|| '\s*\.\s*)' || case when p_bare_ok then '?' else '' end
|
|
863
|
+
|| agent_board_test.ident_re(case when p_name like '%.%' then split_part(p_name, '.', 2) else p_name end)
|
|
864
|
+
|| '\s*\('
|
|
943
865
|
$$;
|
|
944
866
|
|
|
945
867
|
create or replace function agent_board_test.call_frag(p_name text) returns text
|
|
946
868
|
language sql immutable set search_path to pg_catalog as $$
|
|
947
|
-
select agent_board_test.
|
|
948
|
-
|| '\s*\.\s*)?' || agent_board_test.ident_re(p_name) || '\s*\('
|
|
869
|
+
select agent_board_test.call_frag(p_name, true)
|
|
949
870
|
$$;
|
|
950
871
|
|
|
951
872
|
-- THE (?c) DIRECTOR IS THE ENFORCEMENT, not the comment above it. A caller that passes 'i' to
|
|
@@ -958,21 +879,26 @@ $$;
|
|
|
958
879
|
-- caller who prefixes anything gets `invalid regular expression` rather than a silent fold.
|
|
959
880
|
--
|
|
960
881
|
-- EXACTLY WHICH CALLERS THE DIRECTOR PROTECTS, stated narrowly because the first version of this
|
|
961
|
-
-- paragraph claimed more: a pattern that BEGINS with
|
|
962
|
-
--
|
|
963
|
-
--
|
|
964
|
-
--
|
|
965
|
-
-- EXECUTE on them is revoked below so they are not casually reachable;
|
|
966
|
-
-- frag with `~*` deliberately is outside what any mechanism here can
|
|
967
|
-
-- named rather than papered over.
|
|
882
|
+
-- paragraph claimed more: a pattern that BEGINS with a `*_re` is protected against a passed 'i'
|
|
883
|
+
-- flag AND against the `~*` operator, because the director is inside the pattern. A caller that
|
|
884
|
+
-- composes a *_frag directly is NOT protected — the frags carry no director by design, since one
|
|
885
|
+
-- mid-pattern is a syntax error. They exist for embedding inside the _re matchers and inside
|
|
886
|
+
-- agent_board_test.re(), and EXECUTE on them is revoked below so they are not casually reachable;
|
|
887
|
+
-- a test author who calls a frag with `~*` deliberately is outside what any mechanism here can
|
|
888
|
+
-- prevent, and that residue is named rather than papered over.
|
|
968
889
|
create or replace function agent_board_test.qname_re(p_name text) returns text
|
|
969
890
|
language sql immutable set search_path to pg_catalog as $$
|
|
970
891
|
select '(?c)' || agent_board_test.qname_frag(p_name)
|
|
971
892
|
$$;
|
|
972
893
|
|
|
894
|
+
create or replace function agent_board_test.call_re(p_name text, p_bare_ok boolean) returns text
|
|
895
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
896
|
+
select '(?c)' || agent_board_test.call_frag(p_name, p_bare_ok)
|
|
897
|
+
$$;
|
|
898
|
+
|
|
973
899
|
create or replace function agent_board_test.call_re(p_name text) returns text
|
|
974
900
|
language sql immutable set search_path to pg_catalog as $$
|
|
975
|
-
select
|
|
901
|
+
select agent_board_test.call_re(p_name, true)
|
|
976
902
|
$$;
|
|
977
903
|
|
|
978
904
|
-- A COLUMN token. Unquoted identifiers fold, so `SET CLAIM_POLICY = …` names the same column as
|
|
@@ -983,22 +909,52 @@ language sql immutable set search_path to pg_catalog as $$
|
|
|
983
909
|
select agent_board_test.lead_guard() || agent_board_test.ident_re(p_name) || agent_board_test.trail_guard()
|
|
984
910
|
$$;
|
|
985
911
|
|
|
986
|
-
|
|
912
|
+
-- A COLUMN REFERENCE, qualified or bare: `bm.membership_state` as well as `membership_state`.
|
|
913
|
+
-- col_frag is the SET-list form and its lead guard rejects a preceding dot ON PURPOSE (a dotted
|
|
914
|
+
-- name in call or relation position is another schema's object). A column READ — a predicate, an
|
|
915
|
+
-- ORDER BY operand, a SELECT item — is qualified as often as not, and `col_frag` alone dropped
|
|
916
|
+
-- `c.resource_key` writers out of a class set (measured: two of them). The qualified arm accepts
|
|
917
|
+
-- any identifier before the dot, since the qualifier is an alias that names nothing of ours; a
|
|
918
|
+
-- non-column token that happens to sit after a dot (`x.effective_at(`) is admitted the way the
|
|
919
|
+
-- hand-rolled `\meffective_at\M` admitted it. Director-free (Consiliency/message-board#417 PR 3).
|
|
920
|
+
create or replace function agent_board_test.col_ref_frag(p_name text) returns text
|
|
987
921
|
language sql immutable set search_path to pg_catalog as $$
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
922
|
+
select '(?:' || agent_board_test.ident_any_frag() || '\s*\.\s*' || agent_board_test.ident_re(p_name) || agent_board_test.trail_guard()
|
|
923
|
+
|| '|' || agent_board_test.col_frag(p_name) || ')'
|
|
924
|
+
$$;
|
|
925
|
+
|
|
926
|
+
-- "a statement whose verb is one of p_verbs and whose target is agent_board.p_rel".
|
|
927
|
+
-- SPACES INSIDE A MULTIWORD VERB BECOME `\s+`: `INSERT\nINTO` and `DELETE FROM` are the same
|
|
928
|
+
-- statement to PostgreSQL. The hand-rolled expressions this consolidated used `\s+` and got that
|
|
929
|
+
-- right; the first shared version made the space literal and LOST a property the scattered
|
|
930
|
+
-- versions had — a consolidation is only safe if it is at least the union of what it replaces.
|
|
931
|
+
-- Director-free, so it can sit inside an alternation; stmt_re is `'(?c)' || stmt_frag`.
|
|
932
|
+
-- THE VERB STARTS A WORD (`\m`): without it `xupdate agent_board.boards set claim_policy`
|
|
933
|
+
-- matched col_write_re — an identifier ending in a verb read as the verb. Found by the codex seat
|
|
934
|
+
-- on Consiliency/message-board#435 round 1; pinned by CPR-0 `r65-verb-prefix-word` and the
|
|
935
|
+
-- CPR-0a rule 'stmt_frag(core): the verb starts a word'.
|
|
936
|
+
create or replace function agent_board_test.stmt_frag(p_verbs text, p_rel text, p_bare_ok boolean) returns text
|
|
937
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
938
|
+
select '\m(?:' || (select string_agg(agent_board_test.ci_re(v), '|')
|
|
939
|
+
from regexp_split_to_table(p_verbs, '\|') as v)
|
|
995
940
|
|| ')\s+(?:' || agent_board_test.ci_re('only') || '\s+)?'
|
|
996
|
-
|| agent_board_test.qname_frag(p_rel)
|
|
941
|
+
|| agent_board_test.qname_frag(p_rel, p_bare_ok)
|
|
997
942
|
$$;
|
|
998
943
|
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
944
|
+
create or replace function agent_board_test.stmt_frag(p_verbs text, p_rel text) returns text
|
|
945
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
946
|
+
select agent_board_test.stmt_frag(p_verbs, p_rel, true)
|
|
947
|
+
$$;
|
|
948
|
+
|
|
949
|
+
create or replace function agent_board_test.stmt_re(p_verbs text, p_rel text, p_bare_ok boolean) returns text
|
|
950
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
951
|
+
select '(?c)' || agent_board_test.stmt_frag(p_verbs, p_rel, p_bare_ok)
|
|
952
|
+
$$;
|
|
953
|
+
|
|
954
|
+
create or replace function agent_board_test.stmt_re(p_verbs text, p_rel text) returns text
|
|
955
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
956
|
+
select agent_board_test.stmt_re(p_verbs, p_rel, true)
|
|
957
|
+
$$;
|
|
1002
958
|
|
|
1003
959
|
-- THE INSERT-WITHOUT-COLUMN-LIST RULE, AS A NAMED OBJECT. It began as an expression inlined in
|
|
1004
960
|
-- CPR-6, and CPR-0's rows for it RECONSTRUCTED that expression instead of calling it — so the rows
|
|
@@ -1018,16 +974,11 @@ revoke all on function agent_board_test.col_frag(text) from public;
|
|
|
1018
974
|
-- SPLIT INTO frag + re, matching qname/call/col, because CPR-6 embeds this rule INSIDE a larger
|
|
1019
975
|
-- alternation and a director is only legal at position 0. It used to embed
|
|
1020
976
|
-- `replace(insert_all_columns_re(...), '(?c)', '')` — so CPR-0's rows compiled X while production
|
|
1021
|
-
-- compiled a TEXTUAL TRANSFORMATION of X.
|
|
1022
|
-
--
|
|
1023
|
-
|
|
1024
|
-
-- position 0 and the replace stripped it silently. Two strings are not one object. Now CPR-6 embeds
|
|
1025
|
-
-- the frag and CPR-0's rows exercise the frag: the same object, no transformation at any call site.
|
|
1026
|
-
create or replace function agent_board_test.insert_all_columns_frag(p_rel text) returns text
|
|
977
|
+
-- compiled a TEXTUAL TRANSFORMATION of X. Two strings are not one object. Now the frag is embedded
|
|
978
|
+
-- and CPR-0's rows exercise the frag: the same object, no transformation at any call site.
|
|
979
|
+
create or replace function agent_board_test.insert_all_columns_frag(p_rel text, p_bare_ok boolean) returns text
|
|
1027
980
|
language sql immutable set search_path to pg_catalog as $$
|
|
1028
|
-
select agent_board_test.
|
|
1029
|
-
|| '\s+(?:' || agent_board_test.ci_re('only') || '\s+)?'
|
|
1030
|
-
|| agent_board_test.qname_frag(p_rel)
|
|
981
|
+
select agent_board_test.stmt_frag('insert into', p_rel, p_bare_ok)
|
|
1031
982
|
|| '(?!(?:\s+(?:' || agent_board_test.ci_re('as') || '\s+)?(?!'
|
|
1032
983
|
-- ONLY VALUES AND SELECT. `default` and `overriding` were excluded here too and were DEAD
|
|
1033
984
|
-- CODE: an exclusion only matters if the keyword can be followed by `\s*\(` and so be mistaken
|
|
@@ -1036,7 +987,12 @@ language sql immutable set search_path to pg_catalog as $$
|
|
|
1036
987
|
-- not. Removed rather than kept with rows that cannot gate them.
|
|
1037
988
|
|| agent_board_test.ci_re('values') || '(?![A-Za-z0-9_$])|'
|
|
1038
989
|
|| agent_board_test.ci_re('select') || '(?![A-Za-z0-9_$]))'
|
|
1039
|
-
||
|
|
990
|
+
|| agent_board_test.ident_any_frag() || ')?\s*\()'
|
|
991
|
+
$$;
|
|
992
|
+
|
|
993
|
+
create or replace function agent_board_test.insert_all_columns_frag(p_rel text) returns text
|
|
994
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
995
|
+
select agent_board_test.insert_all_columns_frag(p_rel, true)
|
|
1040
996
|
$$;
|
|
1041
997
|
|
|
1042
998
|
create or replace function agent_board_test.insert_all_columns_re(p_rel text) returns text
|
|
@@ -1044,4 +1000,455 @@ language sql immutable set search_path to pg_catalog as $$
|
|
|
1044
1000
|
select '(?c)' || agent_board_test.insert_all_columns_frag(p_rel)
|
|
1045
1001
|
$$;
|
|
1046
1002
|
|
|
1003
|
+
-- THE MERGE TWIN: `merge into <rel> … then insert values|default values|overriding …` with no
|
|
1004
|
+
-- column list writes every column. A `when not matched [by target]` prefix is not required — a
|
|
1005
|
+
-- valid MERGE can carry `then insert` under no other arm, so requiring it could only under-match.
|
|
1006
|
+
-- The join after `insert` is `\s*`, not a statement span: with a span, a column list would be
|
|
1007
|
+
-- skipped and `then insert (slug) values (1)` credited as an all-column write.
|
|
1008
|
+
create or replace function agent_board_test.merge_insert_all_columns_frag(p_rel text, p_bare_ok boolean) returns text
|
|
1009
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
1010
|
+
select agent_board_test.stmt_frag('merge into', p_rel, p_bare_ok) || agent_board_test.same_stmt()
|
|
1011
|
+
|| '\m' || agent_board_test.ci_re('then') || '\s+' || agent_board_test.ci_re('insert')
|
|
1012
|
+
|| '\s*(?:' || agent_board_test.ci_re('default values') || '|' || agent_board_test.ci_re('values')
|
|
1013
|
+
|| '|' || agent_board_test.ci_re('overriding') || ')(?![A-Za-z0-9_$])'
|
|
1014
|
+
$$;
|
|
1015
|
+
|
|
1016
|
+
create or replace function agent_board_test.merge_insert_all_columns_frag(p_rel text) returns text
|
|
1017
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
1018
|
+
select agent_board_test.merge_insert_all_columns_frag(p_rel, true)
|
|
1019
|
+
$$;
|
|
1020
|
+
|
|
1021
|
+
-- EVERY STATEMENT SHAPE THAT WRITES p_rel, as one group of per-verb arms:
|
|
1022
|
+
-- * `insert into | merge into | update | delete from <rel>`;
|
|
1023
|
+
-- * TRUNCATE with a relation LIST (`truncate agent_board.claims, agent_board.boards` writes
|
|
1024
|
+
-- boards) and the optional `table` / `only` words;
|
|
1025
|
+
-- * COPY … FROM, with the optional column list consumed BEFORE the direction word (`copy rel
|
|
1026
|
+
-- (cols) from` continues with a column list, not `\s+from`); `copy … to` is a read;
|
|
1027
|
+
-- * SELECT … INTO [temp|unlogged] [table] <rel> — a table created by the statement. The relation
|
|
1028
|
+
-- must sit at the INTO target: `select … into v_var from <rel>` is a read of <rel>.
|
|
1029
|
+
-- This is the union. A caller that must keep verb KINDS apart (`lock_edges`, whose edges are named
|
|
1030
|
+
-- by kind) calls stmt_frag(<one verb>, rel, bare_ok) per kind, never this.
|
|
1031
|
+
create or replace function agent_board_test.write_frag(p_rel text, p_bare_ok boolean) returns text
|
|
1032
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
1033
|
+
select '(?:' || agent_board_test.stmt_frag('insert into|merge into|update|delete from', p_rel, p_bare_ok)
|
|
1034
|
+
|| '|' || agent_board_test.lead_guard() || agent_board_test.ci_re('truncate')
|
|
1035
|
+
|| '(?:\s+' || agent_board_test.ci_re('table') || ')?\s+(?:' || agent_board_test.ci_re('only') || '\s+)?'
|
|
1036
|
+
|| '(?:[A-Za-z_"][A-Za-z0-9_."]*\s*\*?\s*,\s*(?:' || agent_board_test.ci_re('only') || '\s+)?)*'
|
|
1037
|
+
|| agent_board_test.qname_frag(p_rel, p_bare_ok)
|
|
1038
|
+
|| '|' || agent_board_test.stmt_frag('copy', p_rel, p_bare_ok)
|
|
1039
|
+
|| '\s*(?:\([^)]*\))?\s*' || agent_board_test.ci_re('from') || agent_board_test.trail_guard()
|
|
1040
|
+
|| '|\m' || agent_board_test.ci_re('select') || '\M' || agent_board_test.same_stmt()
|
|
1041
|
+
|| '\m' || agent_board_test.ci_re('into') || '\s+(?:(?:' || agent_board_test.ci_re('temporary')
|
|
1042
|
+
|| '|' || agent_board_test.ci_re('temp') || '|' || agent_board_test.ci_re('unlogged') || ')\s+)?'
|
|
1043
|
+
|| '(?:' || agent_board_test.ci_re('table') || '\s+)?' || agent_board_test.qname_frag(p_rel, p_bare_ok)
|
|
1044
|
+
|| ')'
|
|
1045
|
+
$$;
|
|
1046
|
+
|
|
1047
|
+
create or replace function agent_board_test.write_frag(p_rel text) returns text
|
|
1048
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
1049
|
+
select agent_board_test.write_frag(p_rel, true)
|
|
1050
|
+
$$;
|
|
1051
|
+
|
|
1052
|
+
create or replace function agent_board_test.write_re(p_rel text) returns text
|
|
1053
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
1054
|
+
select '(?c)' || agent_board_test.write_frag(p_rel)
|
|
1055
|
+
$$;
|
|
1056
|
+
|
|
1057
|
+
-- The union of write_frag over a relation list — one helper call at a site, never a string_agg of
|
|
1058
|
+
-- `*_re` outputs there. An EMPTY list is refused rather than returned as a pattern that matches
|
|
1059
|
+
-- nothing: a class assertion handed an empty class would otherwise read "no writer" as clean.
|
|
1060
|
+
create or replace function agent_board_test.writes_any_frag(p_rels text[], p_bare_ok boolean) returns text
|
|
1061
|
+
language plpgsql immutable set search_path to pg_catalog as $$
|
|
1062
|
+
begin
|
|
1063
|
+
if p_rels is null or cardinality(p_rels) = 0 then
|
|
1064
|
+
raise exception 'writes_any_frag: the relation list is empty';
|
|
1065
|
+
end if;
|
|
1066
|
+
return '(?:' || (select string_agg(agent_board_test.write_frag(r, p_bare_ok), '|') from unnest(p_rels) as r) || ')';
|
|
1067
|
+
end $$;
|
|
1068
|
+
|
|
1069
|
+
create or replace function agent_board_test.writes_any_frag(p_rels text[]) returns text
|
|
1070
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
1071
|
+
select agent_board_test.writes_any_frag(p_rels, true)
|
|
1072
|
+
$$;
|
|
1073
|
+
|
|
1074
|
+
create or replace function agent_board_test.writes_any_re(p_rels text[]) returns text
|
|
1075
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
1076
|
+
select '(?c)' || agent_board_test.writes_any_frag(p_rels)
|
|
1077
|
+
$$;
|
|
1078
|
+
|
|
1079
|
+
-- An EXPLICIT locking clause — `for update`, `for no key update`, `for share`, `for key share` —
|
|
1080
|
+
-- as a fragment, and `row_lock_re(rel)`: that clause in the SAME STATEMENT as a reference to
|
|
1081
|
+
-- <rel>, stepping over literals (`same_stmt()`), so a `'for update'` inside a string is not a lock
|
|
1082
|
+
-- and a lock in the NEXT statement is not this relation's. Five sites carried five hand copies of
|
|
1083
|
+
-- the clause before this existed (CPR-14, CPR-14a, ledger_authority:963, signer_lifecycle:702);
|
|
1084
|
+
-- one copy here is the object each of them now names.
|
|
1085
|
+
create or replace function agent_board_test.row_lock_frag() returns text
|
|
1086
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
1087
|
+
select '\m' || agent_board_test.ci_re('for') || '\s+(?:' || agent_board_test.ci_re('no key') || '\s+)?(?:'
|
|
1088
|
+
|| agent_board_test.ci_re('update') || '|' || agent_board_test.ci_re('share') || '|' || agent_board_test.ci_re('key share') || ')'
|
|
1089
|
+
|| agent_board_test.trail_guard()
|
|
1090
|
+
$$;
|
|
1091
|
+
|
|
1092
|
+
create or replace function agent_board_test.row_lock_re(p_rel text) returns text
|
|
1093
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
1094
|
+
select '(?c)' || agent_board_test.qname_frag(p_rel) || agent_board_test.same_stmt() || agent_board_test.row_lock_frag()
|
|
1095
|
+
$$;
|
|
1096
|
+
|
|
1097
|
+
-- A statement that ASSIGNS <rel>.<col>, POSITIONALLY — the column sits where the grammar puts an
|
|
1098
|
+
-- assignment target. The first shared draft was `stmt_re || '[^;]*' || col_frag`, which made
|
|
1099
|
+
-- `update boards set slug = x where claim_policy = y` a writer of claim_policy; CPR-6 read exactly
|
|
1100
|
+
-- that way, so consolidating onto it would have consolidated onto an over-match. Three arms:
|
|
1101
|
+
-- (1) `update <rel> … set <run> col =` — a scalar SET target;
|
|
1102
|
+
-- (2) `update <rel> … set <run> (…, col, …) =` — the row form, reachable after scalar targets
|
|
1103
|
+
-- (`set updated_at = now(), (slug, claim_policy)
|
|
1104
|
+
-- = (…)`);
|
|
1105
|
+
-- (3) `merge into <rel> … then update set <run> col =` — the phrase only a MERGE WHEN clause can
|
|
1106
|
+
-- contain, so `when matched AND <pred> then update set` and PG17's `when not matched by
|
|
1107
|
+
-- source then update set` are both accepted;
|
|
1108
|
+
-- (4) `insert into <rel> … do update set <run> col =` — the ON CONFLICT upsert arm. Missing from
|
|
1109
|
+
-- the first three-arm version; found by the codex seat on Consiliency/message-board#435
|
|
1110
|
+
-- round 2 (`insert into boards(id) values (1) on conflict (id) do update set claim_policy =
|
|
1111
|
+
-- 'advisory'` matched no arm). Thirteen live functions upsert, three of them onto FK columns
|
|
1112
|
+
-- (`_upsert_inbox_item.board_id`, `register_fleet_machine.trust_domain_id`,
|
|
1113
|
+
-- `heartbeat.thread_id`), so this arm changes lock_edges' fk-update set — see FKL-9.
|
|
1114
|
+
-- <run> is set_run() with that statement's terminators. This is the arm set lock_edges uses for
|
|
1115
|
+
-- "an UPDATE re-points an FK column", which is why it is a named object of its own: an INSERT
|
|
1116
|
+
-- naming the column is a different edge kind there (fk-insert), already derived from the INSERT.
|
|
1117
|
+
create or replace function agent_board_test.col_assign_frag(p_rel text, p_col text, p_bare_ok boolean) returns text
|
|
1118
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
1119
|
+
select '(?:' || agent_board_test.stmt_frag('update', p_rel, p_bare_ok) || agent_board_test.same_stmt()
|
|
1120
|
+
|| '\m' || agent_board_test.ci_re('set') || '\M' || agent_board_test.set_run('where|from|returning')
|
|
1121
|
+
|| '(?:' || agent_board_test.col_frag(p_col) || '|\([^)]*' || agent_board_test.col_frag(p_col) || '[^)]*\))\s*='
|
|
1122
|
+
|| '|' || agent_board_test.stmt_frag('merge into', p_rel, p_bare_ok) || agent_board_test.same_stmt()
|
|
1123
|
+
|| '\m' || agent_board_test.ci_re('then update set') || '\M'
|
|
1124
|
+
|| agent_board_test.set_run('when\s+(?:not\s+)?matched|returning')
|
|
1125
|
+
|| agent_board_test.col_frag(p_col) || '\s*='
|
|
1126
|
+
|| '|' || agent_board_test.stmt_frag('insert into', p_rel, p_bare_ok) || agent_board_test.same_stmt()
|
|
1127
|
+
|| '\m' || agent_board_test.ci_re('do update set') || '\M' || agent_board_test.set_run('where|returning')
|
|
1128
|
+
|| '(?:' || agent_board_test.col_frag(p_col) || '|\([^)]*' || agent_board_test.col_frag(p_col) || '[^)]*\))\s*='
|
|
1129
|
+
|| ')'
|
|
1130
|
+
$$;
|
|
1131
|
+
|
|
1132
|
+
-- Every statement shape that writes <rel>.<col>: the assignment arms above plus
|
|
1133
|
+
-- (4) `insert into <rel> [alias] (…, col, …)` — an INSERT column list;
|
|
1134
|
+
-- (5) `merge into <rel> … then insert (…, col, …)` — a MERGE-insert column list;
|
|
1135
|
+
-- (6) an INSERT with no column list (every column) — insert_all_columns_frag;
|
|
1136
|
+
-- (7) a MERGE-insert with no column list — merge_insert_all_columns_frag;
|
|
1137
|
+
-- (8) `copy <rel> [(…, col, …)] from` — a COPY FROM whose list names col or is absent;
|
|
1138
|
+
-- (9) `select … into [temp] [table] <rel>` — a table the statement creates, every column.
|
|
1139
|
+
-- Arm (4)'s alias is any identifier token, without the VALUES/SELECT keyword exclusion that
|
|
1140
|
+
-- insert_all_columns_frag needs: the only sentences the exclusion would change here (`insert into
|
|
1141
|
+
-- rel values (…col…)`) are all-column writes that arm (6) already accepts, so an exclusion here
|
|
1142
|
+
-- could flip no row and would be an unpinned rule.
|
|
1143
|
+
create or replace function agent_board_test.col_write_frag(p_rel text, p_col text, p_bare_ok boolean) returns text
|
|
1144
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
1145
|
+
select '(?:' || agent_board_test.col_assign_frag(p_rel, p_col, p_bare_ok)
|
|
1146
|
+
|| '|' || agent_board_test.stmt_frag('insert into', p_rel, p_bare_ok)
|
|
1147
|
+
|| '(?:\s+(?:' || agent_board_test.ci_re('as') || '\s+)?' || agent_board_test.ident_any_frag() || ')?'
|
|
1148
|
+
|| '\s*\([^)]*' || agent_board_test.col_frag(p_col) || '[^)]*\)'
|
|
1149
|
+
|| '|' || agent_board_test.stmt_frag('merge into', p_rel, p_bare_ok) || agent_board_test.same_stmt()
|
|
1150
|
+
|| '\m' || agent_board_test.ci_re('then insert') || '\s*\([^)]*' || agent_board_test.col_frag(p_col) || '[^)]*\)'
|
|
1151
|
+
|| '|' || agent_board_test.insert_all_columns_frag(p_rel, p_bare_ok)
|
|
1152
|
+
|| '|' || agent_board_test.merge_insert_all_columns_frag(p_rel, p_bare_ok)
|
|
1153
|
+
|| '|' || agent_board_test.stmt_frag('copy', p_rel, p_bare_ok)
|
|
1154
|
+
|| '\s*(?:\((?:[^)]*' || agent_board_test.col_frag(p_col) || '[^)]*)\))?\s*'
|
|
1155
|
+
|| agent_board_test.ci_re('from') || agent_board_test.trail_guard()
|
|
1156
|
+
|| '|\m' || agent_board_test.ci_re('select') || '\M' || agent_board_test.same_stmt()
|
|
1157
|
+
|| '\m' || agent_board_test.ci_re('into') || '\s+(?:(?:' || agent_board_test.ci_re('temporary')
|
|
1158
|
+
|| '|' || agent_board_test.ci_re('temp') || '|' || agent_board_test.ci_re('unlogged') || ')\s+)?'
|
|
1159
|
+
|| '(?:' || agent_board_test.ci_re('table') || '\s+)?' || agent_board_test.qname_frag(p_rel, p_bare_ok)
|
|
1160
|
+
|| ')'
|
|
1161
|
+
$$;
|
|
1162
|
+
|
|
1163
|
+
create or replace function agent_board_test.col_write_frag(p_rel text, p_col text) returns text
|
|
1164
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
1165
|
+
select agent_board_test.col_write_frag(p_rel, p_col, true)
|
|
1166
|
+
$$;
|
|
1167
|
+
|
|
1168
|
+
create or replace function agent_board_test.col_write_re(p_rel text, p_col text) returns text
|
|
1169
|
+
language sql immutable set search_path to pg_catalog as $$
|
|
1170
|
+
select '(?c)' || agent_board_test.col_write_frag(p_rel, p_col)
|
|
1171
|
+
$$;
|
|
1172
|
+
|
|
1173
|
+
-- Resolve BARE names the way PostgreSQL would for THIS function. A function pinning search_path
|
|
1174
|
+
-- WITHOUT agent_board cannot reach our objects unqualified, so bare matches are not attributed to
|
|
1175
|
+
-- it. No search_path setting at all is treated as bare-resolving, which over-approximates
|
|
1176
|
+
-- membership rather than under-approximating it. One definition, read by lock_edges AND
|
|
1177
|
+
-- fn_call_closure — it used to be two copies. Returns boolean, so it is outside the text-returning
|
|
1178
|
+
-- family CPR-0b gates, by construction.
|
|
1179
|
+
create or replace function agent_board_test.bare_ok(p_fn oid) returns boolean
|
|
1180
|
+
language sql stable set search_path to pg_catalog as $$
|
|
1181
|
+
select not exists (select 1 from pg_proc p, unnest(coalesce(p.proconfig, '{}')) c
|
|
1182
|
+
where p.oid = p_fn and c like 'search\_path=%')
|
|
1183
|
+
or exists (select 1 from pg_proc p, unnest(p.proconfig) c
|
|
1184
|
+
where p.oid = p_fn and c like 'search\_path=%'
|
|
1185
|
+
and ',' || replace(replace(split_part(c, '=', 2), ' ', ''), '"', '') || ','
|
|
1186
|
+
like '%,agent\_board,%')
|
|
1187
|
+
$$;
|
|
1188
|
+
|
|
1189
|
+
revoke all on function agent_board_test.ident_any_frag() from public;
|
|
1190
|
+
revoke all on function agent_board_test.same_stmt() from public;
|
|
1191
|
+
revoke all on function agent_board_test.set_run(text) from public;
|
|
1192
|
+
revoke all on function agent_board_test.qname_frag(text) from public;
|
|
1193
|
+
revoke all on function agent_board_test.qname_frag(text, boolean) from public;
|
|
1194
|
+
revoke all on function agent_board_test.call_frag(text) from public;
|
|
1195
|
+
revoke all on function agent_board_test.call_frag(text, boolean) from public;
|
|
1196
|
+
revoke all on function agent_board_test.col_frag(text) from public;
|
|
1197
|
+
revoke all on function agent_board_test.col_ref_frag(text) from public;
|
|
1198
|
+
revoke all on function agent_board_test.stmt_frag(text, text) from public;
|
|
1199
|
+
revoke all on function agent_board_test.stmt_frag(text, text, boolean) from public;
|
|
1047
1200
|
revoke all on function agent_board_test.insert_all_columns_frag(text) from public;
|
|
1201
|
+
revoke all on function agent_board_test.insert_all_columns_frag(text, boolean) from public;
|
|
1202
|
+
revoke all on function agent_board_test.merge_insert_all_columns_frag(text) from public;
|
|
1203
|
+
revoke all on function agent_board_test.merge_insert_all_columns_frag(text, boolean) from public;
|
|
1204
|
+
revoke all on function agent_board_test.write_frag(text) from public;
|
|
1205
|
+
revoke all on function agent_board_test.write_frag(text, boolean) from public;
|
|
1206
|
+
revoke all on function agent_board_test.writes_any_frag(text[]) from public;
|
|
1207
|
+
revoke all on function agent_board_test.writes_any_frag(text[], boolean) from public;
|
|
1208
|
+
revoke all on function agent_board_test.col_assign_frag(text, text, boolean) from public;
|
|
1209
|
+
revoke all on function agent_board_test.col_write_frag(text, text) from public;
|
|
1210
|
+
revoke all on function agent_board_test.col_write_frag(text, text, boolean) from public;
|
|
1211
|
+
revoke all on function agent_board_test.row_lock_frag() from public;
|
|
1212
|
+
|
|
1213
|
+
-- One row per (function, relation, acquisition). `own` distinguishes an
|
|
1214
|
+
-- acquisition written in the function's own body from one reached through a
|
|
1215
|
+
-- callee; `origin_name` names the function the acquisition actually lives in.
|
|
1216
|
+
--
|
|
1217
|
+
-- Every textual arm is a matcher from the family above, PER KIND (the expected strings in
|
|
1218
|
+
-- fk_lock_edges / task_thread_lock_order pin the kind of every edge by name, so the kinds must
|
|
1219
|
+
-- stay distinguishable): `stmt_frag('insert into'|'update'|'delete from', rel, bare_ok)`,
|
|
1220
|
+
-- `col_assign_frag(rel, fk_col, bare_ok)` for the FK re-point, `qname_frag || same_stmt() ||
|
|
1221
|
+
-- <lock clause>` for an explicit lock, `call_frag(proname, bare_ok)` for a call edge. The bounded
|
|
1222
|
+
-- `[^;]{0,250}?` spans became same_stmt(). MERGE is not modelled here (no agent_board body uses
|
|
1223
|
+
-- it; a MERGE update/insert would be an unrecorded write edge — stated, not closed).
|
|
1224
|
+
CREATE OR REPLACE FUNCTION agent_board_test.lock_edges()
|
|
1225
|
+
RETURNS TABLE (
|
|
1226
|
+
fn_oid oid,
|
|
1227
|
+
fn_name text,
|
|
1228
|
+
fn_sig text,
|
|
1229
|
+
rel_oid oid,
|
|
1230
|
+
rel_name text,
|
|
1231
|
+
lock_mode text,
|
|
1232
|
+
via text,
|
|
1233
|
+
path int[],
|
|
1234
|
+
own boolean,
|
|
1235
|
+
origin_oid oid,
|
|
1236
|
+
origin_name text
|
|
1237
|
+
)
|
|
1238
|
+
LANGUAGE sql STABLE
|
|
1239
|
+
SET search_path = pg_catalog
|
|
1240
|
+
AS $lockedges$
|
|
1241
|
+
WITH RECURSIVE
|
|
1242
|
+
fns AS (
|
|
1243
|
+
SELECT p.oid,
|
|
1244
|
+
p.proname,
|
|
1245
|
+
p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')' AS sig,
|
|
1246
|
+
agent_board_test.stripped_body(p.oid) AS body,
|
|
1247
|
+
agent_board_test.bare_ok(p.oid) AS bare
|
|
1248
|
+
FROM pg_proc p
|
|
1249
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
1250
|
+
WHERE n.nspname = 'agent_board'
|
|
1251
|
+
AND p.prokind IN ('f', 'p')
|
|
1252
|
+
AND p.prolang <> (SELECT oid FROM pg_language WHERE lanname = 'internal')
|
|
1253
|
+
),
|
|
1254
|
+
rels AS (
|
|
1255
|
+
SELECT c.oid AS rel_oid, c.relname, n.nspname,
|
|
1256
|
+
-- the matcher's `[schema.]name`: agent_board is the default schema, anything else is
|
|
1257
|
+
-- named explicitly and can only be reached qualified
|
|
1258
|
+
CASE WHEN n.nspname = 'agent_board' THEN c.relname ELSE n.nspname || '.' || c.relname END AS mname
|
|
1259
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
1260
|
+
WHERE c.relkind IN ('r', 'p')
|
|
1261
|
+
AND (n.nspname = 'agent_board' OR (n.nspname = 'auth' AND c.relname = 'users'))
|
|
1262
|
+
),
|
|
1263
|
+
-- Prefilter: a function can only touch a relation whose name its body contains. A case-folded
|
|
1264
|
+
-- substring test is looser than every arm it guards (each arm's match contains the name in some
|
|
1265
|
+
-- case), so it can only save work, never a match. MATERIALIZED so the planner cannot hoist the
|
|
1266
|
+
-- expensive matchers above it — without that, every (function, relation) pair ran the full
|
|
1267
|
+
-- pattern and lock_edges() took minutes instead of seconds.
|
|
1268
|
+
cand AS MATERIALIZED (
|
|
1269
|
+
SELECT f.oid AS fn_oid, f.body, r.rel_oid, r.relname, r.mname,
|
|
1270
|
+
(f.bare AND r.nspname = 'agent_board') AS rb
|
|
1271
|
+
FROM fns f CROSS JOIN rels r
|
|
1272
|
+
WHERE strpos(lower(f.body), lower(r.relname)) > 0
|
|
1273
|
+
),
|
|
1274
|
+
lit AS MATERIALIZED (
|
|
1275
|
+
SELECT c.fn_oid, c.body, c.rel_oid, c.relname, c.mname, c.rb,
|
|
1276
|
+
substring(c.body FROM agent_board_test.re(
|
|
1277
|
+
agent_board_test.stmt_frag('insert into', c.mname, c.rb))) AS ins_hit,
|
|
1278
|
+
substring(c.body FROM agent_board_test.re(
|
|
1279
|
+
agent_board_test.stmt_frag('update', c.mname, c.rb) || agent_board_test.same_stmt()
|
|
1280
|
+
|| '\m' || agent_board_test.ci_re('set') || '\M')) AS upd_hit,
|
|
1281
|
+
substring(c.body FROM agent_board_test.re(
|
|
1282
|
+
agent_board_test.stmt_frag('delete from', c.mname, c.rb))) AS del_hit,
|
|
1283
|
+
substring(c.body FROM agent_board_test.re(
|
|
1284
|
+
agent_board_test.qname_frag(c.mname, c.rb) || agent_board_test.same_stmt()
|
|
1285
|
+
|| '\m' || agent_board_test.ci_re('for') || '\s+(?:' || agent_board_test.ci_re('update')
|
|
1286
|
+
|| '|' || agent_board_test.ci_re('no key update') || '|' || agent_board_test.ci_re('share')
|
|
1287
|
+
|| '|' || agent_board_test.ci_re('key share') || ')' || agent_board_test.trail_guard())) AS lock_hit
|
|
1288
|
+
FROM cand c
|
|
1289
|
+
),
|
|
1290
|
+
-- FOR UPDATE OF <alias, ...> locks only the listed relations. The lock arm above matches every
|
|
1291
|
+
-- relation named earlier in the statement, so an OF list has to be read back: the relation's
|
|
1292
|
+
-- alias (the identifier after its name, or the bare name when it has none) must appear in it.
|
|
1293
|
+
-- Without this, `from a join b ... for update of a` fabricated a lock on b — and the fabricated
|
|
1294
|
+
-- edge reached FKL-10 as an opposite-order pair that no transaction can hold.
|
|
1295
|
+
lock_scope AS (
|
|
1296
|
+
SELECT l.*,
|
|
1297
|
+
lower(substring(l.lock_hit FROM '(?i)^' || agent_board_test.qname_frag(l.mname, l.rb)
|
|
1298
|
+
|| '\s+(?:as\s+)?([A-Za-z_][A-Za-z0-9_$]*)')) AS rel_alias,
|
|
1299
|
+
(SELECT array_agg(lower(trim(both '"' from x)))
|
|
1300
|
+
FROM regexp_split_to_table(
|
|
1301
|
+
substring(substr(l.body, strpos(l.body, l.lock_hit) + length(l.lock_hit))
|
|
1302
|
+
FROM '(?i)^\s+of\s+((?:[A-Za-z_][A-Za-z0-9_$]*|"[^"]*")(?:\s*,\s*(?:[A-Za-z_][A-Za-z0-9_$]*|"[^"]*"))*)'),
|
|
1303
|
+
'\s*,\s*') AS x) AS of_list
|
|
1304
|
+
FROM lit l
|
|
1305
|
+
WHERE l.lock_hit IS NOT NULL
|
|
1306
|
+
),
|
|
1307
|
+
-- FK columns by NAME, from pg_attribute over conkey. Shape (v) is gated on the
|
|
1308
|
+
-- SET naming one of these; hand-listing them, or skipping the gate, is what
|
|
1309
|
+
-- turns this detector into a cycle-fabricator.
|
|
1310
|
+
fkcols AS (
|
|
1311
|
+
SELECT k.conname, k.conrelid, k.confrelid, a.attname
|
|
1312
|
+
FROM pg_constraint k
|
|
1313
|
+
CROSS JOIN LATERAL unnest(k.conkey) AS ck(attnum)
|
|
1314
|
+
JOIN pg_attribute a ON a.attrelid = k.conrelid AND a.attnum = ck.attnum
|
|
1315
|
+
WHERE k.contype = 'f'
|
|
1316
|
+
),
|
|
1317
|
+
-- Same prefilter shape as `cand`: the column name must appear before the assignment matcher runs.
|
|
1318
|
+
updcand AS MATERIALIZED (
|
|
1319
|
+
SELECT l.fn_oid, l.body, l.mname, l.rb, fc.conname, fc.confrelid, fc.attname
|
|
1320
|
+
FROM lit l
|
|
1321
|
+
JOIN fkcols fc ON fc.conrelid = l.rel_oid
|
|
1322
|
+
WHERE strpos(lower(l.body), lower(fc.attname)) > 0
|
|
1323
|
+
),
|
|
1324
|
+
upd AS (
|
|
1325
|
+
SELECT u.fn_oid, u.conname, u.confrelid, min(strpos(u.body, h.hit)) AS pos
|
|
1326
|
+
FROM updcand u
|
|
1327
|
+
CROSS JOIN LATERAL (SELECT substring(u.body FROM agent_board_test.re(
|
|
1328
|
+
agent_board_test.col_assign_frag(u.mname, u.attname, u.rb))) AS hit) h
|
|
1329
|
+
WHERE h.hit IS NOT NULL
|
|
1330
|
+
GROUP BY u.fn_oid, u.conname, u.confrelid
|
|
1331
|
+
),
|
|
1332
|
+
direct AS (
|
|
1333
|
+
-- (i) an explicit row lock written in the body
|
|
1334
|
+
SELECT l.fn_oid, l.rel_oid,
|
|
1335
|
+
lower(regexp_replace(substring(l.lock_hit FROM
|
|
1336
|
+
'(?i)\mfor\s+(update|no\s+key\s+update|share|key\s+share)\M'), '\s+', ' ', 'g')) AS lock_mode,
|
|
1337
|
+
'textual' AS via,
|
|
1338
|
+
strpos(l.body, l.lock_hit) AS pos
|
|
1339
|
+
FROM lock_scope l
|
|
1340
|
+
WHERE l.of_list IS NULL
|
|
1341
|
+
OR l.rel_alias = ANY (l.of_list)
|
|
1342
|
+
OR lower(l.relname) = ANY (l.of_list)
|
|
1343
|
+
UNION ALL
|
|
1344
|
+
-- (ii) UPDATE row-locks the updated rows
|
|
1345
|
+
SELECT l.fn_oid, l.rel_oid, 'update', 'write-update', strpos(l.body, l.upd_hit)
|
|
1346
|
+
FROM lit l WHERE l.upd_hit IS NOT NULL
|
|
1347
|
+
UNION ALL
|
|
1348
|
+
-- (iii) DELETE row-locks the deleted rows
|
|
1349
|
+
SELECT l.fn_oid, l.rel_oid, 'update', 'write-delete', strpos(l.body, l.del_hit)
|
|
1350
|
+
FROM lit l WHERE l.del_hit IS NOT NULL
|
|
1351
|
+
UNION ALL
|
|
1352
|
+
-- (iv) INSERT takes FOR KEY SHARE on every referenced parent row
|
|
1353
|
+
SELECT l.fn_oid, k.confrelid, 'key share', 'fk-insert:' || k.conname, strpos(l.body, l.ins_hit)
|
|
1354
|
+
FROM lit l
|
|
1355
|
+
JOIN pg_constraint k ON k.conrelid = l.rel_oid AND k.contype = 'f'
|
|
1356
|
+
WHERE l.ins_hit IS NOT NULL
|
|
1357
|
+
UNION ALL
|
|
1358
|
+
-- (v) an UPDATE that re-points an FK column locks the NEW parent row
|
|
1359
|
+
SELECT u.fn_oid, u.confrelid, 'key share', 'fk-update:' || u.conname, u.pos
|
|
1360
|
+
FROM upd u
|
|
1361
|
+
UNION ALL
|
|
1362
|
+
-- (vi) DELETE of a referenced row locks the referencing child rows
|
|
1363
|
+
SELECT l.fn_oid, k.conrelid,
|
|
1364
|
+
CASE k.confdeltype WHEN 'c' THEN 'update'
|
|
1365
|
+
WHEN 'n' THEN 'no key update'
|
|
1366
|
+
WHEN 'd' THEN 'no key update'
|
|
1367
|
+
ELSE 'key share' END,
|
|
1368
|
+
'fk-delete:' || k.conname, strpos(l.body, l.del_hit)
|
|
1369
|
+
FROM lit l
|
|
1370
|
+
JOIN pg_constraint k ON k.confrelid = l.rel_oid AND k.contype = 'f'
|
|
1371
|
+
WHERE l.del_hit IS NOT NULL
|
|
1372
|
+
),
|
|
1373
|
+
-- One pattern per (callee, qualification) rather than one per pair, and the same name-substring
|
|
1374
|
+
-- prefilter before the matcher runs.
|
|
1375
|
+
pats AS MATERIALIZED (
|
|
1376
|
+
SELECT oid, proname,
|
|
1377
|
+
agent_board_test.call_re(proname, true) AS pat_bare,
|
|
1378
|
+
agent_board_test.call_re(proname, false) AS pat_qualified
|
|
1379
|
+
FROM fns
|
|
1380
|
+
),
|
|
1381
|
+
callcand AS MATERIALIZED (
|
|
1382
|
+
SELECT c.oid AS caller, c.body, e.oid AS callee, e.proname AS callee_name,
|
|
1383
|
+
CASE WHEN c.bare THEN e.pat_bare ELSE e.pat_qualified END AS pat
|
|
1384
|
+
FROM fns c JOIN pats e ON e.oid <> c.oid
|
|
1385
|
+
WHERE strpos(lower(c.body), lower(e.proname)) > 0
|
|
1386
|
+
),
|
|
1387
|
+
calls AS (
|
|
1388
|
+
SELECT x.caller, x.callee, x.callee_name, strpos(x.body, h.hit) AS pos
|
|
1389
|
+
FROM callcand x
|
|
1390
|
+
CROSS JOIN LATERAL (SELECT substring(x.body FROM x.pat) AS hit) h
|
|
1391
|
+
WHERE h.hit IS NOT NULL
|
|
1392
|
+
),
|
|
1393
|
+
reach AS (
|
|
1394
|
+
SELECT d.fn_oid, d.rel_oid, d.lock_mode, d.via, array[d.pos] AS path,
|
|
1395
|
+
true AS own, d.fn_oid AS origin_oid
|
|
1396
|
+
FROM direct d
|
|
1397
|
+
UNION ALL
|
|
1398
|
+
SELECT c.caller, r.rel_oid, r.lock_mode, 'callee:' || c.callee_name,
|
|
1399
|
+
c.pos || r.path, false, r.origin_oid
|
|
1400
|
+
FROM reach r JOIN calls c ON c.callee = r.fn_oid
|
|
1401
|
+
) CYCLE fn_oid SET is_cycle USING cycle_path
|
|
1402
|
+
SELECT r.fn_oid, f.proname, f.sig, r.rel_oid,
|
|
1403
|
+
(SELECT n.nspname || '.' || cl.relname FROM pg_class cl
|
|
1404
|
+
JOIN pg_namespace n ON n.oid = cl.relnamespace WHERE cl.oid = r.rel_oid),
|
|
1405
|
+
r.lock_mode, r.via, r.path, r.own, r.origin_oid,
|
|
1406
|
+
(SELECT proname FROM fns WHERE oid = r.origin_oid)
|
|
1407
|
+
FROM reach r JOIN fns f ON f.oid = r.fn_oid
|
|
1408
|
+
WHERE NOT r.is_cycle;
|
|
1409
|
+
$lockedges$;
|
|
1410
|
+
|
|
1411
|
+
-- The name-resolved call closure (reflexive), so a test can ask "does this route
|
|
1412
|
+
-- reach <serialising helper>" without re-deriving the graph.
|
|
1413
|
+
CREATE OR REPLACE FUNCTION agent_board_test.fn_call_closure()
|
|
1414
|
+
RETURNS TABLE (root_oid oid, root_name text, reached_oid oid, reached_name text)
|
|
1415
|
+
LANGUAGE sql STABLE
|
|
1416
|
+
SET search_path = pg_catalog
|
|
1417
|
+
AS $closure$
|
|
1418
|
+
WITH RECURSIVE
|
|
1419
|
+
fns AS (
|
|
1420
|
+
SELECT p.oid, p.proname, agent_board_test.stripped_body(p.oid) AS body,
|
|
1421
|
+
agent_board_test.bare_ok(p.oid) AS bare
|
|
1422
|
+
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
1423
|
+
WHERE n.nspname = 'agent_board' AND p.prokind IN ('f','p')
|
|
1424
|
+
AND p.prolang <> (SELECT oid FROM pg_language WHERE lanname = 'internal')
|
|
1425
|
+
),
|
|
1426
|
+
pats AS MATERIALIZED (
|
|
1427
|
+
SELECT oid, proname,
|
|
1428
|
+
agent_board_test.call_re(proname, true) AS pat_bare,
|
|
1429
|
+
agent_board_test.call_re(proname, false) AS pat_qualified
|
|
1430
|
+
FROM fns
|
|
1431
|
+
),
|
|
1432
|
+
-- Same prefilter-then-match shape as lock_edges(): the substring test is looser than the matcher
|
|
1433
|
+
-- and MATERIALIZED keeps the planner from running the matcher on every pair.
|
|
1434
|
+
callcand AS MATERIALIZED (
|
|
1435
|
+
SELECT c.oid AS caller, c.body, e.oid AS callee,
|
|
1436
|
+
CASE WHEN c.bare THEN e.pat_bare ELSE e.pat_qualified END AS pat
|
|
1437
|
+
FROM fns c JOIN pats e ON e.oid <> c.oid
|
|
1438
|
+
WHERE strpos(lower(c.body), lower(e.proname)) > 0
|
|
1439
|
+
),
|
|
1440
|
+
calls AS (
|
|
1441
|
+
SELECT x.caller, x.callee FROM callcand x WHERE x.body ~ x.pat
|
|
1442
|
+
),
|
|
1443
|
+
closure AS (
|
|
1444
|
+
SELECT oid AS root, oid AS reached FROM fns
|
|
1445
|
+
UNION
|
|
1446
|
+
SELECT c.root, e.callee FROM closure c JOIN calls e ON e.caller = c.reached
|
|
1447
|
+
)
|
|
1448
|
+
SELECT c.root, (SELECT proname FROM fns WHERE oid = c.root),
|
|
1449
|
+
c.reached, (SELECT proname FROM fns WHERE oid = c.reached)
|
|
1450
|
+
FROM closure c;
|
|
1451
|
+
$closure$;
|
|
1452
|
+
|
|
1453
|
+
COMMENT ON FUNCTION agent_board_test.lock_edges() IS
|
|
1454
|
+
'FK-aware per-function row-lock edge set: explicit locks, write locks, and the FOR KEY SHARE / child locks PostgreSQL takes through foreign keys. Test-only; see message-board#281.';
|