diff --git a/.agents/skills/testing-fixtures/SKILL.md b/.agents/skills/testing-fixtures/SKILL.md
index dbbad6edb..51be6c68a 100644
--- a/.agents/skills/testing-fixtures/SKILL.md
+++ b/.agents/skills/testing-fixtures/SKILL.md
@@ -144,6 +144,27 @@ This generates two tests per case (pretty and non-pretty) and uses Jest snapshot
3. Run `npx jest` to verify all tests pass
4. Commit the `.sql` file, `generated.json`, and any new generated test files in `__tests__/kitchen-sink/`
+## PostgreSQL Regression Corpus (`__fixtures__/regress/`)
+
+A ratchet over the SQL that libpg_query vendors from PostgreSQL (`test/sql/postgres_regress`,
+`test/sql/plpgsql_regress`) plus its own test SQL (`test/sql/deparse*`, `test/plpgsql_samples.sql`):
+
+- `statements.json.gz`: unique parseable statements keyed like `generated.json`
+ (`postgres_regress/arrays-12.sql`), plus the libpg_query commit they came from. Built by
+ `cd packages/deparser && npm run fixtures:regress -- /path/to/libpg_query`. Check out the
+ `x-publish.libpgQueryCommit` of the pinned `libpg-query` release first.
+- `known-failures.deparser.json` / `known-failures.plpgsql-deparser.json`: statements that do not
+ round-trip yet.
+- Tests: `packages/deparser/__tests__/regress/regress-corpus.test.ts` (one test per source file,
+ pretty and non-pretty, via `TestUtils.expectAstMatch`) and
+ `packages/plpgsql-deparser/__tests__/regress-corpus.test.ts`. They run in the normal `test` script.
+
+A new failure fails the test, and so does a known failure that now passes. After fixing deparser
+bugs (or bumping `libpg-query` and regenerating the corpus), refresh the lists with
+`UPDATE_KNOWN_FAILURES=1 npx jest __tests__/regress` (deparser) and
+`UPDATE_KNOWN_FAILURES=1 npx jest __tests__/regress-corpus` (plpgsql-deparser), then review the diff.
+Bug fixes still get a kitchen-sink fixture; the corpus only guards against regressions.
+
## PL/pgSQL Fixtures (`plpgsql-deparser`)
The PL/pgSQL deparser has its own fixture pipeline:
diff --git a/__fixtures__/generated/generated.json b/__fixtures__/generated/generated.json
index a9ad75702..8eb770e36 100644
--- a/__fixtures__/generated/generated.json
+++ b/__fixtures__/generated/generated.json
@@ -96,6 +96,8 @@
"pretty/misc-15.sql": "ALTER DEFAULT PRIVILEGES IN SCHEMA dashboard_jobs \n GRANT EXECUTE ON FUNCTIONS TO administrator",
"pretty/misc-16.sql": "GRANT EXECUTE ON FUNCTION dashboard_private.uuid_generate_seeded_uuid TO PUBLIC",
"pretty/misc-17.sql": "SELECT CAST(t.date AT TIME ZONE $$America/New_York$$ AS text)::date FROM tbl t",
+ "pretty/misc-18.sql": "FETCH ALL FROM c",
+ "pretty/misc-19.sql": "MOVE BACKWARD ALL IN c",
"pretty/formatting-1.sql": "EXPLAIN (COSTS OFF) SELECT * FROM onek2 WHERE unique2 = 11 AND stringu1 = 'ATAAAA'",
"pretty/formatting-2.sql": "EXPLAIN SELECT * FROM onek2 WHERE unique2 = 11 AND stringu1 = 'ATAAAA'",
"pretty/formatting-3.sql": "INSERT INTO objects.object (name, val, active, hash)\nVALUES ('name', 'val', 't'::boolean, 'abcdefg'),\n ('name', 'val', 't'::boolean, 'abcdefg'),\n ('name', 'val', 't'::boolean, 'abcdefg')",
@@ -21220,6 +21222,44 @@
"original/alter/alter-table-column-7.sql": "ALTER TABLE public.books\nADD COLUMN tags TEXT[] DEFAULT '{}'",
"original/alter/alter-table-column-8.sql": "CREATE TYPE mood AS ENUM ('happy', 'sad', 'neutral')",
"original/alter/alter-table-column-9.sql": "ALTER TABLE public.profiles\nADD COLUMN current_mood mood DEFAULT 'neutral'",
+ "misc/xmlserialize-indent-1.sql": "SELECT xmlserialize(DOCUMENT '42' AS text INDENT)",
+ "misc/xmlserialize-indent-2.sql": "SELECT xmlserialize(CONTENT '42' AS text INDENT)",
+ "misc/xmlserialize-indent-3.sql": "SELECT xmlserialize(DOCUMENT '42' AS text NO INDENT)",
+ "misc/xmlserialize-indent-4.sql": "SELECT xmlserialize(CONTENT doc AS varchar INDENT) FROM xmltest",
+ "misc/transaction-chain-1.sql": "START TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ WRITE, DEFERRABLE",
+ "misc/transaction-chain-2.sql": "COMMIT AND CHAIN",
+ "misc/transaction-chain-3.sql": "ROLLBACK AND CHAIN",
+ "misc/transaction-chain-4.sql": "COMMIT AND NO CHAIN",
+ "misc/transaction-chain-5.sql": "ROLLBACK AND NO CHAIN",
+ "misc/transaction-chain-6.sql": "END AND CHAIN",
+ "misc/transaction-chain-7.sql": "ABORT AND CHAIN",
+ "misc/tablespace-clauses-1.sql": "CREATE TABLE testschema.foo (i int) TABLESPACE regress_tblspace",
+ "misc/tablespace-clauses-2.sql": "CREATE TABLE t (a int) ON COMMIT DROP TABLESPACE ts",
+ "misc/tablespace-clauses-3.sql": "CREATE TABLE t (a int) USING heap TABLESPACE ts",
+ "misc/tablespace-clauses-4.sql": "CREATE TABLE testschema.dflt (a int PRIMARY KEY USING INDEX TABLESPACE regress_tblspace) PARTITION BY LIST (a) TABLESPACE pg_default",
+ "misc/tablespace-clauses-5.sql": "CREATE TABLE testschema.part_1 PARTITION OF testschema.part FOR VALUES IN (1) TABLESPACE \"My Space\"",
+ "misc/tablespace-clauses-6.sql": "CREATE TABLE t (a int, b int, PRIMARY KEY (a) INCLUDE (b) WITH (fillfactor=10) USING INDEX TABLESPACE ts DEFERRABLE)",
+ "misc/tablespace-clauses-7.sql": "CREATE TABLE t (a int UNIQUE NULLS NOT DISTINCT USING INDEX TABLESPACE \"My Space\")",
+ "misc/tablespace-clauses-8.sql": "CREATE TABLE t (a int, CONSTRAINT u UNIQUE (a) INCLUDE (a) WITH (fillfactor=50))",
+ "misc/tablespace-clauses-9.sql": "CREATE TABLE t (a int, EXCLUDE USING gist (a WITH =) INCLUDE (a) WITH (fillfactor=10) USING INDEX TABLESPACE ts WHERE (a > 0))",
+ "misc/tablespace-clauses-10.sql": "ALTER TABLE testschema.atable ADD CONSTRAINT anindex_t UNIQUE (column1) USING INDEX TABLESPACE regress_tblspace",
+ "misc/tablespace-clauses-11.sql": "ALTER TABLE t ADD CONSTRAINT pk PRIMARY KEY USING INDEX \"My Index\"",
+ "misc/tablespace-clauses-12.sql": "ALTER TABLE t ADD UNIQUE USING INDEX idx DEFERRABLE INITIALLY DEFERRED",
+ "misc/tablespace-clauses-13.sql": "CREATE TABLESPACE \"My Space\" OWNER regress_user LOCATION '/tmp' WITH (random_page_cost = 3, seq_page_cost = '1.5')",
+ "misc/tablespace-clauses-14.sql": "ALTER TABLESPACE \"My Space\" SET (random_page_cost = 1.5, effective_io_concurrency = 2)",
+ "misc/tablespace-clauses-15.sql": "ALTER TABLESPACE \"My Space\" RESET (random_page_cost)",
+ "misc/tablespace-clauses-16.sql": "ALTER TABLESPACE \"My Space\" SET (\"Random_Page_Cost\" = 2)",
+ "misc/tablespace-clauses-17.sql": "DROP TABLESPACE IF EXISTS \"My Space\"",
+ "misc/show-quoting-1.sql": "SHOW \"TimeZone\"",
+ "misc/show-quoting-2.sql": "SHOW \"Foo\".\"Bar\"",
+ "misc/show-quoting-3.sql": "SHOW \"my.ext\".setting",
+ "misc/show-quoting-4.sql": "SHOW \"ALL\"",
+ "misc/show-quoting-5.sql": "SHOW ALL",
+ "misc/show-quoting-6.sql": "SHOW TIME ZONE",
+ "misc/show-quoting-7.sql": "SHOW TRANSACTION ISOLATION LEVEL",
+ "misc/show-quoting-8.sql": "SHOW SESSION AUTHORIZATION",
+ "misc/show-quoting-9.sql": "SHOW search_path",
+ "misc/show-quoting-10.sql": "SHOW plpgsql.extra_warnings",
"misc/rename-qualified-and-sequence-privs-1.sql": "ALTER TYPE app.t2 RENAME TO t",
"misc/rename-qualified-and-sequence-privs-2.sql": "ALTER DOMAIN app.d2 RENAME TO d",
"misc/rename-qualified-and-sequence-privs-3.sql": "GRANT ALL ON SEQUENCE app.seq TO bob",
@@ -21526,6 +21566,24 @@
"misc/pg18-copy-18.sql": "COPY t FROM 'it''s.csv'",
"misc/pg18-copy-19.sql": "COPY t FROM STDIN WHERE a > 0",
"misc/pg18-copy-20.sql": "COPY t FROM STDIN WITH NULL AS 'I''m null'",
+ "misc/operator-names-1.sql": "SELECT a OPERATOR(pg_catalog.+) b FROM t",
+ "misc/operator-names-2.sql": "SELECT a OPERATOR(\"My Schema\".+) b FROM t",
+ "misc/operator-names-3.sql": "SELECT a OPERATOR(\"S\".===) b OPERATOR(public.*) c FROM t",
+ "misc/operator-names-4.sql": "SELECT OPERATOR(\"S\".-) a FROM t",
+ "misc/operator-names-5.sql": "SELECT a OPERATOR(\"S\".=) ANY (ARRAY[1]) FROM t",
+ "misc/operator-names-6.sql": "SELECT a OPERATOR(\"S\".=) ALL (SELECT 1) FROM t",
+ "misc/operator-names-7.sql": "SELECT - -1, @ -1, 1 - -1, 2 ^ - 1",
+ "misc/operator-names-8.sql": "CREATE OPERATOR \"My Schema\".=== (LEFTARG = int, RIGHTARG = int, PROCEDURE = f, COMMUTATOR = OPERATOR(\"My Schema\".===), NEGATOR = OPERATOR(\"My Schema\".!==))",
+ "misc/operator-names-9.sql": "CREATE OPERATOR === (LEFTARG = int, RIGHTARG = int, PROCEDURE = f, COMMUTATOR = ===, NEGATOR = !==)",
+ "misc/operator-names-10.sql": "ALTER OPERATOR === (int, int) SET (COMMUTATOR = OPERATOR(\"My Schema\".===))",
+ "misc/operator-names-11.sql": "ALTER OPERATOR \"S\".=== (int, int) SET (NEGATOR = !==)",
+ "misc/operator-names-12.sql": "DROP OPERATOR \"S\".< (int, int)",
+ "misc/operator-names-13.sql": "COMMENT ON OPERATOR \"My Schema\".=== (int, int) IS 'x'",
+ "misc/operator-names-14.sql": "COMMENT ON OPERATOR - (NONE, int) IS 'x'",
+ "misc/operator-names-15.sql": "CREATE OPERATOR CLASS c FOR TYPE int USING btree AS OPERATOR 1 \"S\".< (int, int), OPERATOR 2 <=, FUNCTION 1 f(int, int)",
+ "misc/operator-names-16.sql": "ALTER OPERATOR FAMILY f USING btree ADD OPERATOR 1 \"S\".< (int, int)",
+ "misc/operator-names-17.sql": "ALTER TABLE t ADD EXCLUDE USING gist (c WITH OPERATOR(\"S\".&&))",
+ "misc/operator-names-18.sql": "ALTER TABLE t ADD EXCLUDE USING gist (c WITH &&)",
"misc/missing-types-1.sql": "ALTER PUBLICATION \"my_publication\" OWNER TO \"postgres\"",
"misc/missing-types-2.sql": "ALTER OPERATOR FAMILY my_opfamily USING btree OWNER TO new_owner",
"misc/missing-types-3.sql": "ALTER OPERATOR CLASS my_opclass USING btree OWNER TO new_owner",
@@ -21620,6 +21678,38 @@
"misc/launchql-ext-default-roles-1.sql": "DO $$\n BEGIN\n IF NOT EXISTS (\n SELECT\n 1\n FROM\n pg_roles\n WHERE\n rolname = 'anonymous') THEN\n CREATE ROLE anonymous;\n COMMENT ON ROLE anonymous IS 'Anonymous group';\n ALTER USER anonymous WITH NOCREATEDB;\n ALTER USER anonymous WITH NOCREATEROLE;\n ALTER USER anonymous WITH NOLOGIN;\n ALTER USER anonymous WITH NOBYPASSRLS;\nEND IF;\nEND $$",
"misc/launchql-ext-default-roles-2.sql": "DO $$\n BEGIN\n IF NOT EXISTS (\n SELECT\n 1\n FROM\n pg_roles\n WHERE\n rolname = 'authenticated') THEN\n CREATE ROLE authenticated;\n COMMENT ON ROLE authenticated IS 'Authenticated group';\n ALTER USER authenticated WITH NOCREATEDB;\n ALTER USER authenticated WITH NOCREATEROLE;\n ALTER USER authenticated WITH NOLOGIN;\n ALTER USER authenticated WITH NOBYPASSRLS;\nEND IF;\nEND $$",
"misc/launchql-ext-default-roles-3.sql": "DO $$\n BEGIN\n IF NOT EXISTS (\n SELECT\n 1\n FROM\n pg_roles\n WHERE\n rolname = 'administrator') THEN\n CREATE ROLE administrator;\n COMMENT ON ROLE administrator IS 'Administration group';\n ALTER USER administrator WITH NOCREATEDB;\n ALTER USER administrator WITH NOCREATEROLE;\n ALTER USER administrator WITH NOLOGIN;\n ALTER USER administrator WITH BYPASSRLS;\n GRANT anonymous TO administrator;\n GRANT authenticated TO administrator;\nEND IF;\nEND $$",
+ "misc/json-table-1.sql": "SELECT * FROM JSON_TABLE(jsonb '[1, 2]', '$[*]' COLUMNS (a int PATH '$'))",
+ "misc/json-table-2.sql": "SELECT * FROM JSON_TABLE(jsonb '[1, 2]', '$[*]' COLUMNS (id FOR ORDINALITY, a int)) jt",
+ "misc/json-table-3.sql": "SELECT * FROM JSON_TABLE(jsonb '[1, 2]', '$[*]' AS root COLUMNS (a int PATH '$')) AS jt (x)",
+ "misc/json-table-4.sql": "SELECT * FROM JSON_TABLE(jsonb '{\"a\": [1]}', '$' PASSING 1 AS x, 'y' AS \"Y\" COLUMNS (a int PATH '$.a[*] ? (@ > $x)' DEFAULT -1 ON EMPTY ERROR ON ERROR) ERROR ON ERROR) jt",
+ "misc/json-table-5.sql": "SELECT * FROM JSON_TABLE(jsonb '{\"a\": 1}', '$' COLUMNS (e bool EXISTS PATH '$.a', e2 int EXISTS PATH '$.b' TRUE ON ERROR)) jt",
+ "misc/json-table-6.sql": "SELECT * FROM JSON_TABLE(jsonb '{\"a\": \"x\"}', '$' COLUMNS (js jsonb FORMAT JSON PATH '$.a' WITH CONDITIONAL WRAPPER OMIT QUOTES, js2 text FORMAT JSON ENCODING UTF8 PATH '$' WITHOUT WRAPPER KEEP QUOTES EMPTY OBJECT ON ERROR)) jt",
+ "misc/json-table-7.sql": "SELECT * FROM JSON_TABLE(jsonb '{\"a\": \"x\"}', '$' COLUMNS (q jsonb PATH '$.a' WITH WRAPPER EMPTY ARRAY ON EMPTY, \"Mixed Col\" text PATH '$.a' NULL ON ERROR)) jt",
+ "misc/json-table-8.sql": "SELECT * FROM JSON_TABLE(jsonb '{\"a\": [{\"b\": 1}]}', '$' COLUMNS (id FOR ORDINALITY, NESTED PATH '$.a[*]' COLUMNS (b int PATH '$.b', NESTED PATH '$.c[*]' AS c_path COLUMNS (c text)), NESTED '$.d' AS d COLUMNS (d int))) jt",
+ "misc/json-table-9.sql": "SELECT jt.* FROM t, LATERAL JSON_TABLE(t.js, '$[*]' COLUMNS (a text PATH '$.a')) AS jt",
+ "misc/json-table-10.sql": "SELECT jt.* FROM t CROSS JOIN JSON_TABLE(t.js FORMAT JSON, '$[*]' COLUMNS (a text)) jt",
+ "misc/json-table-11.sql": "CREATE VIEW json_table_view AS SELECT * FROM JSON_TABLE(jsonb 'null', 'lax $[*]' PASSING 1 + 2 AS a, json '\"foo\"' AS \"b c\" COLUMNS (id FOR ORDINALITY, \"int\" int PATH '$', js json PATH '$', jsb jsonb PATH '$', exists1 bool EXISTS PATH '$.aaa', NESTED PATH '$[1]' AS p1 COLUMNS (a1 int, NESTED PATH '$[*]' AS \"p1 1\" COLUMNS (a11 text)))) jt",
+ "misc/json-query-functions-1.sql": "SELECT JSON_EXISTS(jsonb '{\"a\": 1}', '$.a')",
+ "misc/json-query-functions-2.sql": "SELECT JSON_EXISTS(jsonb '{\"a\": [1,2,3]}', 'strict $.a[5]' ERROR ON ERROR)",
+ "misc/json-query-functions-3.sql": "SELECT JSON_EXISTS(jsonb '[1,2,3]', '$[*] ? (@ > $x)' PASSING 2 AS x, 'b' AS \"Y\" UNKNOWN ON ERROR)",
+ "misc/json-query-functions-4.sql": "SELECT JSON_EXISTS(js FORMAT JSON, '$.a' TRUE ON ERROR) FROM t",
+ "misc/json-query-functions-5.sql": "SELECT JSON_VALUE(jsonb '\"aaa\"', '$')",
+ "misc/json-query-functions-6.sql": "SELECT JSON_VALUE(jsonb '123', '$' RETURNING int)",
+ "misc/json-query-functions-7.sql": "SELECT JSON_VALUE(jsonb '\"2017-02-20\"', '$' RETURNING date DEFAULT '2000-01-01' ON EMPTY NULL ON ERROR)",
+ "misc/json-query-functions-8.sql": "SELECT JSON_VALUE(jsonb '[1,2]', 'strict $[*]' DEFAULT 0 + 1 ON ERROR)",
+ "misc/json-query-functions-9.sql": "SELECT JSON_VALUE(js, '$.a' PASSING x + 1 AS x RETURNING text ERROR ON EMPTY ERROR ON ERROR) FROM t",
+ "misc/json-query-functions-10.sql": "SELECT JSON_QUERY(jsonb '[1,2]', '$[*]')",
+ "misc/json-query-functions-11.sql": "SELECT JSON_QUERY(jsonb '\"aaa\"', '$' RETURNING text KEEP QUOTES)",
+ "misc/json-query-functions-12.sql": "SELECT JSON_QUERY(jsonb '\"aaa\"', '$' RETURNING text OMIT QUOTES ON SCALAR STRING)",
+ "misc/json-query-functions-13.sql": "SELECT JSON_QUERY(jsonb '[1,2]', '$[*]' WITH WRAPPER)",
+ "misc/json-query-functions-14.sql": "SELECT JSON_QUERY(jsonb '[1,2]', '$[*]' WITH CONDITIONAL ARRAY WRAPPER)",
+ "misc/json-query-functions-15.sql": "SELECT JSON_QUERY(jsonb '[1,2]', '$[*]' WITH UNCONDITIONAL WRAPPER)",
+ "misc/json-query-functions-16.sql": "SELECT JSON_QUERY(jsonb '[1,2]', '$[*]' WITHOUT WRAPPER)",
+ "misc/json-query-functions-17.sql": "SELECT JSON_QUERY(jsonb '[]', '$[*]' EMPTY ON EMPTY)",
+ "misc/json-query-functions-18.sql": "SELECT JSON_QUERY(jsonb '[]', '$[*]' EMPTY ARRAY ON EMPTY EMPTY OBJECT ON ERROR)",
+ "misc/json-query-functions-19.sql": "SELECT JSON_QUERY(js FORMAT JSON, '$.a' PASSING 1 AS x RETURNING bytea FORMAT JSON ENCODING UTF8 WITH CONDITIONAL WRAPPER KEEP QUOTES DEFAULT '\"x\"' ON EMPTY ERROR ON ERROR) FROM t",
+ "misc/json-query-functions-20.sql": "CREATE VIEW json_query_v AS SELECT JSON_QUERY(jsonb '{\"a\": 1}', '$.a' RETURNING jsonb WITH WRAPPER NULL ON ERROR) AS q, JSON_VALUE(jsonb '1', '$' RETURNING int) AS v, JSON_EXISTS(jsonb '1', '$') AS e",
+ "misc/json-query-functions-21.sql": "CREATE TABLE json_check (js jsonb CHECK (JSON_EXISTS(js, '$.a')), v text DEFAULT JSON_VALUE(jsonb '\"x\"', '$'))",
"misc/issues-1.sql": "select from test_table WHERE status = 'complete'::text",
"misc/issues-2.sql": "select from test_table WHERE status = 'complete'",
"misc/issues-3.sql": "CREATE TABLE new_style (\n id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n val1 TEXT NOT NULL,\n val2 TEXT NULL,\n CONSTRAINT uq_val1_val2_new UNIQUE NULLS NOT DISTINCT (val1, val2)\n)",
@@ -21719,6 +21809,14 @@
"misc/generated-columns-6.sql": "ALTER TABLE generated_misc_test\nALTER COLUMN c SET EXPRESSION AS (a - b)",
"misc/generated-columns-7.sql": "ALTER TABLE generated_misc_test\nALTER COLUMN c DROP EXPRESSION",
"misc/generated-columns-8.sql": "ALTER TABLE generated_misc_test\nALTER COLUMN c DROP EXPRESSION IF EXISTS",
+ "misc/fetch-with-ties-1.sql": "SELECT thousand FROM onek WHERE thousand < 5 ORDER BY thousand FETCH FIRST 2 ROW WITH TIES",
+ "misc/fetch-with-ties-2.sql": "SELECT thousand FROM onek WHERE thousand < 5 ORDER BY thousand FETCH FIRST ROWS WITH TIES",
+ "misc/fetch-with-ties-3.sql": "SELECT thousand FROM onek WHERE thousand < 5 ORDER BY thousand OFFSET 2 ROWS FETCH NEXT (1 + 1) ROWS WITH TIES",
+ "misc/fetch-with-ties-4.sql": "CREATE VIEW limit_thousand_v_1 AS SELECT thousand FROM onek WHERE thousand < 995 ORDER BY thousand FETCH FIRST 5 ROWS WITH TIES OFFSET 10",
+ "misc/fetch-with-ties-5.sql": "CREATE VIEW limit_thousand_v_3 AS SELECT thousand FROM onek WHERE thousand < 995 ORDER BY thousand FETCH FIRST (NULL+1) ROWS WITH TIES",
+ "misc/fetch-with-ties-6.sql": "CREATE VIEW limit_thousand_v_4 AS SELECT thousand FROM onek WHERE thousand < 995 ORDER BY thousand FETCH FIRST NULL ROWS ONLY",
+ "misc/fetch-with-ties-7.sql": "SELECT * FROM t ORDER BY a FETCH FIRST $1 ROWS WITH TIES",
+ "misc/fetch-with-ties-8.sql": "SELECT * FROM t ORDER BY a FETCH FIRST -1 ROWS WITH TIES",
"misc/extract-1.sql": "SELECT EXTRACT(EPOCH FROM now())",
"misc/extract-2.sql": "SELECT EXTRACT(EPOCH FROM DATE '1970-01-01')",
"misc/extract-3.sql": "SELECT EXTRACT(EPOCH FROM TIMESTAMP '1970-01-01')",
@@ -21737,6 +21835,10 @@
"misc/extract-16.sql": "SELECT EXTRACT(TIMEZONE FROM CURRENT_TIMESTAMP)",
"misc/extract-17.sql": "SELECT EXTRACT(TIMEZONE_HOUR FROM CURRENT_TIMESTAMP)",
"misc/extract-18.sql": "SELECT EXTRACT(TIMEZONE_MINUTE FROM CURRENT_TIMESTAMP)",
+ "misc/copy-option-values-1.sql": "COPY t FROM STDIN (HEADER false)",
+ "misc/copy-option-values-2.sql": "COPY t FROM STDIN (HEADER true, FREEZE false)",
+ "misc/copy-option-values-3.sql": "COPY t FROM STDIN (FORMAT \"CSV\")",
+ "misc/copy-option-values-4.sql": "COPY t FROM STDIN (FORMAT 'csv', HEADER off)",
"misc/cascades-1.sql": "DROP TABLE IF EXISTS some_table CASCADE",
"misc/cascades-2.sql": "DROP VIEW IF EXISTS some_view CASCADE",
"misc/cascades-3.sql": "DROP MATERIALIZED VIEW IF EXISTS some_mat_view CASCADE",
@@ -21765,6 +21867,18 @@
"misc/booleans-cast-1.sql": "SELECT * FROM myschema.mytable WHERE a = TRUE",
"misc/booleans-cast-2.sql": "SELECT * FROM myschema.mytable WHERE a = CAST('t' AS boolean)",
"misc/booleans-cast-3.sql": "SELECT * FROM myschema.mytable WHERE a = 't'::boolean",
+ "misc/alter-trigger-rename-1.sql": "ALTER TRIGGER p ON grandp RENAME TO q",
+ "misc/alter-trigger-rename-2.sql": "ALTER TRIGGER \"Mixed Trig\" ON \"My Schema\".\"My Table\" RENAME TO \"New Trig\"",
+ "misc/alter-trigger-rename-3.sql": "ALTER TRIGGER modified_a ON main_table RENAME TO modified_modified_a",
+ "misc/alter-statistics-1.sql": "ALTER STATISTICS ab1_a_b_stats RENAME TO ab1_a_b_stats_new",
+ "misc/alter-statistics-2.sql": "ALTER STATISTICS \"My Schema\".\"My Stats\" RENAME TO \"New Stats\"",
+ "misc/alter-statistics-3.sql": "ALTER STATISTICS public.s1 RENAME TO s2",
+ "misc/alter-statistics-4.sql": "ALTER STATISTICS s1 SET SCHEMA s2",
+ "misc/alter-statistics-5.sql": "ALTER STATISTICS s1 OWNER TO r1",
+ "misc/alter-statistics-6.sql": "ALTER STATISTICS ab1_a_b_stats SET STATISTICS 0",
+ "misc/alter-statistics-7.sql": "ALTER STATISTICS IF EXISTS ab1_a_b_stats_new SET STATISTICS -1",
+ "misc/alter-statistics-8.sql": "ALTER STATISTICS s1 SET STATISTICS DEFAULT",
+ "misc/alter-statistics-9.sql": "ALTER STATISTICS \"My Schema\".\"My Stats\" SET STATISTICS 5",
"latest/postgres/create_view-1.sql": "CREATE FUNCTION interpt_pp(path, path)\n RETURNS point\n AS 'regresslib'\n LANGUAGE C STRICT",
"latest/postgres/create_view-2.sql": "CREATE TABLE real_city (\n\tpop\t\t\tint4,\n\tcname\t\ttext,\n\toutline \tpath\n)",
"latest/postgres/create_view-3.sql": "COPY real_city FROM 'filename'",
diff --git a/__fixtures__/kitchen-sink/misc/alter-statistics.sql b/__fixtures__/kitchen-sink/misc/alter-statistics.sql
new file mode 100644
index 000000000..a40ebbbb0
--- /dev/null
+++ b/__fixtures__/kitchen-sink/misc/alter-statistics.sql
@@ -0,0 +1,9 @@
+ALTER STATISTICS ab1_a_b_stats RENAME TO ab1_a_b_stats_new;
+ALTER STATISTICS "My Schema"."My Stats" RENAME TO "New Stats";
+ALTER STATISTICS public.s1 RENAME TO s2;
+ALTER STATISTICS s1 SET SCHEMA s2;
+ALTER STATISTICS s1 OWNER TO r1;
+ALTER STATISTICS ab1_a_b_stats SET STATISTICS 0;
+ALTER STATISTICS IF EXISTS ab1_a_b_stats_new SET STATISTICS -1;
+ALTER STATISTICS s1 SET STATISTICS DEFAULT;
+ALTER STATISTICS "My Schema"."My Stats" SET STATISTICS 5;
diff --git a/__fixtures__/kitchen-sink/misc/alter-trigger-rename.sql b/__fixtures__/kitchen-sink/misc/alter-trigger-rename.sql
new file mode 100644
index 000000000..dc37c35e3
--- /dev/null
+++ b/__fixtures__/kitchen-sink/misc/alter-trigger-rename.sql
@@ -0,0 +1,5 @@
+-- ALTER TRIGGER name ON table RENAME TO (RenameStmt OBJECT_TRIGGER keeps subname)
+-- Ref: PostgreSQL regress triggers.sql
+ALTER TRIGGER p ON grandp RENAME TO q;
+ALTER TRIGGER "Mixed Trig" ON "My Schema"."My Table" RENAME TO "New Trig";
+ALTER TRIGGER modified_a ON main_table RENAME TO modified_modified_a;
diff --git a/__fixtures__/kitchen-sink/misc/copy-option-values.sql b/__fixtures__/kitchen-sink/misc/copy-option-values.sql
new file mode 100644
index 000000000..674955a7e
--- /dev/null
+++ b/__fixtures__/kitchen-sink/misc/copy-option-values.sql
@@ -0,0 +1,5 @@
+-- COPY option values keep their meaning
+COPY t FROM STDIN (HEADER false);
+COPY t FROM STDIN (HEADER true, FREEZE false);
+COPY t FROM STDIN (FORMAT "CSV");
+COPY t FROM STDIN (FORMAT 'csv', HEADER off);
diff --git a/__fixtures__/kitchen-sink/misc/fetch-with-ties.sql b/__fixtures__/kitchen-sink/misc/fetch-with-ties.sql
new file mode 100644
index 000000000..d19b3470b
--- /dev/null
+++ b/__fixtures__/kitchen-sink/misc/fetch-with-ties.sql
@@ -0,0 +1,10 @@
+-- FETCH FIRST n ROWS WITH TIES (SelectStmt.limitOption)
+-- Ref: PostgreSQL regress limit.sql
+SELECT thousand FROM onek WHERE thousand < 5 ORDER BY thousand FETCH FIRST 2 ROW WITH TIES;
+SELECT thousand FROM onek WHERE thousand < 5 ORDER BY thousand FETCH FIRST ROWS WITH TIES;
+SELECT thousand FROM onek WHERE thousand < 5 ORDER BY thousand OFFSET 2 ROWS FETCH NEXT (1 + 1) ROWS WITH TIES;
+CREATE VIEW limit_thousand_v_1 AS SELECT thousand FROM onek WHERE thousand < 995 ORDER BY thousand FETCH FIRST 5 ROWS WITH TIES OFFSET 10;
+CREATE VIEW limit_thousand_v_3 AS SELECT thousand FROM onek WHERE thousand < 995 ORDER BY thousand FETCH FIRST (NULL+1) ROWS WITH TIES;
+CREATE VIEW limit_thousand_v_4 AS SELECT thousand FROM onek WHERE thousand < 995 ORDER BY thousand FETCH FIRST NULL ROWS ONLY;
+SELECT * FROM t ORDER BY a FETCH FIRST $1 ROWS WITH TIES;
+SELECT * FROM t ORDER BY a FETCH FIRST -1 ROWS WITH TIES;
diff --git a/__fixtures__/kitchen-sink/misc/json-query-functions.sql b/__fixtures__/kitchen-sink/misc/json-query-functions.sql
new file mode 100644
index 000000000..8dc1dd42b
--- /dev/null
+++ b/__fixtures__/kitchen-sink/misc/json-query-functions.sql
@@ -0,0 +1,21 @@
+SELECT JSON_EXISTS(jsonb '{"a": 1}', '$.a');
+SELECT JSON_EXISTS(jsonb '{"a": [1,2,3]}', 'strict $.a[5]' ERROR ON ERROR);
+SELECT JSON_EXISTS(jsonb '[1,2,3]', '$[*] ? (@ > $x)' PASSING 2 AS x, 'b' AS "Y" UNKNOWN ON ERROR);
+SELECT JSON_EXISTS(js FORMAT JSON, '$.a' TRUE ON ERROR) FROM t;
+SELECT JSON_VALUE(jsonb '"aaa"', '$');
+SELECT JSON_VALUE(jsonb '123', '$' RETURNING int);
+SELECT JSON_VALUE(jsonb '"2017-02-20"', '$' RETURNING date DEFAULT '2000-01-01' ON EMPTY NULL ON ERROR);
+SELECT JSON_VALUE(jsonb '[1,2]', 'strict $[*]' DEFAULT 0 + 1 ON ERROR);
+SELECT JSON_VALUE(js, '$.a' PASSING x + 1 AS x RETURNING text ERROR ON EMPTY ERROR ON ERROR) FROM t;
+SELECT JSON_QUERY(jsonb '[1,2]', '$[*]');
+SELECT JSON_QUERY(jsonb '"aaa"', '$' RETURNING text KEEP QUOTES);
+SELECT JSON_QUERY(jsonb '"aaa"', '$' RETURNING text OMIT QUOTES ON SCALAR STRING);
+SELECT JSON_QUERY(jsonb '[1,2]', '$[*]' WITH WRAPPER);
+SELECT JSON_QUERY(jsonb '[1,2]', '$[*]' WITH CONDITIONAL ARRAY WRAPPER);
+SELECT JSON_QUERY(jsonb '[1,2]', '$[*]' WITH UNCONDITIONAL WRAPPER);
+SELECT JSON_QUERY(jsonb '[1,2]', '$[*]' WITHOUT WRAPPER);
+SELECT JSON_QUERY(jsonb '[]', '$[*]' EMPTY ON EMPTY);
+SELECT JSON_QUERY(jsonb '[]', '$[*]' EMPTY ARRAY ON EMPTY EMPTY OBJECT ON ERROR);
+SELECT JSON_QUERY(js FORMAT JSON, '$.a' PASSING 1 AS x RETURNING bytea FORMAT JSON ENCODING UTF8 WITH CONDITIONAL WRAPPER KEEP QUOTES DEFAULT '"x"' ON EMPTY ERROR ON ERROR) FROM t;
+CREATE VIEW json_query_v AS SELECT JSON_QUERY(jsonb '{"a": 1}', '$.a' RETURNING jsonb WITH WRAPPER NULL ON ERROR) AS q, JSON_VALUE(jsonb '1', '$' RETURNING int) AS v, JSON_EXISTS(jsonb '1', '$') AS e;
+CREATE TABLE json_check (js jsonb CHECK (JSON_EXISTS(js, '$.a')), v text DEFAULT JSON_VALUE(jsonb '"x"', '$'));
diff --git a/__fixtures__/kitchen-sink/misc/json-table.sql b/__fixtures__/kitchen-sink/misc/json-table.sql
new file mode 100644
index 000000000..f08fa382b
--- /dev/null
+++ b/__fixtures__/kitchen-sink/misc/json-table.sql
@@ -0,0 +1,11 @@
+SELECT * FROM JSON_TABLE(jsonb '[1, 2]', '$[*]' COLUMNS (a int PATH '$'));
+SELECT * FROM JSON_TABLE(jsonb '[1, 2]', '$[*]' COLUMNS (id FOR ORDINALITY, a int)) jt;
+SELECT * FROM JSON_TABLE(jsonb '[1, 2]', '$[*]' AS root COLUMNS (a int PATH '$')) AS jt (x);
+SELECT * FROM JSON_TABLE(jsonb '{"a": [1]}', '$' PASSING 1 AS x, 'y' AS "Y" COLUMNS (a int PATH '$.a[*] ? (@ > $x)' DEFAULT -1 ON EMPTY ERROR ON ERROR) ERROR ON ERROR) jt;
+SELECT * FROM JSON_TABLE(jsonb '{"a": 1}', '$' COLUMNS (e bool EXISTS PATH '$.a', e2 int EXISTS PATH '$.b' TRUE ON ERROR)) jt;
+SELECT * FROM JSON_TABLE(jsonb '{"a": "x"}', '$' COLUMNS (js jsonb FORMAT JSON PATH '$.a' WITH CONDITIONAL WRAPPER OMIT QUOTES, js2 text FORMAT JSON ENCODING UTF8 PATH '$' WITHOUT WRAPPER KEEP QUOTES EMPTY OBJECT ON ERROR)) jt;
+SELECT * FROM JSON_TABLE(jsonb '{"a": "x"}', '$' COLUMNS (q jsonb PATH '$.a' WITH WRAPPER EMPTY ARRAY ON EMPTY, "Mixed Col" text PATH '$.a' NULL ON ERROR)) jt;
+SELECT * FROM JSON_TABLE(jsonb '{"a": [{"b": 1}]}', '$' COLUMNS (id FOR ORDINALITY, NESTED PATH '$.a[*]' COLUMNS (b int PATH '$.b', NESTED PATH '$.c[*]' AS c_path COLUMNS (c text)), NESTED '$.d' AS d COLUMNS (d int))) jt;
+SELECT jt.* FROM t, LATERAL JSON_TABLE(t.js, '$[*]' COLUMNS (a text PATH '$.a')) AS jt;
+SELECT jt.* FROM t CROSS JOIN JSON_TABLE(t.js FORMAT JSON, '$[*]' COLUMNS (a text)) jt;
+CREATE VIEW json_table_view AS SELECT * FROM JSON_TABLE(jsonb 'null', 'lax $[*]' PASSING 1 + 2 AS a, json '"foo"' AS "b c" COLUMNS (id FOR ORDINALITY, "int" int PATH '$', js json PATH '$', jsb jsonb PATH '$', exists1 bool EXISTS PATH '$.aaa', NESTED PATH '$[1]' AS p1 COLUMNS (a1 int, NESTED PATH '$[*]' AS "p1 1" COLUMNS (a11 text)))) jt;
diff --git a/__fixtures__/kitchen-sink/misc/operator-names.sql b/__fixtures__/kitchen-sink/misc/operator-names.sql
new file mode 100644
index 000000000..374cb39bf
--- /dev/null
+++ b/__fixtures__/kitchen-sink/misc/operator-names.sql
@@ -0,0 +1,18 @@
+SELECT a OPERATOR(pg_catalog.+) b FROM t;
+SELECT a OPERATOR("My Schema".+) b FROM t;
+SELECT a OPERATOR("S".===) b OPERATOR(public.*) c FROM t;
+SELECT OPERATOR("S".-) a FROM t;
+SELECT a OPERATOR("S".=) ANY (ARRAY[1]) FROM t;
+SELECT a OPERATOR("S".=) ALL (SELECT 1) FROM t;
+SELECT - -1, @ -1, 1 - -1, 2 ^ - 1;
+CREATE OPERATOR "My Schema".=== (LEFTARG = int, RIGHTARG = int, PROCEDURE = f, COMMUTATOR = OPERATOR("My Schema".===), NEGATOR = OPERATOR("My Schema".!==));
+CREATE OPERATOR === (LEFTARG = int, RIGHTARG = int, PROCEDURE = f, COMMUTATOR = ===, NEGATOR = !==);
+ALTER OPERATOR === (int, int) SET (COMMUTATOR = OPERATOR("My Schema".===));
+ALTER OPERATOR "S".=== (int, int) SET (NEGATOR = !==);
+DROP OPERATOR "S".< (int, int);
+COMMENT ON OPERATOR "My Schema".=== (int, int) IS 'x';
+COMMENT ON OPERATOR - (NONE, int) IS 'x';
+CREATE OPERATOR CLASS c FOR TYPE int USING btree AS OPERATOR 1 "S".< (int, int), OPERATOR 2 <=, FUNCTION 1 f(int, int);
+ALTER OPERATOR FAMILY f USING btree ADD OPERATOR 1 "S".< (int, int);
+ALTER TABLE t ADD EXCLUDE USING gist (c WITH OPERATOR("S".&&));
+ALTER TABLE t ADD EXCLUDE USING gist (c WITH &&);
diff --git a/__fixtures__/kitchen-sink/misc/show-quoting.sql b/__fixtures__/kitchen-sink/misc/show-quoting.sql
new file mode 100644
index 000000000..f4101d160
--- /dev/null
+++ b/__fixtures__/kitchen-sink/misc/show-quoting.sql
@@ -0,0 +1,11 @@
+-- SHOW keeps identifier quoting (VariableShowStmt.name)
+SHOW "TimeZone";
+SHOW "Foo"."Bar";
+SHOW "my.ext".setting;
+SHOW "ALL";
+SHOW ALL;
+SHOW TIME ZONE;
+SHOW TRANSACTION ISOLATION LEVEL;
+SHOW SESSION AUTHORIZATION;
+SHOW search_path;
+SHOW plpgsql.extra_warnings;
diff --git a/__fixtures__/kitchen-sink/misc/tablespace-clauses.sql b/__fixtures__/kitchen-sink/misc/tablespace-clauses.sql
new file mode 100644
index 000000000..b6fb18cd7
--- /dev/null
+++ b/__fixtures__/kitchen-sink/misc/tablespace-clauses.sql
@@ -0,0 +1,19 @@
+-- TABLESPACE / USING INDEX TABLESPACE / INCLUDE / WITH on table and constraint definitions
+-- Ref: PostgreSQL regress tablespace.sql, create_table.sql, constraints.sql
+CREATE TABLE testschema.foo (i int) TABLESPACE regress_tblspace;
+CREATE TABLE t (a int) ON COMMIT DROP TABLESPACE ts;
+CREATE TABLE t (a int) USING heap TABLESPACE ts;
+CREATE TABLE testschema.dflt (a int PRIMARY KEY USING INDEX TABLESPACE regress_tblspace) PARTITION BY LIST (a) TABLESPACE pg_default;
+CREATE TABLE testschema.part_1 PARTITION OF testschema.part FOR VALUES IN (1) TABLESPACE "My Space";
+CREATE TABLE t (a int, b int, PRIMARY KEY (a) INCLUDE (b) WITH (fillfactor=10) USING INDEX TABLESPACE ts DEFERRABLE);
+CREATE TABLE t (a int UNIQUE NULLS NOT DISTINCT USING INDEX TABLESPACE "My Space");
+CREATE TABLE t (a int, CONSTRAINT u UNIQUE (a) INCLUDE (a) WITH (fillfactor=50));
+CREATE TABLE t (a int, EXCLUDE USING gist (a WITH =) INCLUDE (a) WITH (fillfactor=10) USING INDEX TABLESPACE ts WHERE (a > 0));
+ALTER TABLE testschema.atable ADD CONSTRAINT anindex_t UNIQUE (column1) USING INDEX TABLESPACE regress_tblspace;
+ALTER TABLE t ADD CONSTRAINT pk PRIMARY KEY USING INDEX "My Index";
+ALTER TABLE t ADD UNIQUE USING INDEX idx DEFERRABLE INITIALLY DEFERRED;
+CREATE TABLESPACE "My Space" OWNER regress_user LOCATION '/tmp' WITH (random_page_cost = 3, seq_page_cost = '1.5');
+ALTER TABLESPACE "My Space" SET (random_page_cost = 1.5, effective_io_concurrency = 2);
+ALTER TABLESPACE "My Space" RESET (random_page_cost);
+ALTER TABLESPACE "My Space" SET ("Random_Page_Cost" = 2);
+DROP TABLESPACE IF EXISTS "My Space";
diff --git a/__fixtures__/kitchen-sink/misc/transaction-chain.sql b/__fixtures__/kitchen-sink/misc/transaction-chain.sql
new file mode 100644
index 000000000..8205f5193
--- /dev/null
+++ b/__fixtures__/kitchen-sink/misc/transaction-chain.sql
@@ -0,0 +1,9 @@
+-- COMMIT/ROLLBACK AND CHAIN (TransactionStmt.chain)
+-- Ref: PostgreSQL regress transactions.sql
+START TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ WRITE, DEFERRABLE;
+COMMIT AND CHAIN;
+ROLLBACK AND CHAIN;
+COMMIT AND NO CHAIN;
+ROLLBACK AND NO CHAIN;
+END AND CHAIN;
+ABORT AND CHAIN;
diff --git a/__fixtures__/kitchen-sink/misc/xmlserialize-indent.sql b/__fixtures__/kitchen-sink/misc/xmlserialize-indent.sql
new file mode 100644
index 000000000..025ce37f3
--- /dev/null
+++ b/__fixtures__/kitchen-sink/misc/xmlserialize-indent.sql
@@ -0,0 +1,6 @@
+-- XMLSERIALIZE ... INDENT (XmlSerialize.indent)
+-- Ref: PostgreSQL regress xml.sql
+SELECT xmlserialize(DOCUMENT '42' AS text INDENT);
+SELECT xmlserialize(CONTENT '42' AS text INDENT);
+SELECT xmlserialize(DOCUMENT '42' AS text NO INDENT);
+SELECT xmlserialize(CONTENT doc AS varchar INDENT) FROM xmltest;
diff --git a/__fixtures__/kitchen-sink/pretty/misc.sql b/__fixtures__/kitchen-sink/pretty/misc.sql
index de3896a0c..7dbc6e1e2 100644
--- a/__fixtures__/kitchen-sink/pretty/misc.sql
+++ b/__fixtures__/kitchen-sink/pretty/misc.sql
@@ -232,4 +232,9 @@ GRANT EXECUTE ON FUNCTION dashboard_private.uuid_generate_seeded_uuid TO PUBLIC;
-- https://github.com/constructive-io/pgsql-parser/issues/217
-SELECT CAST(t.date AT TIME ZONE $$America/New_York$$ AS text)::date FROM tbl t;
\ No newline at end of file
+SELECT CAST(t.date AT TIME ZONE $$America/New_York$$ AS text)::date FROM tbl t;
+
+-- FETCH/MOVE ALL (FETCH_ALL is 2147483647 in the WASM AST)
+FETCH ALL FROM c;
+
+MOVE BACKWARD ALL IN c;
diff --git a/__fixtures__/plpgsql-generated/generated.json b/__fixtures__/plpgsql-generated/generated.json
index 11206b7a2..edd638635 100644
--- a/__fixtures__/plpgsql-generated/generated.json
+++ b/__fixtures__/plpgsql-generated/generated.json
@@ -165,6 +165,10 @@
"plpgsql_deparser_fixes-65.sql": "CREATE FUNCTION test_alias_trigger() RETURNS trigger\nLANGUAGE plpgsql AS $$\nDECLARE\n prior ALIAS FOR old;\n updated ALIAS FOR new;\nBEGIN\n updated.updated_at := now();\n RETURN updated;\nEND$$",
"plpgsql_deparser_fixes-66.sql": "CREATE FUNCTION test_assign_named_args() RETURNS text\nLANGUAGE plpgsql AS $$\nDECLARE\n v_body text;\nBEGIN\n v_body := ast_helpers.create_function(v_schema_name := 'app_public', v_function_name := 'f');\n RETURN v_body;\nEND$$",
"plpgsql_deparser_fixes-67.sql": "CREATE FUNCTION test_assign_targets() RETURNS void\nLANGUAGE plpgsql AS $$\nDECLARE\n a int[] := ARRAY[1, 2];\n r record;\nBEGIN\n SELECT 1 AS f INTO r;\n a[2] := coalesce(nullif(3, 0), 4);\n r.f := greatest(1, 2);\nEND$$",
+ "plpgsql_deparser_fixes-68.sql": "CREATE FUNCTION test_multiline_literals(p_on boolean) RETURNS void\nLANGUAGE plpgsql AS $$\nBEGIN\n IF p_on THEN\n RAISE NOTICE 'line one\n line two';\n EXECUTE format('SELECT 1\n FROM %I', 'pg_class');\n PERFORM E'it''s\\\n escaped', $q$dollar\n quoted$q$;\n END IF;\nEND$$",
+ "plpgsql_deparser_fixes-69.sql": "CREATE FUNCTION test_quoted_char_type() RETURNS \"char\"\nLANGUAGE plpgsql AS $$\nDECLARE\n c \"char\" := 'a';\nBEGIN\n RETURN c;\nEND$$",
+ "plpgsql_deparser_fixes-70.sql": "CREATE FUNCTION test_case_empty_else(x int) RETURNS text\nLANGUAGE plpgsql AS $$\nBEGIN\n CASE x\n WHEN 1 THEN RETURN 'one';\n ELSE\n END CASE;\n RETURN 'other';\nEND$$",
+ "plpgsql_deparser_fixes-71.sql": "CREATE FUNCTION test_nested_comment_literal(p_on boolean) RETURNS void\nLANGUAGE plpgsql AS $$\nBEGIN\n IF p_on THEN\n PERFORM /* outer /* inner */ it's */ 'line one\n line two';\n END IF;\nEND$$",
"plpgsql_control-1.sql": "do $$\nbegin\n -- basic case\n for i in 1..3 loop\n raise notice '1..3: i = %', i;\n end loop;\n -- with BY, end matches exactly\n for i in 1..10 by 3 loop\n raise notice '1..10 by 3: i = %', i;\n end loop;\n -- with BY, end does not match\n for i in 1..11 by 3 loop\n raise notice '1..11 by 3: i = %', i;\n end loop;\n -- zero iterations\n for i in 1..0 by 3 loop\n raise notice '1..0 by 3: i = %', i;\n end loop;\n -- REVERSE\n for i in reverse 10..0 by 3 loop\n raise notice 'reverse 10..0 by 3: i = %', i;\n end loop;\n -- potential overflow\n for i in 2147483620..2147483647 by 10 loop\n raise notice '2147483620..2147483647 by 10: i = %', i;\n end loop;\n -- potential overflow, reverse direction\n for i in reverse -2147483620..-2147483647 by 10 loop\n raise notice 'reverse -2147483620..-2147483647 by 10: i = %', i;\n end loop;\nend$$",
"plpgsql_control-2.sql": "do $$\nbegin\n for i in 1..3 by 0 loop\n raise notice '1..3 by 0: i = %', i;\n end loop;\nend$$",
"plpgsql_control-3.sql": "do $$\nbegin\n for i in 1..3 by -1 loop\n raise notice '1..3 by -1: i = %', i;\n end loop;\nend$$",
diff --git a/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql b/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql
index 56ac57d08..7d8ac0a39 100644
--- a/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql
+++ b/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql
@@ -837,3 +837,48 @@ BEGIN
a[2] := coalesce(nullif(3, 0), 4);
r.f := greatest(1, 2);
END$$;
+
+-- Test 68: multi-line string literals keep their exact text inside nested blocks
+CREATE FUNCTION test_multiline_literals(p_on boolean) RETURNS void
+LANGUAGE plpgsql AS $$
+BEGIN
+ IF p_on THEN
+ RAISE NOTICE 'line one
+ line two';
+ EXECUTE format('SELECT 1
+ FROM %I', 'pg_class');
+ PERFORM E'it''s\
+ escaped', $q$dollar
+ quoted$q$;
+ END IF;
+END$$;
+
+-- Test 69: quoted "char" type keeps its quotes
+CREATE FUNCTION test_quoted_char_type() RETURNS "char"
+LANGUAGE plpgsql AS $$
+DECLARE
+ c "char" := 'a';
+BEGIN
+ RETURN c;
+END$$;
+
+-- Test 70: CASE with an empty ELSE keeps the ELSE
+CREATE FUNCTION test_case_empty_else(x int) RETURNS text
+LANGUAGE plpgsql AS $$
+BEGIN
+ CASE x
+ WHEN 1 THEN RETURN 'one';
+ ELSE
+ END CASE;
+ RETURN 'other';
+END$$;
+
+-- Test 71: nested block comments do not hide the string literal after them
+CREATE FUNCTION test_nested_comment_literal(p_on boolean) RETURNS void
+LANGUAGE plpgsql AS $$
+BEGIN
+ IF p_on THEN
+ PERFORM /* outer /* inner */ it's */ 'line one
+ line two';
+ END IF;
+END$$;
diff --git a/__fixtures__/regress/known-failures.deparser.json b/__fixtures__/regress/known-failures.deparser.json
new file mode 100644
index 000000000..5b1e8f010
--- /dev/null
+++ b/__fixtures__/regress/known-failures.deparser.json
@@ -0,0 +1,225 @@
+{
+ "postgres_regress/alter_generic.sql": [
+ "postgres_regress/alter_generic-237.sql",
+ "postgres_regress/alter_generic-238.sql",
+ "postgres_regress/alter_generic-243.sql",
+ "postgres_regress/alter_generic-244.sql",
+ "postgres_regress/alter_generic-245.sql",
+ "postgres_regress/alter_generic-314.sql",
+ "postgres_regress/alter_generic-323.sql"
+ ],
+ "postgres_regress/alter_operator.sql": [
+ "postgres_regress/alter_operator-21.sql"
+ ],
+ "postgres_regress/bit.sql": [
+ "postgres_regress/bit-17.sql",
+ "postgres_regress/bit-18.sql"
+ ],
+ "postgres_regress/collate.linux.utf8.sql": [
+ "postgres_regress/collate.linux.utf8-156.sql",
+ "postgres_regress/collate.linux.utf8-157.sql"
+ ],
+ "postgres_regress/collate.sql": [
+ "postgres_regress/collate-126.sql",
+ "postgres_regress/collate-143.sql"
+ ],
+ "postgres_regress/compression.sql": [
+ "postgres_regress/compression-17.sql"
+ ],
+ "postgres_regress/copy.sql": [
+ "postgres_regress/copy-22.sql"
+ ],
+ "postgres_regress/copy2.sql": [
+ "postgres_regress/copy2-106.sql"
+ ],
+ "postgres_regress/copyselect.sql": [
+ "postgres_regress/copyselect-18.sql",
+ "postgres_regress/copyselect-24.sql"
+ ],
+ "postgres_regress/database.sql": [
+ "postgres_regress/database-2.sql"
+ ],
+ "postgres_regress/domain.sql": [
+ "postgres_regress/domain-8.sql",
+ "postgres_regress/domain-17.sql",
+ "postgres_regress/domain-253.sql",
+ "postgres_regress/domain-287.sql",
+ "postgres_regress/domain-291.sql",
+ "postgres_regress/domain-293.sql"
+ ],
+ "postgres_regress/drop_if_exists.sql": [
+ "postgres_regress/drop_if_exists-158.sql",
+ "postgres_regress/drop_if_exists-159.sql",
+ "postgres_regress/drop_if_exists-160.sql",
+ "postgres_regress/drop_if_exists-161.sql"
+ ],
+ "postgres_regress/foreign_data.sql": [
+ "postgres_regress/foreign_data-77.sql",
+ "postgres_regress/foreign_data-216.sql",
+ "postgres_regress/foreign_data-237.sql",
+ "postgres_regress/foreign_data-295.sql",
+ "postgres_regress/foreign_data-493.sql"
+ ],
+ "postgres_regress/groupingsets.sql": [
+ "postgres_regress/groupingsets-188.sql"
+ ],
+ "postgres_regress/matview.sql": [
+ "postgres_regress/matview-162.sql",
+ "postgres_regress/matview-165.sql"
+ ],
+ "postgres_regress/misc.sql": [
+ "postgres_regress/misc-30.sql"
+ ],
+ "postgres_regress/namespace.sql": [
+ "postgres_regress/namespace-40.sql"
+ ],
+ "postgres_regress/numerology.sql": [
+ "postgres_regress/numerology-68.sql"
+ ],
+ "postgres_regress/object_address.sql": [
+ "postgres_regress/object_address-35.sql"
+ ],
+ "postgres_regress/plpgsql.sql": [
+ "postgres_regress/plpgsql-937.sql",
+ "postgres_regress/plpgsql-939.sql"
+ ],
+ "postgres_regress/privileges.sql": [
+ "postgres_regress/privileges-978.sql",
+ "postgres_regress/privileges-983.sql",
+ "postgres_regress/privileges-987.sql",
+ "postgres_regress/privileges-992.sql"
+ ],
+ "postgres_regress/reloptions.sql": [
+ "postgres_regress/reloptions-48.sql",
+ "postgres_regress/reloptions-58.sql"
+ ],
+ "postgres_regress/rules.sql": [
+ "postgres_regress/rules-481.sql",
+ "postgres_regress/rules-572.sql"
+ ],
+ "postgres_regress/sequence.sql": [
+ "postgres_regress/sequence-13.sql",
+ "postgres_regress/sequence-14.sql",
+ "postgres_regress/sequence-15.sql",
+ "postgres_regress/sequence-16.sql",
+ "postgres_regress/sequence-17.sql",
+ "postgres_regress/sequence-18.sql",
+ "postgres_regress/sequence-19.sql",
+ "postgres_regress/sequence-20.sql",
+ "postgres_regress/sequence-21.sql",
+ "postgres_regress/sequence-22.sql",
+ "postgres_regress/sequence-23.sql",
+ "postgres_regress/sequence-24.sql",
+ "postgres_regress/sequence-25.sql",
+ "postgres_regress/sequence-26.sql",
+ "postgres_regress/sequence-27.sql",
+ "postgres_regress/sequence-28.sql",
+ "postgres_regress/sequence-29.sql",
+ "postgres_regress/sequence-30.sql",
+ "postgres_regress/sequence-31.sql",
+ "postgres_regress/sequence-32.sql",
+ "postgres_regress/sequence-33.sql",
+ "postgres_regress/sequence-34.sql",
+ "postgres_regress/sequence-35.sql",
+ "postgres_regress/sequence-36.sql",
+ "postgres_regress/sequence-154.sql"
+ ],
+ "postgres_regress/sqljson_queryfuncs.sql": [
+ "postgres_regress/sqljson_queryfuncs-186.sql",
+ "postgres_regress/sqljson_queryfuncs-187.sql"
+ ],
+ "postgres_regress/stats_ext.sql": [
+ "postgres_regress/stats_ext-39.sql"
+ ],
+ "postgres_regress/stats_import.sql": [
+ "postgres_regress/stats_import-121.sql"
+ ],
+ "postgres_regress/stats.sql": [
+ "postgres_regress/stats-371.sql"
+ ],
+ "postgres_regress/subselect.sql": [
+ "postgres_regress/subselect-99.sql",
+ "postgres_regress/subselect-100.sql"
+ ],
+ "postgres_regress/tablespace.sql": [
+ "postgres_regress/tablespace-2.sql",
+ "postgres_regress/tablespace-4.sql",
+ "postgres_regress/tablespace-5.sql",
+ "postgres_regress/tablespace-13.sql",
+ "postgres_regress/tablespace-14.sql",
+ "postgres_regress/tablespace-15.sql",
+ "postgres_regress/tablespace-16.sql",
+ "postgres_regress/tablespace-17.sql",
+ "postgres_regress/tablespace-18.sql",
+ "postgres_regress/tablespace-19.sql",
+ "postgres_regress/tablespace-20.sql",
+ "postgres_regress/tablespace-21.sql",
+ "postgres_regress/tablespace-22.sql",
+ "postgres_regress/tablespace-26.sql",
+ "postgres_regress/tablespace-27.sql",
+ "postgres_regress/tablespace-29.sql",
+ "postgres_regress/tablespace-30.sql",
+ "postgres_regress/tablespace-40.sql",
+ "postgres_regress/tablespace-61.sql",
+ "postgres_regress/tablespace-170.sql",
+ "postgres_regress/tablespace-178.sql",
+ "postgres_regress/tablespace-190.sql",
+ "postgres_regress/tablespace-191.sql",
+ "postgres_regress/tablespace-193.sql",
+ "postgres_regress/tablespace-196.sql"
+ ],
+ "postgres_regress/timestamptz.sql": [
+ "postgres_regress/timestamptz-376.sql",
+ "postgres_regress/timestamptz-377.sql",
+ "postgres_regress/timestamptz-382.sql"
+ ],
+ "postgres_regress/timetz.sql": [
+ "postgres_regress/timetz-53.sql"
+ ],
+ "postgres_regress/transactions.sql": [
+ "postgres_regress/transactions-483.sql",
+ "postgres_regress/transactions-486.sql"
+ ],
+ "postgres_regress/triggers.sql": [
+ "postgres_regress/triggers-594.sql",
+ "postgres_regress/triggers-595.sql"
+ ],
+ "postgres_regress/tsdicts.sql": [
+ "postgres_regress/tsdicts-121.sql",
+ "postgres_regress/tsdicts-125.sql",
+ "postgres_regress/tsdicts-128.sql"
+ ],
+ "postgres_regress/vacuum_parallel.sql": [
+ "postgres_regress/vacuum_parallel-11.sql"
+ ],
+ "postgres_regress/vacuum.sql": [
+ "postgres_regress/vacuum-94.sql",
+ "postgres_regress/vacuum-98.sql",
+ "postgres_regress/vacuum-99.sql",
+ "postgres_regress/vacuum-100.sql",
+ "postgres_regress/vacuum-101.sql",
+ "postgres_regress/vacuum-104.sql",
+ "postgres_regress/vacuum-110.sql",
+ "postgres_regress/vacuum-111.sql",
+ "postgres_regress/vacuum-119.sql",
+ "postgres_regress/vacuum-120.sql",
+ "postgres_regress/vacuum-134.sql",
+ "postgres_regress/vacuum-135.sql",
+ "postgres_regress/vacuum-136.sql",
+ "postgres_regress/vacuum-139.sql",
+ "postgres_regress/vacuum-144.sql",
+ "postgres_regress/vacuum-232.sql",
+ "postgres_regress/vacuum-234.sql",
+ "postgres_regress/vacuum-236.sql",
+ "postgres_regress/vacuum-237.sql",
+ "postgres_regress/vacuum-239.sql",
+ "postgres_regress/vacuum-244.sql",
+ "postgres_regress/vacuum-245.sql",
+ "postgres_regress/vacuum-246.sql",
+ "postgres_regress/vacuum-247.sql",
+ "postgres_regress/vacuum-248.sql",
+ "postgres_regress/vacuum-249.sql",
+ "postgres_regress/vacuum-250.sql",
+ "postgres_regress/vacuum-251.sql"
+ ]
+}
diff --git a/__fixtures__/regress/known-failures.plpgsql-deparser.json b/__fixtures__/regress/known-failures.plpgsql-deparser.json
new file mode 100644
index 000000000..2b204a53f
--- /dev/null
+++ b/__fixtures__/regress/known-failures.plpgsql-deparser.json
@@ -0,0 +1,67 @@
+[
+ "plpgsql_samples-28.sql",
+ "plpgsql_samples-31.sql",
+ "plpgsql_samples-33.sql",
+ "plpgsql_samples-34.sql",
+ "plpgsql_samples-36.sql",
+ "postgres_regress/case-43.sql",
+ "postgres_regress/case-46.sql",
+ "postgres_regress/case-53.sql",
+ "postgres_regress/case-54.sql",
+ "postgres_regress/copy2-2.sql",
+ "postgres_regress/copy2-3.sql",
+ "postgres_regress/event_trigger-79.sql",
+ "postgres_regress/explain-46.sql",
+ "postgres_regress/plpgsql-22.sql",
+ "postgres_regress/plpgsql-24.sql",
+ "postgres_regress/plpgsql-28.sql",
+ "postgres_regress/plpgsql-30.sql",
+ "postgres_regress/plpgsql-34.sql",
+ "postgres_regress/plpgsql-38.sql",
+ "postgres_regress/plpgsql-40.sql",
+ "postgres_regress/plpgsql-44.sql",
+ "postgres_regress/plpgsql-46.sql",
+ "postgres_regress/plpgsql-48.sql",
+ "postgres_regress/plpgsql-54.sql",
+ "postgres_regress/plpgsql-60.sql",
+ "postgres_regress/plpgsql-64.sql",
+ "postgres_regress/plpgsql-66.sql",
+ "postgres_regress/plpgsql-68.sql",
+ "postgres_regress/plpgsql-70.sql",
+ "postgres_regress/plpgsql-72.sql",
+ "postgres_regress/plpgsql-74.sql",
+ "postgres_regress/plpgsql-76.sql",
+ "postgres_regress/plpgsql-80.sql",
+ "postgres_regress/plpgsql-81.sql",
+ "postgres_regress/plpgsql-82.sql",
+ "postgres_regress/plpgsql-88.sql",
+ "postgres_regress/plpgsql-89.sql",
+ "postgres_regress/plpgsql-90.sql",
+ "postgres_regress/plpgsql-91.sql",
+ "postgres_regress/plpgsql-92.sql",
+ "postgres_regress/plpgsql-283.sql",
+ "postgres_regress/plpgsql-286.sql",
+ "postgres_regress/plpgsql-289.sql",
+ "postgres_regress/plpgsql-291.sql",
+ "postgres_regress/plpgsql-293.sql",
+ "postgres_regress/plpgsql-295.sql",
+ "postgres_regress/plpgsql-298.sql",
+ "postgres_regress/plpgsql-364.sql",
+ "postgres_regress/plpgsql-365.sql",
+ "postgres_regress/plpgsql-387.sql",
+ "postgres_regress/plpgsql-424.sql",
+ "postgres_regress/plpgsql-504.sql",
+ "postgres_regress/plpgsql-854.sql",
+ "postgres_regress/plpgsql-857.sql",
+ "postgres_regress/privileges-766.sql",
+ "postgres_regress/rangefuncs-128.sql",
+ "postgres_regress/rangefuncs-137.sql",
+ "postgres_regress/rangefuncs-168.sql",
+ "postgres_regress/rowsecurity-23.sql",
+ "postgres_regress/rowsecurity-1068.sql",
+ "postgres_regress/select_views-5.sql",
+ "postgres_regress/stats_ext-741.sql",
+ "postgres_regress/transactions-239.sql",
+ "postgres_regress/transactions-244.sql",
+ "postgres_regress/triggers-261.sql"
+]
diff --git a/__fixtures__/regress/statements.json.gz b/__fixtures__/regress/statements.json.gz
new file mode 100644
index 000000000..1d972dd32
Binary files /dev/null and b/__fixtures__/regress/statements.json.gz differ
diff --git a/packages/deparser/__tests__/kitchen-sink/misc-alter-statistics.test.ts b/packages/deparser/__tests__/kitchen-sink/misc-alter-statistics.test.ts
new file mode 100644
index 000000000..14c4da271
--- /dev/null
+++ b/packages/deparser/__tests__/kitchen-sink/misc-alter-statistics.test.ts
@@ -0,0 +1,17 @@
+
+import { FixtureTestUtils } from '../../test-utils';
+const fixtures = new FixtureTestUtils();
+
+it('misc-alter-statistics', async () => {
+ await fixtures.runFixtureTests([
+ "misc/alter-statistics-1.sql",
+ "misc/alter-statistics-2.sql",
+ "misc/alter-statistics-3.sql",
+ "misc/alter-statistics-4.sql",
+ "misc/alter-statistics-5.sql",
+ "misc/alter-statistics-6.sql",
+ "misc/alter-statistics-7.sql",
+ "misc/alter-statistics-8.sql",
+ "misc/alter-statistics-9.sql"
+]);
+});
diff --git a/packages/deparser/__tests__/kitchen-sink/misc-alter-trigger-rename.test.ts b/packages/deparser/__tests__/kitchen-sink/misc-alter-trigger-rename.test.ts
new file mode 100644
index 000000000..aa8b9f69a
--- /dev/null
+++ b/packages/deparser/__tests__/kitchen-sink/misc-alter-trigger-rename.test.ts
@@ -0,0 +1,11 @@
+
+import { FixtureTestUtils } from '../../test-utils';
+const fixtures = new FixtureTestUtils();
+
+it('misc-alter-trigger-rename', async () => {
+ await fixtures.runFixtureTests([
+ "misc/alter-trigger-rename-1.sql",
+ "misc/alter-trigger-rename-2.sql",
+ "misc/alter-trigger-rename-3.sql"
+]);
+});
diff --git a/packages/deparser/__tests__/kitchen-sink/misc-copy-option-values.test.ts b/packages/deparser/__tests__/kitchen-sink/misc-copy-option-values.test.ts
new file mode 100644
index 000000000..5442ff200
--- /dev/null
+++ b/packages/deparser/__tests__/kitchen-sink/misc-copy-option-values.test.ts
@@ -0,0 +1,12 @@
+
+import { FixtureTestUtils } from '../../test-utils';
+const fixtures = new FixtureTestUtils();
+
+it('misc-copy-option-values', async () => {
+ await fixtures.runFixtureTests([
+ "misc/copy-option-values-1.sql",
+ "misc/copy-option-values-2.sql",
+ "misc/copy-option-values-3.sql",
+ "misc/copy-option-values-4.sql"
+]);
+});
diff --git a/packages/deparser/__tests__/kitchen-sink/misc-fetch-with-ties.test.ts b/packages/deparser/__tests__/kitchen-sink/misc-fetch-with-ties.test.ts
new file mode 100644
index 000000000..acd1d65bf
--- /dev/null
+++ b/packages/deparser/__tests__/kitchen-sink/misc-fetch-with-ties.test.ts
@@ -0,0 +1,16 @@
+
+import { FixtureTestUtils } from '../../test-utils';
+const fixtures = new FixtureTestUtils();
+
+it('misc-fetch-with-ties', async () => {
+ await fixtures.runFixtureTests([
+ "misc/fetch-with-ties-1.sql",
+ "misc/fetch-with-ties-2.sql",
+ "misc/fetch-with-ties-3.sql",
+ "misc/fetch-with-ties-4.sql",
+ "misc/fetch-with-ties-5.sql",
+ "misc/fetch-with-ties-6.sql",
+ "misc/fetch-with-ties-7.sql",
+ "misc/fetch-with-ties-8.sql"
+]);
+});
diff --git a/packages/deparser/__tests__/kitchen-sink/misc-json-query-functions.test.ts b/packages/deparser/__tests__/kitchen-sink/misc-json-query-functions.test.ts
new file mode 100644
index 000000000..cc4dbcf36
--- /dev/null
+++ b/packages/deparser/__tests__/kitchen-sink/misc-json-query-functions.test.ts
@@ -0,0 +1,29 @@
+
+import { FixtureTestUtils } from '../../test-utils';
+const fixtures = new FixtureTestUtils();
+
+it('misc-json-query-functions', async () => {
+ await fixtures.runFixtureTests([
+ "misc/json-query-functions-1.sql",
+ "misc/json-query-functions-2.sql",
+ "misc/json-query-functions-3.sql",
+ "misc/json-query-functions-4.sql",
+ "misc/json-query-functions-5.sql",
+ "misc/json-query-functions-6.sql",
+ "misc/json-query-functions-7.sql",
+ "misc/json-query-functions-8.sql",
+ "misc/json-query-functions-9.sql",
+ "misc/json-query-functions-10.sql",
+ "misc/json-query-functions-11.sql",
+ "misc/json-query-functions-12.sql",
+ "misc/json-query-functions-13.sql",
+ "misc/json-query-functions-14.sql",
+ "misc/json-query-functions-15.sql",
+ "misc/json-query-functions-16.sql",
+ "misc/json-query-functions-17.sql",
+ "misc/json-query-functions-18.sql",
+ "misc/json-query-functions-19.sql",
+ "misc/json-query-functions-20.sql",
+ "misc/json-query-functions-21.sql"
+]);
+});
diff --git a/packages/deparser/__tests__/kitchen-sink/misc-json-table.test.ts b/packages/deparser/__tests__/kitchen-sink/misc-json-table.test.ts
new file mode 100644
index 000000000..389769ef2
--- /dev/null
+++ b/packages/deparser/__tests__/kitchen-sink/misc-json-table.test.ts
@@ -0,0 +1,19 @@
+
+import { FixtureTestUtils } from '../../test-utils';
+const fixtures = new FixtureTestUtils();
+
+it('misc-json-table', async () => {
+ await fixtures.runFixtureTests([
+ "misc/json-table-1.sql",
+ "misc/json-table-2.sql",
+ "misc/json-table-3.sql",
+ "misc/json-table-4.sql",
+ "misc/json-table-5.sql",
+ "misc/json-table-6.sql",
+ "misc/json-table-7.sql",
+ "misc/json-table-8.sql",
+ "misc/json-table-9.sql",
+ "misc/json-table-10.sql",
+ "misc/json-table-11.sql"
+]);
+});
diff --git a/packages/deparser/__tests__/kitchen-sink/misc-operator-names.test.ts b/packages/deparser/__tests__/kitchen-sink/misc-operator-names.test.ts
new file mode 100644
index 000000000..c942c4f3c
--- /dev/null
+++ b/packages/deparser/__tests__/kitchen-sink/misc-operator-names.test.ts
@@ -0,0 +1,26 @@
+
+import { FixtureTestUtils } from '../../test-utils';
+const fixtures = new FixtureTestUtils();
+
+it('misc-operator-names', async () => {
+ await fixtures.runFixtureTests([
+ "misc/operator-names-1.sql",
+ "misc/operator-names-2.sql",
+ "misc/operator-names-3.sql",
+ "misc/operator-names-4.sql",
+ "misc/operator-names-5.sql",
+ "misc/operator-names-6.sql",
+ "misc/operator-names-7.sql",
+ "misc/operator-names-8.sql",
+ "misc/operator-names-9.sql",
+ "misc/operator-names-10.sql",
+ "misc/operator-names-11.sql",
+ "misc/operator-names-12.sql",
+ "misc/operator-names-13.sql",
+ "misc/operator-names-14.sql",
+ "misc/operator-names-15.sql",
+ "misc/operator-names-16.sql",
+ "misc/operator-names-17.sql",
+ "misc/operator-names-18.sql"
+]);
+});
diff --git a/packages/deparser/__tests__/kitchen-sink/misc-show-quoting.test.ts b/packages/deparser/__tests__/kitchen-sink/misc-show-quoting.test.ts
new file mode 100644
index 000000000..b4def2026
--- /dev/null
+++ b/packages/deparser/__tests__/kitchen-sink/misc-show-quoting.test.ts
@@ -0,0 +1,18 @@
+
+import { FixtureTestUtils } from '../../test-utils';
+const fixtures = new FixtureTestUtils();
+
+it('misc-show-quoting', async () => {
+ await fixtures.runFixtureTests([
+ "misc/show-quoting-1.sql",
+ "misc/show-quoting-2.sql",
+ "misc/show-quoting-3.sql",
+ "misc/show-quoting-4.sql",
+ "misc/show-quoting-5.sql",
+ "misc/show-quoting-6.sql",
+ "misc/show-quoting-7.sql",
+ "misc/show-quoting-8.sql",
+ "misc/show-quoting-9.sql",
+ "misc/show-quoting-10.sql"
+]);
+});
diff --git a/packages/deparser/__tests__/kitchen-sink/misc-tablespace-clauses.test.ts b/packages/deparser/__tests__/kitchen-sink/misc-tablespace-clauses.test.ts
new file mode 100644
index 000000000..64c3a56c0
--- /dev/null
+++ b/packages/deparser/__tests__/kitchen-sink/misc-tablespace-clauses.test.ts
@@ -0,0 +1,25 @@
+
+import { FixtureTestUtils } from '../../test-utils';
+const fixtures = new FixtureTestUtils();
+
+it('misc-tablespace-clauses', async () => {
+ await fixtures.runFixtureTests([
+ "misc/tablespace-clauses-1.sql",
+ "misc/tablespace-clauses-2.sql",
+ "misc/tablespace-clauses-3.sql",
+ "misc/tablespace-clauses-4.sql",
+ "misc/tablespace-clauses-5.sql",
+ "misc/tablespace-clauses-6.sql",
+ "misc/tablespace-clauses-7.sql",
+ "misc/tablespace-clauses-8.sql",
+ "misc/tablespace-clauses-9.sql",
+ "misc/tablespace-clauses-10.sql",
+ "misc/tablespace-clauses-11.sql",
+ "misc/tablespace-clauses-12.sql",
+ "misc/tablespace-clauses-13.sql",
+ "misc/tablespace-clauses-14.sql",
+ "misc/tablespace-clauses-15.sql",
+ "misc/tablespace-clauses-16.sql",
+ "misc/tablespace-clauses-17.sql"
+]);
+});
diff --git a/packages/deparser/__tests__/kitchen-sink/misc-transaction-chain.test.ts b/packages/deparser/__tests__/kitchen-sink/misc-transaction-chain.test.ts
new file mode 100644
index 000000000..30e58df28
--- /dev/null
+++ b/packages/deparser/__tests__/kitchen-sink/misc-transaction-chain.test.ts
@@ -0,0 +1,15 @@
+
+import { FixtureTestUtils } from '../../test-utils';
+const fixtures = new FixtureTestUtils();
+
+it('misc-transaction-chain', async () => {
+ await fixtures.runFixtureTests([
+ "misc/transaction-chain-1.sql",
+ "misc/transaction-chain-2.sql",
+ "misc/transaction-chain-3.sql",
+ "misc/transaction-chain-4.sql",
+ "misc/transaction-chain-5.sql",
+ "misc/transaction-chain-6.sql",
+ "misc/transaction-chain-7.sql"
+]);
+});
diff --git a/packages/deparser/__tests__/kitchen-sink/misc-xmlserialize-indent.test.ts b/packages/deparser/__tests__/kitchen-sink/misc-xmlserialize-indent.test.ts
new file mode 100644
index 000000000..3a1884420
--- /dev/null
+++ b/packages/deparser/__tests__/kitchen-sink/misc-xmlserialize-indent.test.ts
@@ -0,0 +1,12 @@
+
+import { FixtureTestUtils } from '../../test-utils';
+const fixtures = new FixtureTestUtils();
+
+it('misc-xmlserialize-indent', async () => {
+ await fixtures.runFixtureTests([
+ "misc/xmlserialize-indent-1.sql",
+ "misc/xmlserialize-indent-2.sql",
+ "misc/xmlserialize-indent-3.sql",
+ "misc/xmlserialize-indent-4.sql"
+]);
+});
diff --git a/packages/deparser/__tests__/kitchen-sink/pretty-misc.test.ts b/packages/deparser/__tests__/kitchen-sink/pretty-misc.test.ts
index 4ea3778e4..95ed6103d 100644
--- a/packages/deparser/__tests__/kitchen-sink/pretty-misc.test.ts
+++ b/packages/deparser/__tests__/kitchen-sink/pretty-misc.test.ts
@@ -20,6 +20,8 @@ it('pretty-misc', async () => {
"pretty/misc-14.sql",
"pretty/misc-15.sql",
"pretty/misc-16.sql",
- "pretty/misc-17.sql"
+ "pretty/misc-17.sql",
+ "pretty/misc-18.sql",
+ "pretty/misc-19.sql"
]);
});
diff --git a/packages/deparser/__tests__/misc/copy-boolean-options.test.ts b/packages/deparser/__tests__/misc/copy-boolean-options.test.ts
new file mode 100644
index 000000000..e6af5ad06
--- /dev/null
+++ b/packages/deparser/__tests__/misc/copy-boolean-options.test.ts
@@ -0,0 +1,21 @@
+import { Deparser } from '../../src';
+
+// The parser only emits Boolean(true) COPY option args (e.g. `CSV HEADER`), and the
+// JSON AST omits `boolval` when it is false, so a false Boolean must be hand-built.
+const copyWith = (defname: string, arg: any) => ({
+ CopyStmt: {
+ relation: { relname: 't', inh: true, relpersistence: 'p' },
+ is_from: true,
+ options: [{ DefElem: { defname, arg, defaction: 'DEFELEM_UNSPEC' } }]
+ }
+});
+
+describe('COPY Boolean option args', () => {
+ it.each([
+ ['false (boolval omitted)', { Boolean: {} }, 'COPY t FROM STDIN WITH (HEADER false)'],
+ ['false', { Boolean: { boolval: false } }, 'COPY t FROM STDIN WITH (HEADER false)'],
+ ['true', { Boolean: { boolval: true } }, 'COPY t FROM STDIN WITH (HEADER)']
+ ])('keeps a %s Boolean value', (_label, arg, expected) => {
+ expect(Deparser.deparse(copyWith('header', arg) as any)).toBe(expected);
+ });
+});
diff --git a/packages/deparser/__tests__/misc/operator-names.test.ts b/packages/deparser/__tests__/misc/operator-names.test.ts
new file mode 100644
index 000000000..ff42419b9
--- /dev/null
+++ b/packages/deparser/__tests__/misc/operator-names.test.ts
@@ -0,0 +1,38 @@
+import { Deparser } from '../../src';
+
+// The parser can only produce operator names that lex back as one operator token,
+// so names that would not round-trip have to be hand-built.
+const opExpr = (...name: string[]) => ({
+ A_Expr: {
+ kind: 'AEXPR_OP',
+ name: name.map(sval => ({ String: { sval } })),
+ lexpr: { ColumnRef: { fields: [{ String: { sval: 'a' } }] } },
+ rexpr: { ColumnRef: { fields: [{ String: { sval: 'b' } }] } }
+ }
+});
+
+describe('operator names', () => {
+ it.each([
+ ['+-'],
+ ['*-'],
+ ['--'],
+ ['/*'],
+ ['!='],
+ ['=>'],
+ ['and'],
+ [''],
+ ['<'.repeat(64)]
+ ])('rejects %j, which would not reparse as the same operator', op => {
+ expect(() => Deparser.deparse(opExpr(op) as any)).toThrow(/Invalid operator name/);
+ expect(() => Deparser.deparse(opExpr('s', op) as any)).toThrow(/Invalid operator name/);
+ });
+
+ it.each([
+ [['+'], 'a + b'],
+ [['@-@'], 'a @-@ b'],
+ [['~-'], 'a ~- b'],
+ [['My Schema', '<->'], 'a OPERATOR("My Schema".<->) b']
+ ])('keeps %j', (name, expected) => {
+ expect(Deparser.deparse(opExpr(...name) as any)).toBe(expected);
+ });
+});
diff --git a/packages/deparser/__tests__/pretty/__snapshots__/misc-pretty.test.ts.snap b/packages/deparser/__tests__/pretty/__snapshots__/misc-pretty.test.ts.snap
index 88f227f33..968434a51 100644
--- a/packages/deparser/__tests__/pretty/__snapshots__/misc-pretty.test.ts.snap
+++ b/packages/deparser/__tests__/pretty/__snapshots__/misc-pretty.test.ts.snap
@@ -34,6 +34,10 @@ exports[`non-pretty: pretty/misc-16.sql 1`] = `"GRANT EXECUTE ON FUNCTION dashbo
exports[`non-pretty: pretty/misc-17.sql 1`] = `"SELECT CAST((t.date AT TIME ZONE 'America/New_York')::text AS date) FROM tbl AS t"`;
+exports[`non-pretty: pretty/misc-18.sql 1`] = `"FETCH FORWARD ALL c"`;
+
+exports[`non-pretty: pretty/misc-19.sql 1`] = `"MOVE BACKWARD ALL c"`;
+
exports[`pretty: pretty/misc-1.sql 1`] = `
"WITH
recent_orders AS (SELECT
@@ -302,3 +306,7 @@ exports[`pretty: pretty/misc-17.sql 1`] = `
"SELECT CAST((t.date AT TIME ZONE 'America/New_York')::text AS date)
FROM tbl AS t"
`;
+
+exports[`pretty: pretty/misc-18.sql 1`] = `"FETCH FORWARD ALL c"`;
+
+exports[`pretty: pretty/misc-19.sql 1`] = `"MOVE BACKWARD ALL c"`;
diff --git a/packages/deparser/__tests__/pretty/misc-pretty.test.ts b/packages/deparser/__tests__/pretty/misc-pretty.test.ts
index 33d717cd7..ca2426747 100644
--- a/packages/deparser/__tests__/pretty/misc-pretty.test.ts
+++ b/packages/deparser/__tests__/pretty/misc-pretty.test.ts
@@ -18,6 +18,8 @@ const testCases = [
'pretty/misc-15.sql',
'pretty/misc-16.sql',
'pretty/misc-17.sql',
+ 'pretty/misc-18.sql',
+ 'pretty/misc-19.sql',
];
const prettyTest = new PrettyTest(testCases);
diff --git a/packages/deparser/__tests__/regress/regress-corpus.test.ts b/packages/deparser/__tests__/regress/regress-corpus.test.ts
new file mode 100644
index 000000000..228bd96c7
--- /dev/null
+++ b/packages/deparser/__tests__/regress/regress-corpus.test.ts
@@ -0,0 +1,68 @@
+/**
+ * Round-trips the PostgreSQL regression corpus (__fixtures__/regress/statements.json.gz,
+ * built by `npm run fixtures:regress`) through parse -> deparse -> parse, pretty and not.
+ *
+ * known-failures.deparser.json is a ratchet: a new failure fails the test, and so does a listed
+ * statement that now passes. After a deliberate change, refresh it with
+ * UPDATE_KNOWN_FAILURES=1 npx jest __tests__/regress
+ */
+import * as fs from 'fs';
+import * as path from 'path';
+import * as zlib from 'zlib';
+
+import { TestUtils } from '../../test-utils';
+
+const FIXTURE_DIR = path.join(__dirname, '../../../../__fixtures__/regress');
+const KNOWN_FAILURES_FILE = path.join(FIXTURE_DIR, 'known-failures.deparser.json');
+const UPDATE = process.env.UPDATE_KNOWN_FAILURES === '1';
+
+const { statements } = JSON.parse(
+ zlib.gunzipSync(fs.readFileSync(path.join(FIXTURE_DIR, 'statements.json.gz'))).toString('utf8')
+) as { statements: Record };
+const knownFailures: Record = JSON.parse(fs.readFileSync(KNOWN_FAILURES_FILE, 'utf8'));
+
+const byFile = new Map();
+for (const key of Object.keys(statements)) {
+ const file = key.replace(/-\d+\.sql$/, '.sql');
+ byFile.set(file, [...(byFile.get(file) ?? []), key]);
+}
+
+const observed: Record = {};
+const ran = new Set();
+const utils = new TestUtils();
+
+describe('regress corpus round-trip', () => {
+ beforeAll(() => {
+ for (const level of ['error', 'warn', 'log'] as const) jest.spyOn(console, level).mockImplementation(() => {});
+ });
+ afterAll(() => {
+ jest.restoreAllMocks();
+ if (UPDATE) {
+ // Only replace entries for files that ran, so a filtered run (-t, --shard) keeps the rest
+ const merged = { ...knownFailures };
+ for (const file of ran) delete merged[file];
+ Object.assign(merged, observed);
+ for (const file of Object.keys(merged)) if (!byFile.has(file)) delete merged[file];
+ const sorted = Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b)));
+ fs.writeFileSync(KNOWN_FAILURES_FILE, JSON.stringify(sorted, null, 2) + '\n');
+ }
+ });
+
+ it('lists only files that are in the corpus', () => {
+ expect(Object.keys(knownFailures).filter((file) => !byFile.has(file))).toEqual([]);
+ });
+
+ it.each([...byFile.keys()])('%s', async (file) => {
+ const failing: string[] = [];
+ for (const key of byFile.get(file)!) {
+ try {
+ await utils.expectAstMatch(key, statements[key]);
+ } catch {
+ failing.push(key);
+ }
+ }
+ ran.add(file);
+ if (failing.length) observed[file] = failing;
+ if (!UPDATE) expect(failing).toEqual(knownFailures[file] ?? []);
+ });
+});
diff --git a/packages/deparser/package.json b/packages/deparser/package.json
index a853bdd75..47b2bcffa 100644
--- a/packages/deparser/package.json
+++ b/packages/deparser/package.json
@@ -31,6 +31,7 @@
"fixtures:sql": "ts-node scripts/make-fixtures-sql.ts",
"fixtures": "ts-node scripts/make-fixtures.ts",
"fixtures:upstream-diff": "ts-node scripts/make-upstream-diff.ts",
+ "fixtures:regress": "ts-node scripts/make-regress-fixtures.ts",
"lint": "eslint . --fix",
"test": "jest",
"test:watch": "jest --watch",
diff --git a/packages/deparser/scripts/clean-utils.ts b/packages/deparser/scripts/clean-utils.ts
index 4d32ebaa5..8d91dc7ed 100644
--- a/packages/deparser/scripts/clean-utils.ts
+++ b/packages/deparser/scripts/clean-utils.ts
@@ -64,6 +64,7 @@ export const cleanTree = (tree: any) => {
stmt_len: noop,
stmt_location: noop,
location: noop,
+ name_location: noop,
rexpr_list_start: noop,
rexpr_list_end: noop,
list_start: noop,
diff --git a/packages/deparser/scripts/make-regress-fixtures.ts b/packages/deparser/scripts/make-regress-fixtures.ts
new file mode 100644
index 000000000..2c595bd9a
--- /dev/null
+++ b/packages/deparser/scripts/make-regress-fixtures.ts
@@ -0,0 +1,110 @@
+#!/usr/bin/env ts-node
+/**
+ * Builds __fixtures__/regress/statements.json.gz from a libpg_query checkout:
+ * the PostgreSQL regression SQL it vendors (test/sql/postgres_regress, plpgsql_regress)
+ * plus its own test SQL (test/sql/deparse*, test/plpgsql_samples.sql).
+ *
+ * npm run fixtures:regress -- /path/to/libpg_query
+ *
+ * Check out the libpg_query commit that the pinned libpg-query release was built from
+ * (x-publish.libpgQueryCommit in libpg-query-node versions/18/package.json).
+ */
+import { execFileSync } from 'child_process';
+import * as fs from 'fs';
+import { sync as globSync } from 'glob';
+import { loadModule, parseSync, scanSync } from 'libpg-query';
+import * as path from 'path';
+import * as zlib from 'zlib';
+
+import { generateStatementKey } from './statement-splitter';
+
+const OUT_FILE = path.join(__dirname, '../../../__fixtures__/regress/statements.json.gz');
+const SOURCES = [
+ 'test/sql/postgres_regress/*.sql',
+ 'test/sql/plpgsql_regress/*.sql',
+ 'test/sql/deparse/*.sql',
+ 'test/sql/deparse-depesz/**/*.{sql,psql}',
+ 'test/plpgsql_samples.sql',
+];
+
+// Split on top-level semicolons using the real scanner, so string/dollar quoting and
+// comments are handled. CREATE RULE (...; ...) and BEGIN ATOMIC ... END bodies stay whole.
+function splitWithScanner(sql: string): string[] {
+ const buf = Buffer.from(sql, 'utf8');
+ const tokens = scanSync(sql).tokens;
+ const out: string[] = [];
+ let start = 0;
+ let parens = 0;
+ let atomic = 0;
+ let cases = 0;
+ let prev = '';
+ for (const tok of tokens) {
+ const word = tok.text.toUpperCase();
+ if (tok.text === '(') parens++;
+ else if (tok.text === ')') parens = Math.max(0, parens - 1);
+ else if (word === 'ATOMIC' && prev === 'BEGIN') atomic++;
+ else if (atomic > 0 && word === 'CASE') cases++;
+ else if (atomic > 0 && word === 'END') {
+ if (cases > 0) cases--;
+ else atomic--;
+ } else if (tok.text === ';' && parens === 0 && atomic === 0) {
+ out.push(buf.subarray(start, tok.start).toString('utf8'));
+ start = tok.end;
+ }
+ if (tok.tokenName !== 'SQL_COMMENT' && tok.tokenName !== 'C_COMMENT') prev = word;
+ }
+ out.push(buf.subarray(start).toString('utf8'));
+ return out;
+}
+
+function splitStatements(sql: string): string[] {
+ let pieces: string[];
+ try {
+ pieces = splitWithScanner(sql);
+ } catch {
+ // e.g. strings.sql has intentionally invalid escapes the scanner rejects
+ pieces = sql.split(/;[ \t]*\r?\n/);
+ }
+ return pieces
+ .map((s) => s.replace(/^(\s*--[^\n]*\n|\s+)+/, '').trim())
+ .filter((s) => s.length > 0);
+}
+
+async function main() {
+ const root = path.resolve(process.argv[2] || process.env.LIBPG_QUERY_DIR || '');
+ if (!fs.existsSync(path.join(root, 'test/sql/postgres_regress'))) {
+ throw new Error('usage: npm run fixtures:regress -- /path/to/libpg_query');
+ }
+ await loadModule();
+ const commit = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD']).toString().trim();
+ const files = SOURCES.flatMap((pattern) => globSync(pattern, { cwd: root })).sort();
+ const seen = new Set();
+ const statements: Record = {};
+ let skipped = 0;
+ for (const rel of files) {
+ const relKey = rel.replace(/^test\/(sql\/)?/, '').replace(/\.psql$/, '.sql');
+ splitStatements(fs.readFileSync(path.join(root, rel), 'utf8')).forEach((sql, i) => {
+ if (seen.has(sql)) return;
+ seen.add(sql);
+ try {
+ if (!parseSync(sql).stmts?.length) return;
+ } catch {
+ skipped++;
+ return;
+ }
+ statements[generateStatementKey(relKey, i)] = sql;
+ });
+ }
+ const json = JSON.stringify({ libpgQueryCommit: commit, statements });
+ fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
+ fs.writeFileSync(OUT_FILE, zlib.gzipSync(json, { level: 9 }));
+ console.log(
+ `${files.length} files, ${Object.keys(statements).length} statements (${skipped} unparseable skipped) ` +
+ `-> ${path.relative(process.cwd(), OUT_FILE)} (${fs.statSync(OUT_FILE).size} bytes)`
+ );
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts
index 94c7f6256..32b9fd44b 100644
--- a/packages/deparser/src/deparser.ts
+++ b/packages/deparser/src/deparser.ts
@@ -717,12 +717,28 @@ export class Deparser implements DeparserVisitor {
}
}
- if (node.limitCount) {
- output.push('LIMIT ' + this.visit(node.limitCount as Node, context));
- }
+ if (node.limitOption === 'LIMIT_OPTION_WITH_TIES') {
+ if (node.limitOffset) {
+ output.push('OFFSET ' + this.visit(node.limitOffset as Node, context) + ' ROWS');
+ }
+ const count = node.limitCount as Node | undefined;
+ if (count) {
+ let countStr = this.visit(count, context);
+ if (!('A_Const' in count) || countStr.startsWith('-')) {
+ countStr = `(${countStr})`;
+ }
+ output.push(`FETCH FIRST ${countStr} ROWS WITH TIES`);
+ } else {
+ output.push('FETCH FIRST ROWS WITH TIES');
+ }
+ } else {
+ if (node.limitCount) {
+ output.push('LIMIT ' + this.visit(node.limitCount as Node, context));
+ }
- if (node.limitOffset) {
- output.push('OFFSET ' + this.visit(node.limitOffset as Node, context));
+ if (node.limitOffset) {
+ output.push('OFFSET ' + this.visit(node.limitOffset as Node, context));
+ }
}
if (node.lockingClause) {
@@ -965,18 +981,36 @@ export class Deparser implements DeparserVisitor {
return '';
}
- const parts = name.map((n: any) => {
- if (n.String) {
- return n.String.sval || n.String.str;
- }
- return this.visit(n, context);
- });
+ const qualified = this.qualifiedOperatorName(name);
+ return name.length > 1 ? `OPERATOR(${qualified})` : qualified;
+ }
- if (parts.length > 1) {
- return `OPERATOR(${parts.join('.')})`;
+ /**
+ * Renders `schema.op` with the schema quoted. Throws if the operator symbol
+ * would not lex back as a single operator token (e.g. `+-`, `--`, `!=`).
+ */
+ qualifiedOperatorName(name: t.Node[]): string {
+ const parts = ListUtils.unwrapList(name).map((n: any) => n.String ? (n.String.sval ?? n.String.str ?? '') : '');
+ const op = parts.pop() ?? '';
+ if (!this.isValidOperatorSymbol(op)) {
+ throw new Error(`Invalid operator name: ${JSON.stringify(op)}`);
}
+ return [...parts.map(part => QuoteUtils.quoteIdentifier(part)), op].join('.');
+ }
- return parts.join('.');
+ private isValidOperatorSymbol(op: string): boolean {
+ if (!/^[~!@#^&|`?+\-*/%<>=]+$/.test(op) || op.length >= 64) return false;
+ if (op.includes('--') || op.includes('/*')) return false;
+ // The lexer rewrites `!=` to `<>` and treats `=>` as its own token.
+ if (op === '!=' || op === '=>') return false;
+ // A multi-char operator may only end in + or - if it contains one of these.
+ return op.length === 1 || !/[+-]$/.test(op) || /[~!@#^&|`?%]/.test(op);
+ }
+
+ private isOperatorNameList(arg: t.Node | undefined): boolean {
+ if (!arg || this.getNodeType(arg) !== 'List') return false;
+ const items = ListUtils.unwrapList(this.getNodeData(arg).items);
+ return items.length > 0 && items.every((item: any) => item.String);
}
private getOperatorPrecedence(operator: string): number {
@@ -2808,6 +2842,10 @@ export class Deparser implements DeparserVisitor {
output.push(this.quoteIfNeeded(node.accessMethod));
}
+ if (node.tablespacename) {
+ output.push('TABLESPACE', QuoteUtils.quoteIdentifier(node.tablespacename));
+ }
+
return output.join(' ');
}
@@ -3018,10 +3056,7 @@ export class Deparser implements DeparserVisitor {
}
output.push(`(${keyItems.join(', ')})`);
}
- if (node.indexname) {
- output.push('USING INDEX');
- output.push(node.indexname);
- }
+ this.pushIndexConstraintOptions(node, output, context);
break;
case 'CONSTR_UNIQUE':
if (context.isPretty() && !context.isColumnConstraint) {
@@ -3040,10 +3075,7 @@ export class Deparser implements DeparserVisitor {
}
output.push(`(${keyItems.join(', ')})`);
}
- if (node.indexname) {
- output.push('USING INDEX');
- output.push(node.indexname);
- }
+ this.pushIndexConstraintOptions(node, output, context);
break;
case 'CONSTR_FOREIGN':
// Only add "FOREIGN KEY" for table-level constraints, not column-level constraints
@@ -3211,10 +3243,7 @@ export class Deparser implements DeparserVisitor {
const operatorNode = elemList[1];
let operator = '';
if (this.getNodeType(operatorNode) === 'List') {
- const operatorList = ListUtils.unwrapList(operatorNode);
- if (operatorList.length > 0 && operatorList[0].String) {
- operator = operatorList[0].String.sval;
- }
+ operator = this.deparseOperatorName(ListUtils.unwrapList(operatorNode), context);
} else if (operatorNode.String) {
operator = operatorNode.String.sval;
} else {
@@ -3227,6 +3256,7 @@ export class Deparser implements DeparserVisitor {
});
output.push(`(${exclusionElements.join(', ')})`);
}
+ this.pushIndexConstraintOptions(node, output, context);
if (node.where_clause) {
output.push('WHERE');
output.push(context.parens(this.visit(node.where_clause as any, context)));
@@ -3280,6 +3310,25 @@ export class Deparser implements DeparserVisitor {
return output.join(' ');
}
+ private pushIndexConstraintOptions(node: t.Constraint, output: string[], context: DeparserContext): void {
+ if (node.indexname) {
+ output.push('USING INDEX', QuoteUtils.quoteIdentifier(node.indexname));
+ return;
+ }
+ if (node.including && node.including.length > 0) {
+ const cols = ListUtils.unwrapList(node.including).map(col => this.visit(col, context));
+ output.push(`INCLUDE (${cols.join(', ')})`);
+ }
+ if (node.options && node.options.length > 0) {
+ const optionContext = context.spawn('IndexStmt');
+ const opts = ListUtils.unwrapList(node.options).map(opt => this.visit(opt, optionContext));
+ output.push(`WITH (${opts.join(', ')})`);
+ }
+ if (node.indexspace) {
+ output.push('USING INDEX TABLESPACE', QuoteUtils.quoteIdentifier(node.indexspace));
+ }
+ }
+
SubLink(node: t.SubLink, context: DeparserContext): string {
const subselect = context.parens(this.visit(node.subselect, context));
@@ -4347,6 +4396,10 @@ export class Deparser implements DeparserVisitor {
}
}
+ if (node.chain) {
+ output.push('AND CHAIN');
+ }
+
return output.join(' ');
}
@@ -4466,10 +4519,10 @@ export class Deparser implements DeparserVisitor {
}
VariableShowStmt(node: t.VariableShowStmt, context: DeparserContext): string {
- if (node.name === 'ALL') {
+ if (node.name === 'all') {
return 'SHOW ALL';
}
- return `SHOW ${node.name}`;
+ return `SHOW ${QuoteUtils.quoteDottedName(node.name.split('.'))}`;
}
CreateSchemaStmt(node: t.CreateSchemaStmt, context: DeparserContext): string {
@@ -5941,13 +5994,9 @@ export class Deparser implements DeparserVisitor {
// Handle CREATE OPERATOR commutator/negator - MUST be first to prevent quoting
if (context.parentNodeTypes.includes('DefineStmt') &&
['commutator', 'negator'].includes(node.defname.toLowerCase())) {
- if (node.arg && this.getNodeType(node.arg) === 'List') {
- const listData = this.getNodeData(node.arg);
- const listItems = ListUtils.unwrapList(listData.items);
- if (listItems.length === 1 && listItems[0].String) {
- const preservedName = this.preserveOperatorDefElemCase(node.defname);
- return `${preservedName} = ${listItems[0].String.sval}`;
- }
+ if (this.isOperatorNameList(node.arg)) {
+ const preservedName = this.preserveOperatorDefElemCase(node.defname);
+ return `${preservedName} = ${this.deparseOperatorName(ListUtils.unwrapList(this.getNodeData(node.arg).items), context)}`;
}
}
@@ -6053,12 +6102,8 @@ export class Deparser implements DeparserVisitor {
}
}
- if (node.arg && this.getNodeType(node.arg) === 'List') {
- const listData = this.getNodeData(node.arg);
- const listItems = ListUtils.unwrapList(listData.items);
- if (listItems.length === 1 && listItems[0].String) {
- return `${node.defname} = ${listItems[0].String.sval}`;
- }
+ if (this.isOperatorNameList(node.arg)) {
+ return `${node.defname} = ${this.deparseOperatorName(ListUtils.unwrapList(this.getNodeData(node.arg).items), context)}`;
}
}
@@ -6187,7 +6232,7 @@ export class Deparser implements DeparserVisitor {
}
if (context.parentNodeTypes.includes('CreateTableSpaceStmt') || context.parentNodeTypes.includes('AlterTableSpaceOptionsStmt')) {
- return `${node.defname.toUpperCase()} ${argValue}`;
+ return `${QuoteUtils.quoteIdentifier(node.defname)} = ${this.formatOptionValue(node.arg, argValue)}`;
}
if (context.parentNodeTypes.includes('ExplainStmt')) {
@@ -6397,8 +6442,8 @@ export class Deparser implements DeparserVisitor {
return optName;
}
const copyArgType = this.getNodeType(node.arg);
- if (copyArgType === 'Boolean' && this.getNodeData(node.arg).boolval !== false) {
- return optName;
+ if (copyArgType === 'Boolean') {
+ return this.getNodeData(node.arg).boolval ? optName : `${optName} false`;
}
if (copyArgType === 'List') {
const items = ListUtils.unwrapList(this.getNodeData(node.arg).items)
@@ -6407,7 +6452,7 @@ export class Deparser implements DeparserVisitor {
}
if (copyArgType === 'String') {
const sval: string = this.getNodeData(node.arg).sval;
- if (node.defname === 'format') {
+ if (node.defname === 'format' && QuoteUtils.quoteIdentifier(sval) === sval) {
return `${optName} ${sval}`;
}
return `${optName} ${QuoteUtils.escape(sval)}`;
@@ -6423,12 +6468,8 @@ export class Deparser implements DeparserVisitor {
if (['commutator', 'negator'].includes(node.defname.toLowerCase())) {
if (node.arg) {
// For commutator/negator, preserve the original operator symbol without quotes
- if (node.arg && this.getNodeType(node.arg) === 'List') {
- const listData = this.getNodeData(node.arg);
- const listItems = ListUtils.unwrapList(listData.items);
- if (listItems.length === 1 && listItems[0].String) {
- return `${preservedName} = ${listItems[0].String.sval}`;
- }
+ if (this.isOperatorNameList(node.arg)) {
+ return `${preservedName} = ${this.deparseOperatorName(ListUtils.unwrapList(this.getNodeData(node.arg).items), context)}`;
}
const unquotedValue = argValue.replace(/^"(.*)"$/, '$1');
return `${preservedName} = ${unquotedValue}`;
@@ -6525,7 +6566,7 @@ export class Deparser implements DeparserVisitor {
const output: string[] = ['CREATE', 'TABLESPACE'];
if (node.tablespacename) {
- output.push(node.tablespacename);
+ output.push(QuoteUtils.quoteIdentifier(node.tablespacename));
}
if (node.owner) {
@@ -6558,7 +6599,7 @@ export class Deparser implements DeparserVisitor {
}
if (node.tablespacename) {
- output.push(node.tablespacename);
+ output.push(QuoteUtils.quoteIdentifier(node.tablespacename));
}
return output.join(' ');
@@ -6568,7 +6609,7 @@ export class Deparser implements DeparserVisitor {
const output: string[] = ['ALTER', 'TABLESPACE'];
if (node.tablespacename) {
- output.push(node.tablespacename);
+ output.push(QuoteUtils.quoteIdentifier(node.tablespacename));
}
if (node.isReset) {
@@ -7120,12 +7161,7 @@ export class Deparser implements DeparserVisitor {
// For operators, we need to handle ObjectWithArgs structure
if (node.object && (node.object as any).ObjectWithArgs) {
const objWithArgs = (node.object as any).ObjectWithArgs;
- let operatorName = objWithArgs.objname && objWithArgs.objname[0] && objWithArgs.objname[0].String
- ? objWithArgs.objname[0].String.sval : 'unknown';
-
- if (operatorName.startsWith('"') && operatorName.endsWith('"')) {
- operatorName = operatorName.slice(1, -1);
- }
+ const operatorName = this.qualifiedOperatorName(objWithArgs.objname);
const args: string[] = [];
if (objWithArgs.objargs) {
@@ -7182,13 +7218,7 @@ export class Deparser implements DeparserVisitor {
} else if (node.objtype === 'OBJECT_OPERATOR' && node.object && (node.object as any).ObjectWithArgs) {
// Handle direct ObjectWithArgs for OPERATOR syntax: COMMENT ON OPERATOR -(NONE, integer) IS 'comment'
const objWithArgs = (node.object as any).ObjectWithArgs;
- let operatorName = objWithArgs.objname && objWithArgs.objname[0] && objWithArgs.objname[0].String
- ? objWithArgs.objname[0].String.sval : 'unknown';
-
- // Remove quotes from operator name if present
- if (operatorName.startsWith('"') && operatorName.endsWith('"')) {
- operatorName = operatorName.slice(1, -1);
- }
+ const operatorName = this.qualifiedOperatorName(objWithArgs.objname);
const args: string[] = [];
if (objWithArgs.objargs) {
@@ -7904,8 +7934,9 @@ export class Deparser implements DeparserVisitor {
FetchStmt(node: t.FetchStmt, context: DeparserContext): string {
const output: string[] = [node.ismove ? 'MOVE' : 'FETCH'];
- // Check if howMany represents "ALL" (PostgreSQL uses LONG_MAX as sentinel)
- const isAll = (node.howMany as any) === 9223372036854776000;
+ // FETCH_ALL is LONG_MAX: 2^63-1 from a 64-bit libpg_query, 2^31-1 from the 32-bit WASM build
+ const howMany = node.howMany as any;
+ const isAll = howMany === 9223372036854776000 || howMany === 2147483647;
// Handle direction first, then check for ALL within each direction
if (node.direction) {
@@ -7958,24 +7989,28 @@ export class Deparser implements DeparserVisitor {
AlterStatsStmt(node: t.AlterStatsStmt, context: DeparserContext): string {
const output: string[] = ['ALTER', 'STATISTICS'];
+ if (node.missing_ok) {
+ output.push('IF EXISTS');
+ }
+
if (node.defnames && node.defnames.length > 0) {
- const names = ListUtils.unwrapList(node.defnames).map(name => this.visit(name, context));
+ const names = ListUtils.unwrapList(node.defnames).map(name => QuoteUtils.quoteIdentifier(this.getNodeData(name).sval));
output.push(names.join('.'));
}
output.push('SET', 'STATISTICS');
- if (node.stxstattarget) {
- output.push(this.visit(node.stxstattarget, context));
- }
+ output.push(node.stxstattarget ? this.visit(node.stxstattarget, context) : 'DEFAULT');
return output.join(' ');
}
- ObjectWithArgs(node: t.ObjectWithArgs, context: DeparserContext): string {
+ ObjectWithArgs(node: t.ObjectWithArgs, context: DeparserContext, isOperator = context.objtype === 'OBJECT_OPERATOR'): string {
let result = '';
- if (node.objname && node.objname.length > 0) {
+ if (isOperator && node.objname && node.objname.length > 0) {
+ result = this.qualifiedOperatorName(node.objname);
+ } else if (node.objname && node.objname.length > 0) {
const objContext = context.spawn('ObjectWithArgs');
const names = ListUtils.unwrapList(node.objname).map(name => this.visit(name, objContext));
result = names.join('.');
@@ -8013,7 +8048,7 @@ export class Deparser implements DeparserVisitor {
const output: string[] = ['ALTER', 'OPERATOR'];
if (node.opername) {
- output.push(this.ObjectWithArgs(node.opername, context));
+ output.push(this.ObjectWithArgs(node.opername, context, true));
}
output.push('SET');
@@ -8485,6 +8520,9 @@ export class Deparser implements DeparserVisitor {
break;
case 'OBJECT_TRIGGER':
output.push('TRIGGER');
+ if (node.subname) {
+ output.push(QuoteUtils.quoteIdentifier(node.subname));
+ }
break;
case 'OBJECT_TSCONFIGURATION':
output.push('TEXT SEARCH CONFIGURATION');
@@ -8514,6 +8552,9 @@ export class Deparser implements DeparserVisitor {
case 'OBJECT_ROUTINE':
output.push('ROUTINE');
break;
+ case 'OBJECT_STATISTIC_EXT':
+ output.push('STATISTICS');
+ break;
default:
throw new Error(`Unsupported RenameStmt renameType: ${node.renameType}`);
}
@@ -8530,8 +8571,8 @@ export class Deparser implements DeparserVisitor {
} else if (node.relation) {
const rangeVarContext = context.spawn('RenameStmt', { objtype: node.relationType });
- // Add ON keyword for policy operations
- if (node.renameType === 'OBJECT_POLICY') {
+ // Add ON keyword for policy and trigger operations
+ if (node.renameType === 'OBJECT_POLICY' || node.renameType === 'OBJECT_TRIGGER') {
output.push('ON');
}
@@ -8547,7 +8588,7 @@ export class Deparser implements DeparserVisitor {
} else {
output.push(this.visit(node.object, context));
}
- } else if ((node.renameType === 'OBJECT_TYPE' || node.renameType === 'OBJECT_DOMAIN') && (node.object as any).List) {
+ } else if ((node.renameType === 'OBJECT_TYPE' || node.renameType === 'OBJECT_DOMAIN' || node.renameType === 'OBJECT_STATISTIC_EXT') && (node.object as any).List) {
// Qualified type names - join List parts with dots
const items = ListUtils.unwrapList(node.object as any);
const parts = items
@@ -10094,16 +10135,7 @@ export class Deparser implements DeparserVisitor {
output.push('CREATE OPERATOR');
if (node.defnames && node.defnames.length > 0) {
- const names = ListUtils.unwrapList(node.defnames).map((name, index) => {
- if (index === node.defnames.length - 1) {
- const nodeData = this.getNodeData(name);
- if (nodeData && nodeData.sval) {
- return nodeData.sval; // Return operator symbol unquoted
- }
- }
- return this.visit(name, context); // Quote schema/namespace names normally
- });
- output.push(names.join('.'));
+ output.push(this.qualifiedOperatorName(node.defnames));
}
if (node.definition && node.definition.length > 0) {
@@ -10118,11 +10150,8 @@ export class Deparser implements DeparserVisitor {
const quotedDefName = QuoteUtils.quoteIdentifier(defName);
const preservedDefName = quotedDefName !== defName ? quotedDefName : this.preserveOperatorDefElemCase(defName);
- if ((defName.toLowerCase() === 'commutator' || defName.toLowerCase() === 'negator') && defValue.List) {
- const listItems = ListUtils.unwrapList(defValue.List.items);
- if (listItems.length === 1 && listItems[0].String) {
- return `${preservedDefName} = ${listItems[0].String.sval}`;
- }
+ if ((defName.toLowerCase() === 'commutator' || defName.toLowerCase() === 'negator') && this.isOperatorNameList(defValue)) {
+ return `${preservedDefName} = ${this.deparseOperatorName(ListUtils.unwrapList(defValue.List.items), context)}`;
}
// For commutator/negator, we already handled them above
if ((defName.toLowerCase() === 'commutator' || defName.toLowerCase() === 'negator')) {
@@ -10706,7 +10735,7 @@ export class Deparser implements DeparserVisitor {
output.push(operatorNumber.toString());
if (node.name) {
const opClassContext = context.spawn('CreateOpClassItem');
- output.push(this.ObjectWithArgs(node.name, opClassContext));
+ output.push(this.ObjectWithArgs(node.name, opClassContext, true));
}
} else if (node.itemtype === 2) {
output.push('FUNCTION');
@@ -11098,6 +11127,189 @@ export class Deparser implements DeparserVisitor {
return parts.join(' ');
}
+ JsonBehavior(node: t.JsonBehavior, context: DeparserContext): string {
+ switch (node.btype) {
+ case 'JSON_BEHAVIOR_NULL':
+ return 'NULL';
+ case 'JSON_BEHAVIOR_ERROR':
+ return 'ERROR';
+ case 'JSON_BEHAVIOR_EMPTY':
+ return 'EMPTY';
+ case 'JSON_BEHAVIOR_TRUE':
+ return 'TRUE';
+ case 'JSON_BEHAVIOR_FALSE':
+ return 'FALSE';
+ case 'JSON_BEHAVIOR_UNKNOWN':
+ return 'UNKNOWN';
+ case 'JSON_BEHAVIOR_EMPTY_ARRAY':
+ return 'EMPTY ARRAY';
+ case 'JSON_BEHAVIOR_EMPTY_OBJECT':
+ return 'EMPTY OBJECT';
+ case 'JSON_BEHAVIOR_DEFAULT':
+ return `DEFAULT ${this.visit(node.expr as Node, context)}`;
+ default:
+ throw new Error(`Unsupported JsonBehavior btype: ${node.btype}`);
+ }
+ }
+
+ JsonArgument(node: t.JsonArgument, context: DeparserContext): string {
+ const val = node.val ? this.JsonValueExpr(node.val, context) : '';
+ return `${val} AS ${QuoteUtils.quoteIdentifier(node.name)}`;
+ }
+
+ formatJsonPassing(passing: t.Node[] | undefined, context: DeparserContext): string | null {
+ if (!passing || passing.length === 0) return null;
+ return 'PASSING ' + ListUtils.unwrapList(passing).map(arg => this.visit(arg, context)).join(', ');
+ }
+
+ formatJsonWrapper(wrapper: t.JsonWrapper | undefined): string | null {
+ switch (wrapper) {
+ case 'JSW_NONE':
+ return 'WITHOUT WRAPPER';
+ case 'JSW_CONDITIONAL':
+ return 'WITH CONDITIONAL WRAPPER';
+ case 'JSW_UNCONDITIONAL':
+ return 'WITH UNCONDITIONAL WRAPPER';
+ default:
+ return null;
+ }
+ }
+
+ formatJsonQuotes(quotes: t.JsonQuotes | undefined): string | null {
+ switch (quotes) {
+ case 'JS_QUOTES_KEEP':
+ return 'KEEP QUOTES';
+ case 'JS_QUOTES_OMIT':
+ return 'OMIT QUOTES';
+ default:
+ return null;
+ }
+ }
+
+ formatJsonBehaviors(onEmpty: t.JsonBehavior | undefined, onError: t.JsonBehavior | undefined, context: DeparserContext): string[] {
+ const parts: string[] = [];
+ if (onEmpty) parts.push(`${this.JsonBehavior(onEmpty, context)} ON EMPTY`);
+ if (onError) parts.push(`${this.JsonBehavior(onError, context)} ON ERROR`);
+ return parts;
+ }
+
+ JsonFuncExpr(node: t.JsonFuncExpr, context: DeparserContext): string {
+ let name: string;
+ switch (node.op) {
+ case 'JSON_EXISTS_OP':
+ name = 'JSON_EXISTS';
+ break;
+ case 'JSON_QUERY_OP':
+ name = 'JSON_QUERY';
+ break;
+ case 'JSON_VALUE_OP':
+ name = 'JSON_VALUE';
+ break;
+ default:
+ throw new Error(`Unsupported JsonFuncExpr op: ${node.op}`);
+ }
+
+ const args: string[] = [];
+ const contextItem = node.context_item ? this.JsonValueExpr(node.context_item, context) : '';
+ args.push(`${contextItem}, ${this.visit(node.pathspec as Node, context)}`);
+
+ const passing = this.formatJsonPassing(node.passing, context);
+ if (passing) args.push(passing);
+
+ const output = this.formatJsonOutput(node.output, context);
+ if (output) args.push(output);
+
+ const wrapper = this.formatJsonWrapper(node.wrapper);
+ if (wrapper) args.push(wrapper);
+
+ const quotes = this.formatJsonQuotes(node.quotes);
+ if (quotes) args.push(quotes);
+
+ args.push(...this.formatJsonBehaviors(node.on_empty, node.on_error, context));
+
+ return `${name}(${args.join(' ')})`;
+ }
+
+ JsonTablePathSpec(node: t.JsonTablePathSpec, context: DeparserContext): string {
+ const path = this.visit(node.string as Node, context);
+ return node.name ? `${path} AS ${QuoteUtils.quoteIdentifier(node.name)}` : path;
+ }
+
+ formatJsonTableColumns(columns: t.Node[] | undefined, context: DeparserContext): string {
+ const cols = ListUtils.unwrapList(columns || []).map(col => this.visit(col, context));
+ return `COLUMNS (${cols.join(', ')})`;
+ }
+
+ JsonTableColumn(node: t.JsonTableColumn, context: DeparserContext): string {
+ if (node.coltype === 'JTC_NESTED') {
+ const path = node.pathspec ? this.JsonTablePathSpec(node.pathspec, context) : '';
+ return `NESTED PATH ${path} ${this.formatJsonTableColumns(node.columns, context)}`;
+ }
+
+ const parts: string[] = [QuoteUtils.quoteIdentifier(node.name)];
+
+ if (node.coltype === 'JTC_FOR_ORDINALITY') {
+ parts.push('FOR ORDINALITY');
+ return parts.join(' ');
+ }
+
+ if (node.typeName) {
+ parts.push(this.TypeName(node.typeName, context));
+ }
+
+ if (node.coltype === 'JTC_EXISTS') {
+ parts.push('EXISTS');
+ } else if (node.coltype === 'JTC_FORMATTED') {
+ parts.push(this.formatJsonFormat(node.format) || 'FORMAT JSON');
+ }
+
+ if (node.pathspec) {
+ parts.push('PATH', this.JsonTablePathSpec(node.pathspec, context));
+ }
+
+ if (node.coltype !== 'JTC_EXISTS') {
+ const wrapper = this.formatJsonWrapper(node.wrapper);
+ if (wrapper) parts.push(wrapper);
+
+ const quotes = this.formatJsonQuotes(node.quotes);
+ if (quotes) parts.push(quotes);
+ }
+
+ parts.push(...this.formatJsonBehaviors(node.on_empty, node.on_error, context));
+
+ return parts.join(' ');
+ }
+
+ JsonTable(node: t.JsonTable, context: DeparserContext): string {
+ const output: string[] = [];
+
+ if (node.lateral) {
+ output.push('LATERAL');
+ }
+
+ const args: string[] = [];
+ const contextItem = node.context_item ? this.JsonValueExpr(node.context_item, context) : '';
+ const pathspec = node.pathspec ? this.JsonTablePathSpec(node.pathspec, context) : '';
+ args.push(`${contextItem}, ${pathspec}`);
+
+ const passing = this.formatJsonPassing(node.passing, context);
+ if (passing) args.push(passing);
+
+ args.push(this.formatJsonTableColumns(node.columns, context));
+
+ if (node.on_error) {
+ args.push(`${this.JsonBehavior(node.on_error, context)} ON ERROR`);
+ }
+
+ output.push(`JSON_TABLE(${args.join(' ')})`);
+
+ if (node.alias) {
+ output.push(this.Alias(node.alias, context));
+ }
+
+ return output.join(' ');
+ }
+
RangeFunction(node: t.RangeFunction, context: DeparserContext): string {
const output: string[] = [];
@@ -11417,6 +11629,10 @@ export class Deparser implements DeparserVisitor {
output.push(this.visit(node.expr as any, context));
output.push('AS');
output.push(this.TypeName(node.typeName, context));
+
+ if (node.indent) {
+ output.push('INDENT');
+ }
}
output.push(')');
diff --git a/packages/pgsql-types/src/field-metadata.json b/packages/pgsql-types/src/field-metadata.json
index 3b1ce2dc0..a41707167 100644
--- a/packages/pgsql-types/src/field-metadata.json
+++ b/packages/pgsql-types/src/field-metadata.json
@@ -349,7 +349,7 @@
"isArray": true
},
"stxstattarget": {
- "nullable": false,
+ "nullable": true,
"tags": [
"Integer"
],
@@ -2527,7 +2527,8 @@
"A_Const",
"A_Expr",
"CaseExpr",
- "FuncCall"
+ "FuncCall",
+ "ParamRef"
],
"isArray": false
},
diff --git a/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap b/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap
index f4fd5211b..7dc91834e 100644
--- a/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap
+++ b/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap
@@ -462,6 +462,15 @@ exports[`plpgsql-deparser bug fixes implicit trailing RETURN suppression should
END"
`;
+exports[`plpgsql-deparser bug fixes nested block comments should keep a string literal intact after a nested block comment 1`] = `
+"BEGIN
+ IF p_on THEN
+ PERFORM /* outer /* inner */ it's */ 'line one
+ line two';
+ END IF;
+END"
+`;
+
exports[`plpgsql-deparser bug fixes nested block compositions (END; bug class) should handle labeled nested block 1`] = `
"BEGIN
<>
diff --git a/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts b/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts
index 56f8aaa88..61e5e509c 100644
--- a/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts
+++ b/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts
@@ -1513,4 +1513,24 @@ END$$`;
expect(deparsed).toContain('updated ALIAS FOR new;');
});
});
+
+ describe('nested block comments', () => {
+ it('should keep a string literal intact after a nested block comment', async () => {
+ const sql = `CREATE FUNCTION test_nested_comment_literal(p_on boolean) RETURNS void
+LANGUAGE plpgsql AS $$
+BEGIN
+ IF p_on THEN
+ PERFORM /* outer /* inner */ it's */ 'line one
+ line two';
+ END IF;
+END$$`;
+
+ await testUtils.expectAstMatch('nested block comment before literal', sql);
+
+ const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
+ const deparsed = deparseSync(parsed);
+ expect(deparsed).toMatchSnapshot();
+ expect(deparsed).toContain(`'line one\n line two'`);
+ });
+ });
});
diff --git a/packages/plpgsql-deparser/__tests__/regress-corpus.test.ts b/packages/plpgsql-deparser/__tests__/regress-corpus.test.ts
new file mode 100644
index 000000000..b68d4bf8a
--- /dev/null
+++ b/packages/plpgsql-deparser/__tests__/regress-corpus.test.ts
@@ -0,0 +1,63 @@
+/**
+ * Round-trips the PL/pgSQL functions and DO blocks of the PostgreSQL regression corpus
+ * (__fixtures__/regress/statements.json.gz, built by `npm run fixtures:regress` in
+ * packages/deparser) through parse -> deparse -> parse.
+ *
+ * known-failures.plpgsql-deparser.json is a ratchet: a new failure fails the test, and so
+ * does a listed statement that now passes. After a deliberate change, refresh it with
+ * UPDATE_KNOWN_FAILURES=1 npx jest __tests__/regress-corpus
+ */
+import * as fs from 'fs';
+import { loadModule, parsePlPgSQLSync } from 'libpg-query';
+import * as path from 'path';
+import * as zlib from 'zlib';
+
+import { PLpgSQLTestUtils } from '../test-utils';
+
+const FIXTURE_DIR = path.join(__dirname, '../../../__fixtures__/regress');
+const KNOWN_FAILURES_FILE = path.join(FIXTURE_DIR, 'known-failures.plpgsql-deparser.json');
+const UPDATE = process.env.UPDATE_KNOWN_FAILURES === '1';
+
+const { statements } = JSON.parse(
+ zlib.gunzipSync(fs.readFileSync(path.join(FIXTURE_DIR, 'statements.json.gz'))).toString('utf8')
+) as { statements: Record };
+const knownFailures: string[] = JSON.parse(fs.readFileSync(KNOWN_FAILURES_FILE, 'utf8'));
+
+const candidates = Object.keys(statements).filter((key) =>
+ /language\s+'?plpgsql|^\s*do\s/i.test(statements[key])
+);
+
+describe('regress corpus PL/pgSQL round-trip', () => {
+ const utils = new PLpgSQLTestUtils();
+
+ beforeAll(async () => {
+ await loadModule();
+ for (const level of ['error', 'warn', 'log'] as const) jest.spyOn(console, level).mockImplementation(() => {});
+ });
+ afterAll(() => jest.restoreAllMocks());
+
+ it('matches known failures', async () => {
+ const failing: string[] = [];
+ let tested = 0;
+ for (const key of candidates) {
+ try {
+ const parsed = parsePlPgSQLSync(statements[key]) as any;
+ if (!parsed.plpgsql_funcs?.length) continue;
+ } catch {
+ continue;
+ }
+ tested++;
+ try {
+ await utils.expectAstMatch(key, statements[key]);
+ } catch {
+ failing.push(key);
+ }
+ }
+ expect(tested).toBeGreaterThan(500);
+ if (UPDATE) {
+ fs.writeFileSync(KNOWN_FAILURES_FILE, JSON.stringify(failing, null, 2) + '\n');
+ } else {
+ expect(failing).toEqual(knownFailures);
+ }
+ });
+});
diff --git a/packages/plpgsql-deparser/src/plpgsql-deparser.ts b/packages/plpgsql-deparser/src/plpgsql-deparser.ts
index ab40695d7..ecab42c9d 100644
--- a/packages/plpgsql-deparser/src/plpgsql-deparser.ts
+++ b/packages/plpgsql-deparser/src/plpgsql-deparser.ts
@@ -883,8 +883,9 @@ export class PLpgSQLDeparser {
const isSchemaQualified = this.isSchemaQualifiedType(typname);
if (!isSchemaQualified) {
- // Simple type - just strip quotes and return as-is
- return typname.replace(/"/g, '').trim();
+ // Simple type - strip quotes unless they change the meaning ("char" is not char)
+ const trimmed = typname.trim();
+ return /^"char"(\[\d*\])*$/.test(trimmed) ? trimmed : trimmed.replace(/"/g, '');
}
// Schema-qualified type - apply proper quoting
@@ -1266,10 +1267,10 @@ export class PLpgSQLDeparser {
}
// ELSE clause
- if (caseStmt.have_else && caseStmt.else_stmts) {
+ if (caseStmt.have_else) {
parts.push(this.indent(kw('ELSE'), context.indentLevel + 1));
const bodyContext = { ...context, indentLevel: context.indentLevel + 2 };
- for (const stmt of caseStmt.else_stmts) {
+ for (const stmt of caseStmt.else_stmts ?? []) {
const stmtStr = this.deparseStmt(stmt, bodyContext);
parts.push(this.indent(stmtStr + ';', bodyContext.indentLevel));
}
@@ -2406,7 +2407,79 @@ export class PLpgSQLDeparser {
*/
private indent(text: string, level: number): string {
const indent = this.options.indent!.repeat(level);
- return indent + text.replace(/\n/g, '\n' + indent);
+ if (!indent) return text;
+ let out = indent;
+ let i = 0;
+ while (i < text.length) {
+ const ch = text[i];
+ if (ch === '\n') {
+ out += '\n' + indent;
+ i++;
+ } else if (ch === "'" || ch === '"' || ch === '$' || (ch === '-' && text[i + 1] === '-') || (ch === '/' && text[i + 1] === '*')) {
+ const end = this.skipQuoted(text, i);
+ const chunk = text.slice(i, end);
+ out += ch === '/' ? chunk.replace(/\n/g, '\n' + indent) : chunk;
+ i = end;
+ } else {
+ out += ch;
+ i++;
+ }
+ }
+ return out;
+ }
+
+ /**
+ * Return the index just past a string literal, quoted identifier or
+ * dollar-quoted string starting at `start`, so its newlines are left intact.
+ * Comments are skipped so that quotes inside them are not mistaken for
+ * literal delimiters.
+ */
+ private skipQuoted(text: string, start: number): number {
+ const ch = text[start];
+ if (ch === '-') {
+ const nl = text.indexOf('\n', start);
+ return nl === -1 ? text.length : nl;
+ }
+ if (ch === '/') {
+ // PostgreSQL block comments nest
+ let depth = 0;
+ let i = start;
+ while (i < text.length) {
+ if (text[i] === '/' && text[i + 1] === '*') {
+ depth++;
+ i += 2;
+ } else if (text[i] === '*' && text[i + 1] === '/') {
+ i += 2;
+ if (--depth === 0) return i;
+ } else {
+ i++;
+ }
+ }
+ return text.length;
+ }
+ if (ch === '$') {
+ const prev = start > 0 ? text[start - 1] : '';
+ const tag = /^\$([A-Za-z_\u0080-\uffff][A-Za-z0-9_\u0080-\uffff]*)?\$/.exec(text.slice(start));
+ if (!tag || /[A-Za-z0-9_$]/.test(prev)) return start + 1;
+ const close = text.indexOf(tag[0], start + tag[0].length);
+ return close === -1 ? text.length : close + tag[0].length;
+ }
+ const escapes = ch === "'" && start > 0 && /[eE]/.test(text[start - 1]) && !/[A-Za-z0-9_]/.test(text[start - 2] ?? '');
+ let i = start + 1;
+ while (i < text.length) {
+ if (escapes && text[i] === '\\') {
+ i += 2;
+ } else if (text[i] === ch) {
+ if (text[i + 1] === ch) {
+ i += 2;
+ } else {
+ return i + 1;
+ }
+ } else {
+ i++;
+ }
+ }
+ return text.length;
}
/**
diff --git a/packages/transform/__fixtures__/input/json-constructors.sql b/packages/transform/__fixtures__/input/json-constructors.sql
new file mode 100644
index 000000000..14791ed2e
--- /dev/null
+++ b/packages/transform/__fixtures__/input/json-constructors.sql
@@ -0,0 +1,7 @@
+-- SQL/JSON aggregate constructors carry their own location under a
+-- "constructor" key; extra whitespace shifts it on deparse.
+SELECT JSON_ARRAYAGG( t.name ORDER BY t.name ) FROM "my-schema".items t;
+
+SELECT JSON_OBJECTAGG( t.id : t.name ) FROM "my-schema".items t;
+
+SELECT JSON_ARRAY( "my-schema".label(1), 2 ), JSON_OBJECT( 'k' : 1 );
diff --git a/packages/transform/__fixtures__/output/json-constructors.sql b/packages/transform/__fixtures__/output/json-constructors.sql
new file mode 100644
index 000000000..a89f6c69f
--- /dev/null
+++ b/packages/transform/__fixtures__/output/json-constructors.sql
@@ -0,0 +1,11 @@
+-- SQL/JSON aggregate constructors carry their own location under a
+-- "constructor" key; extra whitespace shifts it on deparse.
+SELECT JSON_ARRAYAGG(t.name ORDER BY t.name ABSENT ON NULL)
+FROM my_schema.items AS t;
+
+SELECT JSON_OBJECTAGG(t.id : t.name NULL ON NULL)
+FROM my_schema.items AS t;
+
+SELECT
+ JSON_ARRAY(my_schema.label(1), 2 ABSENT ON NULL),
+ JSON_OBJECT('k' : 1 NULL ON NULL);
\ No newline at end of file
diff --git a/packages/transform/src/round-trip-core.ts b/packages/transform/src/round-trip-core.ts
index 88360043c..33fa825f3 100644
--- a/packages/transform/src/round-trip-core.ts
+++ b/packages/transform/src/round-trip-core.ts
@@ -59,7 +59,8 @@ export function normalizeTree(node: any, options: NormalizeTreeOptions = {}): an
const keys = sortKeys ? Object.keys(node).sort() : Object.keys(node);
for (const key of keys) {
if (volatileKeys?.has(key)) continue;
- const handler = keyHandlers?.[key];
+ const handler =
+ keyHandlers && Object.prototype.hasOwnProperty.call(keyHandlers, key) ? keyHandlers[key] : undefined;
out[key] = handler ? handler(node[key], recurse) : recurse(node[key]);
}
return out;