-- Deterministic: no timestamps, no oids, no catalog-wide scans. The temporary -- schema is created here so the catalog sources have something they own to read -- -- reading the whole cluster would make the expected output depend on what -- else happens to be installed. -- terse: the default DETAIL prints failing rows carrying clock_timestamp and -- current_user, and CONTEXT prints plpgsql line numbers. Both would make the -- expected output depend on the machine. \set VERBOSITY terse -- CASCADE because 0.3.0 requires pg_living_assertions: the guard half lives -- there now. CREATE EXTENSION pg_grammar_guard CASCADE; SET search_path = grammar_guard, public; CREATE SCHEMA gg_test; CREATE TABLE gg_test.clientes (id int, rut text, nombre text); CREATE TABLE gg_test.facturas (id int, cliente_id int, monto numeric); CREATE VIEW gg_test.vigentes AS SELECT * FROM gg_test.facturas; CREATE TYPE gg_test.estado AS ENUM ('abierta', 'pagada', 'anulada'); -- ---------------------------------------------------------------- sources -- SELECT catalog_tables(ARRAY['gg_test']); SELECT catalog_columns('gg_test.clientes'); SELECT catalog_enum('gg_test.estado'); -- A dropped column must disappear from the columns and therefore from the -- grammar. This is the whole premise: the grammar tracks the catalog. ALTER TABLE gg_test.clientes DROP COLUMN rut; SELECT catalog_columns('gg_test.clientes'); -- ------------------------------------------------------------- generation -- SELECT grammar_for_json(ARRAY[ ROW('table', 'enum', catalog_tables(ARRAY['gg_test']), true), ROW('column', 'enum', catalog_columns('gg_test.facturas'), true), ROW('limit', 'integer', NULL, false) ]::grammar_field[]); -- Same fields, the other dialect. SELECT grammar_for_json(ARRAY[ ROW('table', 'enum', catalog_tables(ARRAY['gg_test']), true), ROW('limit', 'integer', NULL, false) ]::grammar_field[], 'json_schema'); -- ---------------------------------------------------------------- quoting -- -- A value carrying a quote, a backslash and a newline. Both quoting levels have -- to survive: the JSON one and the GBNF one. Getting the order wrong here does -- not raise an error, it produces a grammar that permits a different language. SELECT grammar_for_json(ARRAY[ ROW('v', 'enum', ARRAY['plain', 'has"quote', 'has\back', E'has\nnewline'], true) ]::grammar_field[]); -- Two field names that sanitise to the same rule name. Without the ordinal they -- would collide and one field would silently get the other's permitted values. SELECT grammar_for_json(ARRAY[ ROW('a.b', 'enum', ARRAY['x'], true), ROW('a-b', 'enum', ARRAY['y'], true) ]::grammar_field[]); -- --------------------------------------------------------------- refusals -- -- Each of these is refused rather than compiled into something subtly wrong. SELECT grammar_for_json(ARRAY[ROW('v', 'enum', '{}'::text[], true)]::grammar_field[]); SELECT grammar_for_json(ARRAY[ROW('v', 'enum', ARRAY['x'], false)]::grammar_field[]); SELECT grammar_for_json(ARRAY[ROW('v', 'colour', NULL, true)]::grammar_field[]); SELECT grammar_for_json(ARRAY[ROW('v', 'enum', ARRAY['x'], true)]::grammar_field[], 'lark'); SELECT grammar_for_json('{}'::grammar_field[]); -- ------------------------------------------------------------ fingerprint -- -- Over the fields, not over the emitted text: the two dialects of one world -- must fingerprint the same, or approving one would read as drift in the other. SELECT grammar_fingerprint(ARRAY[ROW('t', 'enum', ARRAY['a', 'b'], true)]::grammar_field[]) = grammar_fingerprint(ARRAY[ROW('t', 'enum', ARRAY['a', 'b'], true)]::grammar_field[]) AS stable; -- Order of enum values is part of what the catalog said, so it changes it. SELECT grammar_fingerprint(ARRAY[ROW('t', 'enum', ARRAY['a', 'b'], true)]::grammar_field[]) <> grammar_fingerprint(ARRAY[ROW('t', 'enum', ARRAY['b', 'a'], true)]::grammar_field[]) AS order_matters; -- ------------------------------------------------------------------ guard -- -- The guard half is pg_living_assertions since 0.3.0. What is under test here -- is the BEHAVIOUR, and it is unchanged: what moved is where it lives. -- -- Never approved is still its own answer and still not drift. It is simply no -- longer a severity this extension had to invent for itself -- and the registry -- says `unregistered` rather than returning an empty result, because empty -- reads as a clean bill of health. SELECT living_assertions.state('grammar:answer') AS never_approved; -- watch() takes the QUERY that rebuilds the spec, not the spec. This is the -- defect the move exposed: check_grammar(name, fields) let the CALLER bring the -- world, so a stale variable compared the baseline against something that was -- not the catalog and reported no drift. SELECT watch('answer', $q$select jsonb_build_array(jsonb_build_object( 'name', 'table', 'kind', 'enum', 'required', true, 'values', to_jsonb(grammar_guard.catalog_tables(ARRAY['gg_test']))))$q$, 'the tables a question may name') > 0 AS watched; -- Approved and unchanged: holds. This is the half a monitor gets wrong. SELECT check_grammar('answer') AS unchanged; -- The world moves. Nobody re-supplies the spec: the stored check rebuilds it -- from the catalog, which is what makes this runnable by a cron job or a deploy -- gate instead of only by whoever approved it. CREATE TABLE gg_test.pagos (id int); SELECT check_grammar('answer') AS after_the_world_moved; -- And the verdict never travels without its age -- the whole thesis of the -- piece. An old `holds` reads exactly like a fresh one and means something else. SELECT name, state, age IS NOT NULL AS carries_its_age FROM living_assertions.status WHERE name = 'grammar:answer'; -- Re-approving makes it quiet again, which is what makes the check usable -- twice: a monitor that cannot be acknowledged gets ignored. Superseding costs -- writing down why, and it retires the previous one in the same statement. SELECT living_assertions.declare( 'grammar:answer', 'the approved grammar still describes the live catalog', format($f$select grammar_guard.grammar_fingerprint((%s)::jsonb) = %L as holds$f$, $q$select jsonb_build_array(jsonb_build_object( 'name', 'table', 'kind', 'enum', 'required', true, 'values', to_jsonb(grammar_guard.catalog_tables(ARRAY['gg_test']))))$q$, grammar_fingerprint(jsonb_build_array(jsonb_build_object( 'name', 'table', 'kind', 'enum', 'required', true, 'values', to_jsonb(catalog_tables(ARRAY['gg_test'])))))), 'grammar:answer', 'the catalog gained a table and the new one is now the approved world') > 0 AS reapproved; SELECT check_grammar('answer') AS quiet_again; -- And the replacement is on the record, with what the old one last said. It -- cannot know whether an arbitrary SQL check got looser -- that is undecidable -- -- so it reports the timing, which is the part that accuses. SELECT name, last_state_before, what_happened FROM living_assertions.renegotiated ORDER BY name; -- ------------------------------------------------- arrays and nesting (0.2) -- -- The shape of a real tool call, which 0.1.0 could not express at all. SELECT grammar_for('[ {"name": "action", "kind": "enum", "values": ["select", "count"], "required": true}, {"name": "columns", "kind": "array", "required": true, "items": {"kind": "enum", "values": ["id", "monto"]}}, {"name": "filter", "kind": "object", "required": false, "fields": [ {"name": "column", "kind": "enum", "values": ["id"], "required": true}, {"name": "op", "kind": "enum", "values": ["=", "<"], "required": true}, {"name": "value", "kind": "integer", "required": false} ]} ]'::jsonb); -- An array OF objects: two levels of recursion, which is where a hand-written -- nested case would have started disagreeing with the root. SELECT grammar_for('[ {"name": "edits", "kind": "array", "required": true, "items": { "kind": "object", "fields": [ {"name": "path", "kind": "enum", "values": ["a.ts"], "required": true}, {"name": "text", "kind": "string", "required": true} ]}} ]'::jsonb); -- REGRESSION: arrays are bounded. An unbounded ( ... )* is a loop waiting to -- happen -- measured against a local 35B, an array of enum emitted -- ["id","id","id", ...] forty-one times until it ran out of budget, every token -- legal. A model stuck in that looks exactly like a model working, which is the -- worst thing a grammar can do. Default 16, overridable per field. SELECT grammar_for('[{"name": "xs", "kind": "array", "required": true, "max_items": 3, "items": {"kind": "enum", "values": ["a"]}}]'::jsonb); SELECT grammar_for('[{"name": "xs", "kind": "array", "required": true, "items": {"kind": "enum", "values": ["a"]}}]'::jsonb); SELECT grammar_for('[{"name": "xs", "kind": "array", "required": true, "max_items": 0, "items": {"kind": "enum", "values": ["a"]}}]'::jsonb); -- min_items exists because the measurement found real arrays of length ZERO. -- Requiring one element would make a legitimate empty list unreachable -- the -- same failure as an unbounded array, in the other direction. SELECT grammar_for('[{"name": "xs", "kind": "array", "required": true, "min_items": 0, "max_items": 2, "items": {"kind": "enum", "values": ["a"]}}]'::jsonb); SELECT grammar_for('[{"name": "xs", "kind": "array", "required": true, "min_items": 2, "max_items": 4, "items": {"kind": "enum", "values": ["a"]}}]'::jsonb); SELECT grammar_for('[{"name": "xs", "kind": "array", "required": true, "min_items": 5, "max_items": 2, "items": {"kind": "enum", "values": ["a"]}}]'::jsonb); -- --------------------------------------------------------- correlation -- -- The canonical case, and the reason this extension exists: without it a -- grammar permits {"table":"facturas","column":"nombre"} where nombre belongs -- to clientes -- well formed and impossible. SELECT grammar_for(jsonb_build_array( catalog_correlated(ARRAY['gg_test.clientes', 'gg_test.facturas']))); -- Built straight from the catalog, so it tracks a dropped column like the rest. SELECT (catalog_correlated(ARRAY['gg_test.clientes']) -> 'dependents' -> 0 -> 'by_value' -> 'gg_test.clientes') AS columnas_de_clientes; -- With another required field alongside: it is shared between branches and its -- rule is emitted once, not once per pivot value. SELECT grammar_for(jsonb_build_array( catalog_correlated(ARRAY['gg_test.clientes', 'gg_test.facturas']), jsonb_build_object('name','limit','kind','integer','required',true))); -- Refusals specific to correlation. -- El pivote no puede ser opcional. SELECT grammar_for('[{"name":"t","kind":"enum","values":["a"],"required":false, "dependents":[{"name":"c","kind":"enum","required":true,"by_value":{"a":["x"]}}]}]'::jsonb); -- Un valor del pivote sin columnas legales haria esa rama insatisfacible: el -- modelo puede entrar y quedarse sin ningun token legal. SELECT grammar_for('[{"name":"t","kind":"enum","values":["a","b"],"required":true, "dependents":[{"name":"c","kind":"enum","required":true,"by_value":{"a":["x"]}}]}]'::jsonb); -- Dos pivotes pediria una alternativa por COMBINACION -- la explosion que la -- gente espera de esto y que no ocurre, justamente porque se rechaza. SELECT grammar_for('[{"name":"t","kind":"enum","values":["a"],"required":true, "dependents":[{"name":"c","kind":"enum","required":true,"by_value":{"a":["x"]}}]}, {"name":"u","kind":"enum","values":["a"],"required":true, "dependents":[{"name":"d","kind":"enum","required":true,"by_value":{"a":["y"]}}]}]'::jsonb); -- Y un dependiente declarado DOS veces. Este caso existe por el defecto que lo -- destapo: la primera version emitia el pivote sin sus dependientes cuando no -- estaban tambien en fields, y salia una gramatica valida a la que le FALTABA un -- campo. Nada fallaba; el objeto simplemente venia corto. SELECT grammar_for('[{"name":"t","kind":"enum","values":["a"],"required":true, "dependents":[{"name":"c","kind":"enum","required":true,"by_value":{"a":["x"]}}]}, {"name":"c","kind":"enum","values":["x"],"required":true}]'::jsonb); -- Refusals, each one preferred over compiling something subtly wrong. SELECT grammar_for('[{"name": "xs", "kind": "array", "required": true}]'::jsonb); SELECT grammar_for('[{"name": "o", "kind": "object", "required": true, "fields": []}]'::jsonb); SELECT grammar_for('[{"name": "o", "kind": "object", "required": true, "fields": [{"name": "a", "kind": "string", "required": false}]}]'::jsonb); SELECT grammar_for('[{"name": "v", "kind": "enum", "values": [], "required": true}]'::jsonb); SELECT grammar_for('[]'::jsonb); SELECT grammar_for('[{"name": "v", "kind": "enum", "values": ["x"], "required": true}]'::jsonb, 'lark'); -- The jsonb fingerprint ignores how the spec was written, which is what makes -- approve() usable: the same world approved twice must not read as drift. SELECT grammar_fingerprint('[{"name":"t","kind":"enum","values":["a"],"required":true}]'::jsonb) = grammar_fingerprint('[{"kind":"enum","name":"t","required":true,"values":["a"]}]'::jsonb) AS key_order_ignored; -- And the guard half works on the nested spec too, in both directions. SELECT living_assertions.state('grammar:nested') AS before_watching; SELECT watch('nested', $q$select '[{"name":"t","kind":"enum","values":["a"],"required":true}]'::jsonb$q$) > 0 AS watched; SELECT check_grammar('nested') AS unchanged; -- REGRESSION, and the honest half of the port: a spec that is a CONSTANT can -- never drift, so this check compares the world against itself forever. Under -- 0.2.0 that was invisible -- the caller passed the same literal twice and got -- a reassuring silence. Here it is at least stored where it can be read. -- The catalog-derived spec used for 'answer' above is the shape that is worth -- anything. SELECT check_grammar('nested') AS a_constant_spec_cannot_drift; DROP SCHEMA gg_test CASCADE; DROP EXTENSION pg_grammar_guard CASCADE; -- The registry goes too, so the upgrade half below starts from nothing. Note -- what this implies and the README says out loud: assertions outlive the -- consumer that declared them, and an orphaned one turns `erroring` rather than -- disappearing -- loud, which is the right direction for this extension. DROP EXTENSION pg_living_assertions CASCADE; -- ------------------------------------------------------------- the upgrade -- -- The path that matters for anyone already on 0.1.0. Without this test the -- upgrade script could be broken and nobody would find out until a user ran it -- -- and the only alternative for them would be DROP + CREATE, which takes -- approved_grammars with it: exactly the baselines the guard half exists to keep. -- CASCADE even for 0.1.0, which never needed it: `requires` in the control file -- is not per-version, so from 0.3.0 onwards installing ANY version of this -- extension pulls pg_living_assertions. Worth knowing before an old pin fails -- on a host that does not have it. CREATE EXTENSION pg_grammar_guard VERSION '0.1.0' CASCADE; SELECT approve('survives', ARRAY[ROW('t', 'enum', ARRAY['a'], true)]::grammar_field[], 'gbnf', 'approved before the upgrade') IS NOT NULL AS approved_on_0_1_0; ALTER EXTENSION pg_grammar_guard UPDATE TO '0.2.0'; SELECT extversion FROM pg_extension WHERE extname = 'pg_grammar_guard'; -- The baseline is still there, and the flat API still answers. SELECT name, note FROM approved_grammars WHERE name = 'survives'; SELECT count(*) AS breaks FROM check_grammar('survives', ARRAY[ROW('t', 'enum', ARRAY['a'], true)]::grammar_field[]); -- And the new one answers too. SELECT grammar_for('[{"name":"v","kind":"enum","values":["x"],"required":true}]'::jsonb); -- --------------------------------------------------- 0.2.0 -> 0.3.0 -- -- The step that moves the guard half out. It WARNS instead of succeeding -- quietly, because 0.2.0 stored a fingerprint and never the query that rebuilds -- the spec -- so these baselines cannot be re-checked by anyone, and an upgrade -- that left you silently unwatched would be this extension's own subject matter -- happening to its users. ALTER EXTENSION pg_grammar_guard UPDATE TO '0.3.0'; SELECT extversion FROM pg_extension WHERE extname = 'pg_grammar_guard'; -- Kept, not dropped: it is the only record of what somebody had approved. SELECT name, note FROM baselines_from_0_2_0 WHERE name = 'survives'; -- And nothing is watching it until a human names the query again. `unregistered` -- rather than an empty result, which would read as a clean bill of health. SELECT living_assertions.state('grammar:survives') AS after_the_upgrade; SELECT watch('survives', $q$select '[{"name":"t","kind":"enum","values":["a"],"required":true}]'::jsonb$q$, 're-approved by hand after the 0.3.0 upgrade') > 0 AS rewatched; SELECT check_grammar('survives') AS watched_again; -- --------------------------------------------------- 0.3.0 -> 0.4.0 -- -- watch() stops rebuilding the freeze-and-compare by hand. Assertions watched -- under 0.3.0 keep their stored check_sql and keep answering the same thing: -- the generated check is equivalent, not shared. ALTER EXTENSION pg_grammar_guard UPDATE TO '0.4.0'; SELECT extversion FROM pg_extension WHERE extname = 'pg_grammar_guard'; SELECT check_grammar('survives') AS still_answers_after_the_upgrade; -- And the new watch() behaves the same, in both directions. CREATE SCHEMA gg4; CREATE TABLE gg4.uno (id int); SELECT watch('on_0_4_0', $q$select jsonb_build_array(jsonb_build_object( 'name', 'table', 'kind', 'enum', 'required', true, 'values', to_jsonb(grammar_guard.catalog_tables(ARRAY['gg4']))))$q$) > 0 AS watched; SELECT check_grammar('on_0_4_0') AS unchanged; CREATE TABLE gg4.dos (id int); SELECT check_grammar('on_0_4_0') AS after_the_world_moved; DROP SCHEMA gg4 CASCADE; DROP EXTENSION pg_grammar_guard CASCADE;