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;