-- 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; NOTICE: installing required extension "pg_living_assertions" 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']); catalog_tables ------------------------------------------------------ {gg_test.clientes,gg_test.facturas,gg_test.vigentes} (1 row) SELECT catalog_columns('gg_test.clientes'); catalog_columns ----------------- {id,rut,nombre} (1 row) SELECT catalog_enum('gg_test.estado'); catalog_enum -------------------------- {abierta,pagada,anulada} (1 row) -- 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'); catalog_columns ----------------- {id,nombre} (1 row) -- ------------------------------------------------------------- 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[]); grammar_for_json ------------------------------------------------------------------------------------------------------------------------------------------------ root ::= "{" ws "\"table\"" ws ":" ws f1-table ws "," ws "\"column\"" ws ":" ws f2-column ws ( "," ws "\"limit\"" ws ":" ws f3-limit ws )? "}"+ f1-table ::= "\"gg_test.clientes\"" | "\"gg_test.facturas\"" | "\"gg_test.vigentes\"" + f2-column ::= "\"id\"" | "\"cliente_id\"" | "\"monto\"" + f3-limit ::= integer + string ::= "\"" char* "\"" + char ::= [^"\\\x00-\x1F] | "\\" (["\\/bfnrt] | "u" hex hex hex hex) + hex ::= [0-9a-fA-F] + integer ::= "-"? ("0" | [1-9] [0-9]*) + number ::= integer ("." [0-9]+)? ([eE] [-+]? [0-9]+)? + boolean ::= "true" | "false" + ws ::= [ \t\n]* + (1 row) -- 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'); grammar_for_json ------------------------------------- { + "type": "object", + "required": [ + "table" + ], + "properties": { + "limit": { + "type": "integer" + }, + "table": { + "enum": [ + "gg_test.clientes",+ "gg_test.facturas",+ "gg_test.vigentes" + ], + "type": "string" + } + }, + "additionalProperties": false + } (1 row) -- ---------------------------------------------------------------- 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[]); grammar_for_json ------------------------------------------------------------------------------------- root ::= "{" ws "\"v\"" ws ":" ws f1-v ws "}" + f1-v ::= "\"plain\"" | "\"has\\\"quote\"" | "\"has\\\\back\"" | "\"has\\nnewline\""+ ws ::= [ \t\n]* + (1 row) -- 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[]); grammar_for_json ---------------------------------------------------------------------------------------- root ::= "{" ws "\"a.b\"" ws ":" ws f1-a-b ws "," ws "\"a-b\"" ws ":" ws f2-a-b ws "}"+ f1-a-b ::= "\"x\"" + f2-a-b ::= "\"y\"" + ws ::= [ \t\n]* + (1 row) -- --------------------------------------------------------------- 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[]); ERROR: field v: enum with no values SELECT grammar_for_json(ARRAY[ROW('v', 'enum', ARRAY['x'], false)]::grammar_field[]); ERROR: every field is optional SELECT grammar_for_json(ARRAY[ROW('v', 'colour', NULL, true)]::grammar_field[]); ERROR: field v: unknown kind colour SELECT grammar_for_json(ARRAY[ROW('v', 'enum', ARRAY['x'], true)]::grammar_field[], 'lark'); ERROR: unknown dialect: lark SELECT grammar_for_json('{}'::grammar_field[]); ERROR: no fields given -- ------------------------------------------------------------ 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; stable -------- t (1 row) -- 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; order_matters --------------- t (1 row) -- ------------------------------------------------------------------ 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; never_approved ---------------- unregistered (1 row) -- 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; watched --------- t (1 row) -- Approved and unchanged: holds. This is the half a monitor gets wrong. SELECT check_grammar('answer') AS unchanged; unchanged ----------- holds (1 row) -- 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; after_the_world_moved ----------------------- broken (1 row) -- 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'; name | state | carries_its_age ----------------+--------+----------------- grammar:answer | broken | t (1 row) -- 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; reapproved ------------ t (1 row) SELECT check_grammar('answer') AS quiet_again; quiet_again ------------- holds (1 row) -- 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; name | last_state_before | what_happened ----------------+-------------------+----------------------- grammar:answer | broken | REPLACED WHILE BROKEN (1 row) -- ------------------------------------------------- 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); grammar_for ---------------------------------------------------------------------------------------------------------------------------------------------- root ::= "{" ws "\"action\"" ws ":" ws root-0 ws "," ws "\"columns\"" ws ":" ws root-1 ws ( "," ws "\"filter\"" ws ":" ws root-2 ws )? "}" + root-0 ::= "\"select\"" | "\"count\"" + root-1 ::= "[" ws root-1-i (ws "," ws root-1-i){0,31} ws "]" + root-1-i ::= "\"id\"" | "\"monto\"" + root-2 ::= "{" ws "\"column\"" ws ":" ws root-2-0 ws "," ws "\"op\"" ws ":" ws root-2-1 ws ( "," ws "\"value\"" ws ":" ws root-2-2 ws )? "}"+ root-2-0 ::= "\"id\"" + root-2-1 ::= "\"=\"" | "\"<\"" + root-2-2 ::= integer + string ::= "\"" char* "\"" + char ::= [^"\\\x00-\x1F] | "\\" (["\\/bfnrt] | "u" hex hex hex hex) + hex ::= [0-9a-fA-F] + integer ::= "-"? ("0" | [1-9] [0-9]*) + number ::= integer ("." [0-9]+)? ([eE] [-+]? [0-9]+)? + boolean ::= "true" | "false" + ws ::= [ \t\n]* + (1 row) -- 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); grammar_for ------------------------------------------------------------------------------------------------------ root ::= "{" ws "\"edits\"" ws ":" ws root-0 ws "}" + root-0 ::= "[" ws root-0-i (ws "," ws root-0-i){0,31} ws "]" + root-0-i ::= "{" ws "\"path\"" ws ":" ws root-0-i-0 ws "," ws "\"text\"" ws ":" ws root-0-i-1 ws "}"+ root-0-i-0 ::= "\"a.ts\"" + root-0-i-1 ::= string + string ::= "\"" char* "\"" + char ::= [^"\\\x00-\x1F] | "\\" (["\\/bfnrt] | "u" hex hex hex hex) + hex ::= [0-9a-fA-F] + integer ::= "-"? ("0" | [1-9] [0-9]*) + number ::= integer ("." [0-9]+)? ([eE] [-+]? [0-9]+)? + boolean ::= "true" | "false" + ws ::= [ \t\n]* + (1 row) -- 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); grammar_for --------------------------------------------------------------------- root ::= "{" ws "\"xs\"" ws ":" ws root-0 ws "}" + root-0 ::= "[" ws root-0-i (ws "," ws root-0-i){0,2} ws "]" + root-0-i ::= "\"a\"" + string ::= "\"" char* "\"" + char ::= [^"\\\x00-\x1F] | "\\" (["\\/bfnrt] | "u" hex hex hex hex)+ hex ::= [0-9a-fA-F] + integer ::= "-"? ("0" | [1-9] [0-9]*) + number ::= integer ("." [0-9]+)? ([eE] [-+]? [0-9]+)? + boolean ::= "true" | "false" + ws ::= [ \t\n]* + (1 row) SELECT grammar_for('[{"name": "xs", "kind": "array", "required": true, "items": {"kind": "enum", "values": ["a"]}}]'::jsonb); grammar_for --------------------------------------------------------------------- root ::= "{" ws "\"xs\"" ws ":" ws root-0 ws "}" + root-0 ::= "[" ws root-0-i (ws "," ws root-0-i){0,31} ws "]" + root-0-i ::= "\"a\"" + string ::= "\"" char* "\"" + char ::= [^"\\\x00-\x1F] | "\\" (["\\/bfnrt] | "u" hex hex hex hex)+ hex ::= [0-9a-fA-F] + integer ::= "-"? ("0" | [1-9] [0-9]*) + number ::= integer ("." [0-9]+)? ([eE] [-+]? [0-9]+)? + boolean ::= "true" | "false" + ws ::= [ \t\n]* + (1 row) SELECT grammar_for('[{"name": "xs", "kind": "array", "required": true, "max_items": 0, "items": {"kind": "enum", "values": ["a"]}}]'::jsonb); ERROR: field xs: max_items must be >= 1 -- 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); grammar_for --------------------------------------------------------------------- root ::= "{" ws "\"xs\"" ws ":" ws root-0 ws "}" + root-0 ::= "[" ws ( root-0-i (ws "," ws root-0-i){0,1} ws )? "]" + root-0-i ::= "\"a\"" + string ::= "\"" char* "\"" + char ::= [^"\\\x00-\x1F] | "\\" (["\\/bfnrt] | "u" hex hex hex hex)+ hex ::= [0-9a-fA-F] + integer ::= "-"? ("0" | [1-9] [0-9]*) + number ::= integer ("." [0-9]+)? ([eE] [-+]? [0-9]+)? + boolean ::= "true" | "false" + ws ::= [ \t\n]* + (1 row) SELECT grammar_for('[{"name": "xs", "kind": "array", "required": true, "min_items": 2, "max_items": 4, "items": {"kind": "enum", "values": ["a"]}}]'::jsonb); grammar_for --------------------------------------------------------------------- root ::= "{" ws "\"xs\"" ws ":" ws root-0 ws "}" + root-0 ::= "[" ws root-0-i (ws "," ws root-0-i){1,3} ws "]" + root-0-i ::= "\"a\"" + string ::= "\"" char* "\"" + char ::= [^"\\\x00-\x1F] | "\\" (["\\/bfnrt] | "u" hex hex hex hex)+ hex ::= [0-9a-fA-F] + integer ::= "-"? ("0" | [1-9] [0-9]*) + number ::= integer ("." [0-9]+)? ([eE] [-+]? [0-9]+)? + boolean ::= "true" | "false" + ws ::= [ \t\n]* + (1 row) SELECT grammar_for('[{"name": "xs", "kind": "array", "required": true, "min_items": 5, "max_items": 2, "items": {"kind": "enum", "values": ["a"]}}]'::jsonb); ERROR: field xs: min_items must be between 0 and max_items -- --------------------------------------------------------- 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']))); grammar_for -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- root ::= "{" ws "\"table\"" ws ":" ws "\"gg_test.clientes\"" ws "," ws "\"column\"" ws ":" ws root-v0-d0 ws "}" | "{" ws "\"table\"" ws ":" ws "\"gg_test.facturas\"" ws "," ws "\"column\"" ws ":" ws root-v1-d0 ws "}"+ root-v0-d0 ::= "\"id\"" | "\"nombre\"" + root-v1-d0 ::= "\"id\"" | "\"cliente_id\"" | "\"monto\"" + string ::= "\"" char* "\"" + char ::= [^"\\\x00-\x1F] | "\\" (["\\/bfnrt] | "u" hex hex hex hex) + hex ::= [0-9a-fA-F] + integer ::= "-"? ("0" | [1-9] [0-9]*) + number ::= integer ("." [0-9]+)? ([eE] [-+]? [0-9]+)? + boolean ::= "true" | "false" + ws ::= [ \t\n]* + (1 row) -- 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; columnas_de_clientes ---------------------- ["id", "nombre"] (1 row) -- 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))); grammar_for -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- root ::= "{" ws "\"table\"" ws ":" ws "\"gg_test.clientes\"" ws "," ws "\"column\"" ws ":" ws root-v0-d0 ws "," ws "\"limit\"" ws ":" ws root-1 ws "}" | "{" ws "\"table\"" ws ":" ws "\"gg_test.facturas\"" ws "," ws "\"column\"" ws ":" ws root-v1-d0 ws "," ws "\"limit\"" ws ":" ws root-1 ws "}"+ root-v0-d0 ::= "\"id\"" | "\"nombre\"" + root-1 ::= integer + root-v1-d0 ::= "\"id\"" | "\"cliente_id\"" | "\"monto\"" + string ::= "\"" char* "\"" + char ::= [^"\\\x00-\x1F] | "\\" (["\\/bfnrt] | "u" hex hex hex hex) + hex ::= [0-9a-fA-F] + integer ::= "-"? ("0" | [1-9] [0-9]*) + number ::= integer ("." [0-9]+)? ([eE] [-+]? [0-9]+)? + boolean ::= "true" | "false" + ws ::= [ \t\n]* + (1 row) -- 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); ERROR: field t: a correlated field must be required -- 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); ERROR: field c: no values for t = b -- 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); ERROR: field root: two correlated fields in one object -- 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); ERROR: field c: a dependent is declared inside dependents, not again in fields -- Refusals, each one preferred over compiling something subtly wrong. SELECT grammar_for('[{"name": "xs", "kind": "array", "required": true}]'::jsonb); ERROR: field xs: array without items SELECT grammar_for('[{"name": "o", "kind": "object", "required": true, "fields": []}]'::jsonb); ERROR: field o: object with no fields SELECT grammar_for('[{"name": "o", "kind": "object", "required": true, "fields": [{"name": "a", "kind": "string", "required": false}]}]'::jsonb); ERROR: field o: every subfield is optional SELECT grammar_for('[{"name": "v", "kind": "enum", "values": [], "required": true}]'::jsonb); ERROR: field v: enum with no values SELECT grammar_for('[]'::jsonb); ERROR: no fields given SELECT grammar_for('[{"name": "v", "kind": "enum", "values": ["x"], "required": true}]'::jsonb, 'lark'); ERROR: unknown dialect: 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; key_order_ignored ------------------- t (1 row) -- And the guard half works on the nested spec too, in both directions. SELECT living_assertions.state('grammar:nested') AS before_watching; before_watching ----------------- unregistered (1 row) SELECT watch('nested', $q$select '[{"name":"t","kind":"enum","values":["a"],"required":true}]'::jsonb$q$) > 0 AS watched; watched --------- t (1 row) SELECT check_grammar('nested') AS unchanged; unchanged ----------- holds (1 row) -- 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; a_constant_spec_cannot_drift ------------------------------ holds (1 row) DROP SCHEMA gg_test CASCADE; NOTICE: drop cascades to 5 other objects 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; NOTICE: installing required extension "pg_living_assertions" 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; approved_on_0_1_0 ------------------- t (1 row) ALTER EXTENSION pg_grammar_guard UPDATE TO '0.2.0'; SELECT extversion FROM pg_extension WHERE extname = 'pg_grammar_guard'; extversion ------------ 0.2.0 (1 row) -- The baseline is still there, and the flat API still answers. SELECT name, note FROM approved_grammars WHERE name = 'survives'; name | note ----------+----------------------------- survives | approved before the upgrade (1 row) SELECT count(*) AS breaks FROM check_grammar('survives', ARRAY[ROW('t', 'enum', ARRAY['a'], true)]::grammar_field[]); breaks -------- 0 (1 row) -- And the new one answers too. SELECT grammar_for('[{"name":"v","kind":"enum","values":["x"],"required":true}]'::jsonb); grammar_for --------------------------------------------------------------------- root ::= "{" ws "\"v\"" ws ":" ws root-0 ws "}" + root-0 ::= "\"x\"" + string ::= "\"" char* "\"" + char ::= [^"\\\x00-\x1F] | "\\" (["\\/bfnrt] | "u" hex hex hex hex)+ hex ::= [0-9a-fA-F] + integer ::= "-"? ("0" | [1-9] [0-9]*) + number ::= integer ("." [0-9]+)? ([eE] [-+]? [0-9]+)? + boolean ::= "true" | "false" + ws ::= [ \t\n]* + (1 row) -- --------------------------------------------------- 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'; WARNING: 1 grammar baseline(s) are no longer being checked SELECT extversion FROM pg_extension WHERE extname = 'pg_grammar_guard'; extversion ------------ 0.3.0 (1 row) -- 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'; name | note ----------+----------------------------- survives | approved before the upgrade (1 row) -- 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; after_the_upgrade ------------------- unregistered (1 row) 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; rewatched ----------- t (1 row) SELECT check_grammar('survives') AS watched_again; watched_again --------------- holds (1 row) -- --------------------------------------------------- 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'; extversion ------------ 0.4.0 (1 row) SELECT check_grammar('survives') AS still_answers_after_the_upgrade; still_answers_after_the_upgrade --------------------------------- holds (1 row) -- 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; watched --------- t (1 row) SELECT check_grammar('on_0_4_0') AS unchanged; unchanged ----------- holds (1 row) CREATE TABLE gg4.dos (id int); SELECT check_grammar('on_0_4_0') AS after_the_world_moved; after_the_world_moved ----------------------- broken (1 row) DROP SCHEMA gg4 CASCADE; NOTICE: drop cascades to 2 other objects DROP EXTENSION pg_grammar_guard CASCADE;