diff --git a/__fixtures__/generated/generated.json b/__fixtures__/generated/generated.json index 91e844b2..a9ad7570 100644 --- a/__fixtures__/generated/generated.json +++ b/__fixtures__/generated/generated.json @@ -21580,6 +21580,27 @@ "misc/literal-escaping-30.sql": "SELECT * FROM XMLTABLE(XMLNAMESPACES('http://x' AS x, DEFAULT 'http://d'), '/x:r' PASSING (SELECT doc FROM t) COLUMNS a int) AS xt", "misc/literal-escaping-31.sql": "SELECT * FROM t, LATERAL XMLTABLE('/r' PASSING t.doc COLUMNS a int PATH 'a') xt", "misc/literal-escaping-32.sql": "SELECT 1_000.000_1, 1_000.5e1_0, 0x_FFFF_FFFF_FFFF_FFFF", + "misc/literal-edge-cases-1.sql": "SELECT X''", + "misc/literal-edge-cases-2.sql": "SELECT B''", + "misc/literal-edge-cases-3.sql": "SELECT X'', B'', X'1F', B'101'", + "misc/literal-edge-cases-4.sql": "DO $x$ BEGIN RAISE NOTICE '$_PGFN_$'; END $x$", + "misc/literal-edge-cases-5.sql": "DO $x$ BEGIN RAISE NOTICE '$$'; END $x$", + "misc/literal-edge-cases-6.sql": "DO $x$ BEGIN RAISE NOTICE '$$ $EOFCODE$'; END $x$", + "misc/literal-edge-cases-7.sql": "DO $x$ BEGIN RAISE NOTICE '$$ $EOFCODE$ $EOFCODE1$'; END $x$", + "misc/literal-edge-cases-8.sql": "DO $x$BEGIN NULL; END; --$$x$", + "misc/literal-edge-cases-9.sql": "CREATE FUNCTION f() RETURNS int LANGUAGE sql AS $x$ SELECT length('$_PGFN_$') $x$", + "misc/literal-edge-cases-10.sql": "CREATE FUNCTION f() RETURNS int LANGUAGE sql AS $x$ SELECT length('$$ $EOFCODE$') $x$", + "misc/literal-edge-cases-11.sql": "CREATE FUNCTION f() RETURNS int LANGUAGE sql AS $x$SELECT 1 --$$x$", + "misc/literal-edge-cases-12.sql": "CREATE VIEW v WITH (security_barrier = 'Foo') AS SELECT 1", + "misc/literal-edge-cases-13.sql": "CREATE VIEW v WITH (security_barrier = 'true') AS SELECT 1", + "misc/literal-edge-cases-14.sql": "CREATE INDEX i ON t (a) WITH (fillfactor = 'int')", + "misc/literal-edge-cases-15.sql": "CREATE INDEX i ON t (a) WITH (opt = 'Foo')", + "misc/literal-edge-cases-16.sql": "CREATE TABLE t (a int) WITH (opt = 'Foo')", + "misc/literal-edge-cases-17.sql": "CREATE TABLE t (a int) WITH (opt = 'int')", + "misc/literal-edge-cases-18.sql": "CREATE TABLE t (a int) WITH (opt = 'select')", + "misc/literal-edge-cases-19.sql": "ALTER TABLE t SET (opt = 'Foo')", + "misc/literal-edge-cases-20.sql": "ALTER TABLE t SET (opt = 'int')", + "misc/literal-edge-cases-21.sql": "ALTER INDEX i SET (opt = 'Foo')", "misc/launchql-ext-types-1.sql": "CREATE DOMAIN attachment AS jsonb CHECK ( value ?& ARRAY['url', 'mime'] AND (value->>'url') ~ '^(https?)://[^\\s/$.?#].[^\\s]*$' )", "misc/launchql-ext-types-2.sql": "COMMENT ON DOMAIN attachment IS E'@name launchqlInternalTypeAttachment'", "misc/launchql-ext-types-3.sql": "CREATE DOMAIN email AS citext CHECK ( value ~ '^[a-zA-Z0-9.!#$%&''*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$' )", diff --git a/__fixtures__/kitchen-sink/misc/literal-edge-cases.sql b/__fixtures__/kitchen-sink/misc/literal-edge-cases.sql new file mode 100644 index 00000000..721e2005 --- /dev/null +++ b/__fixtures__/kitchen-sink/misc/literal-edge-cases.sql @@ -0,0 +1,29 @@ +-- Edge-case literals the deparser must keep valid +-- Ref: constructive-io/constructive-planning#2175 + +-- Empty bit-string literals +SELECT X''; +SELECT B''; +SELECT X'', B'', X'1F', B'101'; + +-- Dollar-quoted bodies that contain the default or fallback delimiter +DO $x$ BEGIN RAISE NOTICE '$_PGFN_$'; END $x$; +DO $x$ BEGIN RAISE NOTICE '$$'; END $x$; +DO $x$ BEGIN RAISE NOTICE '$$ $EOFCODE$'; END $x$; +DO $x$ BEGIN RAISE NOTICE '$$ $EOFCODE$ $EOFCODE1$'; END $x$; +DO $x$BEGIN NULL; END; --$$x$; +CREATE FUNCTION f() RETURNS int LANGUAGE sql AS $x$ SELECT length('$_PGFN_$') $x$; +CREATE FUNCTION f() RETURNS int LANGUAGE sql AS $x$ SELECT length('$$ $EOFCODE$') $x$; +CREATE FUNCTION f() RETURNS int LANGUAGE sql AS $x$SELECT 1 --$$x$; + +-- String option values must keep their case and stay strings +CREATE VIEW v WITH (security_barrier = 'Foo') AS SELECT 1; +CREATE VIEW v WITH (security_barrier = 'true') AS SELECT 1; +CREATE INDEX i ON t (a) WITH (fillfactor = 'int'); +CREATE INDEX i ON t (a) WITH (opt = 'Foo'); +CREATE TABLE t (a int) WITH (opt = 'Foo'); +CREATE TABLE t (a int) WITH (opt = 'int'); +CREATE TABLE t (a int) WITH (opt = 'select'); +ALTER TABLE t SET (opt = 'Foo'); +ALTER TABLE t SET (opt = 'int'); +ALTER INDEX i SET (opt = 'Foo'); diff --git a/packages/deparser/__tests__/kitchen-sink/misc-literal-edge-cases.test.ts b/packages/deparser/__tests__/kitchen-sink/misc-literal-edge-cases.test.ts new file mode 100644 index 00000000..9a6e1e40 --- /dev/null +++ b/packages/deparser/__tests__/kitchen-sink/misc-literal-edge-cases.test.ts @@ -0,0 +1,29 @@ + +import { FixtureTestUtils } from '../../test-utils'; +const fixtures = new FixtureTestUtils(); + +it('misc-literal-edge-cases', async () => { + await fixtures.runFixtureTests([ + "misc/literal-edge-cases-1.sql", + "misc/literal-edge-cases-2.sql", + "misc/literal-edge-cases-3.sql", + "misc/literal-edge-cases-4.sql", + "misc/literal-edge-cases-5.sql", + "misc/literal-edge-cases-6.sql", + "misc/literal-edge-cases-7.sql", + "misc/literal-edge-cases-8.sql", + "misc/literal-edge-cases-9.sql", + "misc/literal-edge-cases-10.sql", + "misc/literal-edge-cases-11.sql", + "misc/literal-edge-cases-12.sql", + "misc/literal-edge-cases-13.sql", + "misc/literal-edge-cases-14.sql", + "misc/literal-edge-cases-15.sql", + "misc/literal-edge-cases-16.sql", + "misc/literal-edge-cases-17.sql", + "misc/literal-edge-cases-18.sql", + "misc/literal-edge-cases-19.sql", + "misc/literal-edge-cases-20.sql", + "misc/literal-edge-cases-21.sql" +]); +}); diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts index 88fca5a0..94c7f625 100644 --- a/packages/deparser/src/deparser.ts +++ b/packages/deparser/src/deparser.ts @@ -1,4 +1,4 @@ -import { QuoteUtils } from '@pgsql/quotes'; +import { keywordKindOf, QuoteUtils } from '@pgsql/quotes'; import { Node } from '@pgsql/types'; import * as t from '@pgsql/types'; @@ -149,7 +149,7 @@ function isWrappedParseResult(obj: any): obj is { ParseResult: t.ParseResult } { */ function formatBitString(bsval: string): string { const quote = (value: string) => value.replace(/'/g, "''"); - if (/^x[0-9A-Fa-f]+$/.test(bsval)) { + if (/^x[0-9A-Fa-f]*$/.test(bsval)) { return `x'${bsval.substring(1)}'`; } if (bsval.startsWith('b')) { @@ -158,6 +158,12 @@ function formatBitString(bsval: string): string { return `b'${quote(bsval)}'`; } +// A dollar-quoted body ends at the first occurrence of its tag, including one +// formed by the body's trailing characters plus the closing tag (`...$` + `$$`). +function dollarTagConflicts(body: string, tag: string): boolean { + return (body + tag).indexOf(tag) < body.length; +} + const NUMERIC_LITERAL = /^[+-]?(?:0[xX](?:_?[0-9A-Fa-f])+|0[oO](?:_?[0-7])+|0[bB](?:_?[01])+|(?:\d(?:_?\d)*(?:\.(?:\d(?:_?\d)*)?)?|\.\d(?:_?\d)*)(?:[eE][+-]?\d(?:_?\d)*)?)$/; function formatInteger(ival: unknown): string { @@ -239,10 +245,19 @@ export class Deparser implements DeparserVisitor { */ private getFunctionDelimiter(body: string): string { const delimiter = this.options.functionDelimiter || '$$'; - if (body.includes(delimiter)) { - return this.options.functionDelimiterFallback || '$EOFCODE$'; + if (!dollarTagConflicts(body, delimiter)) { + return delimiter; + } + const fallback = this.options.functionDelimiterFallback || '$EOFCODE$'; + if (!dollarTagConflicts(body, fallback)) { + return fallback; + } + const base = fallback.slice(0, -1); + let counter = 1; + while (dollarTagConflicts(body, `${base}${counter}$`)) { + counter++; } - return delimiter; + return `${base}${counter}$`; } /** @@ -10739,7 +10754,9 @@ export class Deparser implements DeparserVisitor { private formatOptionValue(arg: any, argValue: any): any { if (arg && this.getNodeType(arg) === 'String') { const sval: string = this.getNodeData(arg).sval || ''; - return /^[A-Za-z_][A-Za-z0-9_]*$/.test(sval) ? sval : QuoteUtils.escape(sval); + return /^[a-z_][a-z0-9_]*$/.test(sval) && keywordKindOf(sval) === 'NO_KEYWORD' + ? sval + : QuoteUtils.escape(sval); } return argValue; }