diff --git a/__fixtures__/generated/generated.json b/__fixtures__/generated/generated.json index 2680c92b..a6ae4e2b 100644 --- a/__fixtures__/generated/generated.json +++ b/__fixtures__/generated/generated.json @@ -21037,6 +21037,34 @@ "original/alter/alter-97.sql": "ALTER SCHEMA schemaname OWNER TO newowner", "misc/quotes_etc-1.sql": "CREATE USER MAPPING FOR local_user SERVER foreign_server OPTIONS (\"user\" 'remote_user', password 'secret123')", "misc/quotes_etc-2.sql": "CREATE USER MAPPING FOR local_user SERVER foreign_server OPTIONS (\"user\" 'remote_user', password 'secret123')", + "misc/quotes_etc-3.sql": "SELECT 'Line 1\nLine 2'", + "misc/quotes_etc-4.sql": "SELECT 'Column\tValue with quote: ''' AS formatted_string", + "misc/quotes_etc-5.sql": "SELECT E'Path is C:\\\\Program Files\\\\PostgreSQL\r\nDone.'", + "misc/quotes_etc-6.sql": "SELECT 'Unicode heart: ❤' AS unicode_heart", + "misc/quotes_etc-7.sql": "SELECT 'Extended Unicode: 🚀' AS rocket_emoji", + "misc/quotes_etc-8.sql": "SELECT 'Bell sound: \u0007' AS octal_escape", + "misc/quotes_etc-9.sql": "SELECT E'This is not a bytea literal: \\\\xDEAD and a newline \n'", + "misc/quotes_etc-10.sql": "SELECT E'\\\\\\\\xDEADBEEF'::bytea", + "misc/quotes_etc-11.sql": "INSERT INTO messages (content) VALUES ('Line one.\nLine two with tab:\tEnd.')", + "misc/quotes_etc-12.sql": "INSERT INTO logs (message) VALUES ('Escaped comment info: \nAuthor said: ''yes''')", + "misc/quotes_etc-13.sql": "SELECT E'Invalid path: C:\\\\Users\\\\Me\\\\Documents'", + "misc/quotes_etc-14.sql": "SELECT 'Page break here:\fNext page'", + "misc/quotes_etc-15.sql": "INSERT INTO configs (data) VALUES (E'{\"theme\": \"dark\", \"alert\": \"bell\\\\nchime\"}')", + "misc/quotes_etc-16.sql": "INSERT INTO docs (note) VALUES ('This value includes a SQL-style comment -- tricky!\nBut it''s safe here.')", + "misc/quotes_etc-17.sql": "SELECT 'Just a plain string, nothing to escape.'", + "misc/quotes_etc-18.sql": "SELECT 'Just a plain string, nothing to escape.'", + "misc/quotes_etc-19.sql": "SELECT E'This string has \"quotes\" and \\\\slashes\\\\' AS tricky_string", + "misc/quotes_etc-20.sql": "SELECT E'String with null byte: \\\\0 after this' AS null_char", + "misc/quotes_etc-21.sql": "SELECT E'This ends in backslash: \\\\' AS trailing_backslash", + "misc/quotes_etc-22.sql": "SELECT E'Config path: C:\\\\\\\\Temp\\\\\\\\Files\\\\' AS double_slash", + "misc/quotes_etc-23.sql": "SELECT 'First line\nSecond line\nThird line' AS multiline_string", + "misc/quotes_etc-24.sql": "WITH msg AS (SELECT 'CTE with newline\nand tab\tinside' AS txt) SELECT * FROM msg", + "misc/quotes_etc-25.sql": "SELECT 'Some string' AS \"select\"", + "misc/quotes_etc-26.sql": "SELECT E'Escapes: \\\\ \b \f \n \r \t \u000b ''' AS all_escapes", + "misc/quotes_etc-27.sql": "CREATE FUNCTION escape_example() RETURNS text AS $$\nBEGIN\n RETURN E'This has a newline\\\\nand tab\\\\twith quotes: \\\\'hello\\\\'';\nEND;\n$$ LANGUAGE plpgsql", + "misc/quotes_etc-28.sql": "DO $$\nBEGIN\n RAISE NOTICE 'Line one\\nLine two';\nEND;\n$$ LANGUAGE plpgsql", + "misc/quotes_etc-29.sql": "CREATE USER MAPPING FOR local_user SERVER foreign_server OPTIONS (\"user\" 'remote_user', password 'secret123')", + "misc/quotes_etc-30.sql": "CREATE USER MAPPING FOR local_user SERVER foreign_server OPTIONS (\"user\" 'remote_user', password 'secret123')", "misc/launchql-ext-types-1.sql": "CREATE DOMAIN attachment AS jsonb CHECK (value ?& ARRAY['url', 'mime'] AND (value ->> 'url') ~ E'^(https?)://[^\\\\s/$.?#].[^\\\\s]*$')", "misc/launchql-ext-types-2.sql": "COMMENT ON DOMAIN attachment IS '@name launchqlInternalTypeAttachment'", "misc/launchql-ext-types-3.sql": "CREATE DOMAIN email AS citext CHECK (value ~ E'^[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/quotes_etc.sql b/__fixtures__/kitchen-sink/misc/quotes_etc.sql index 9c463dd6..4c25989d 100644 --- a/__fixtures__/kitchen-sink/misc/quotes_etc.sql +++ b/__fixtures__/kitchen-sink/misc/quotes_etc.sql @@ -1,2 +1,105 @@ CREATE USER MAPPING FOR local_user SERVER "foreign_server" OPTIONS (user 'remote_user', password 'secret123'); -CREATE USER MAPPING FOR local_user SERVER foreign_server OPTIONS (user 'remote_user', password 'secret123'); \ No newline at end of file +CREATE USER MAPPING FOR local_user SERVER foreign_server OPTIONS (user 'remote_user', password 'secret123'); + +-- This file contains examples of SQL string literals requiring E-prefixed strings + +-- Basic string with newline +SELECT E'Line 1\nLine 2'; + +-- Tab character and single quote +SELECT E'Column\tValue with quote: \'' AS formatted_string; + +-- Escaped backslash and carriage return +SELECT E'Path is C:\\Program Files\\PostgreSQL\r\nDone.'; + +-- Unicode escapes +SELECT E'Unicode heart: \u2764' AS unicode_heart; +SELECT E'Extended Unicode: \U0001F680' AS rocket_emoji; + +-- Octal escape +SELECT E'Bell sound: \007' AS octal_escape; + +-- Hex-looking string that is NOT bytea +-- This needs E because it has \x but also other escapes +SELECT E'This is not a bytea literal: \\xDEAD and a newline \n'; + +-- Proper bytea hex string (should NOT need E prefix) +SELECT '\\xDEADBEEF'::bytea; + +-- A_Const-style literal in INSERT +INSERT INTO messages (content) VALUES ( + E'Line one.\nLine two with tab:\tEnd.' +); + +-- Another INSERT with a tricky string in a comment +-- Comment: escaped quote and newline: \n and \' +INSERT INTO logs (message) VALUES ( + E'Escaped comment info: \nAuthor said: \'yes\'' +); + +-- String that would cause parsing issues without E +SELECT E'Invalid path: C:\\Users\\Me\\Documents'; + +-- Control character (form feed) +SELECT E'Page break here:\fNext page'; + +-- JSON-like string that *requires* E because of escaped quotes +INSERT INTO configs (data) VALUES ( + E'{\"theme\": \"dark\", \"alert\": \"bell\\nchime\"}' +); + +-- Nested comment trick: using E-string inside a comment-containing SQL +-- This shows a string literal *inside* a SQL statement that also includes a SQL comment +INSERT INTO docs (note) VALUES ( + E'This value includes a SQL-style comment -- tricky!\nBut it''s safe here.' +); + +-- Example where normal string is okay (no E needed) +SELECT E'Just a plain string, nothing to escape.'; +SELECT 'Just a plain string, nothing to escape.'; + +-- Just to make sure we're parsing string types correctly +-- sval.String.str with embedded backslashes and quotes +SELECT E'This string has \"quotes\" and \\slashes\\' AS tricky_string; + + +SELECT E'String with null byte: \\0 after this' AS null_char; + +-- Backslash at end of string +SELECT E'This ends in backslash: \\' AS trailing_backslash; + +-- Double escaped path +SELECT E'Config path: C:\\\\Temp\\\\Files\\' AS double_slash; + +-- Multi-line string (escaped newlines) +SELECT E'First line\nSecond line\nThird line' AS multiline_string; + +-- E-string inside CTE +WITH msg AS ( + SELECT E'CTE with newline\nand tab\tinside' AS txt +) +SELECT * FROM msg; + +-- Reserved keyword as alias (quoted identifier) +SELECT E'Some string' AS "select"; + +-- All common escapes in one go +SELECT E'Escapes: \\ \b \f \n \r \t \v \'' AS all_escapes; + +-- E-string inside a dollar-quoted PL/pgSQL block +CREATE FUNCTION escape_example() RETURNS text AS $$ +BEGIN + RETURN E'This has a newline\\nand tab\\twith quotes: \\'hello\\''; +END; +$$ LANGUAGE plpgsql; + +-- Dollar-quoted function without E-string — for contrast +DO $$ +BEGIN + RAISE NOTICE 'Line one\nLine two'; +END; +$$ LANGUAGE plpgsql; + +-- CREATE USER MAPPING with and without quoted identifiers +CREATE USER MAPPING FOR local_user SERVER "foreign_server" OPTIONS (user 'remote_user', password 'secret123'); +CREATE USER MAPPING FOR local_user SERVER foreign_server OPTIONS (user 'remote_user', password 'secret123'); diff --git a/packages/deparser/__tests__/kitchen-sink/misc-launchql-ext-types.test.ts b/packages/deparser/__tests__/kitchen-sink/misc-launchql-ext-types.test.ts index 4484933e..7f0c7304 100644 --- a/packages/deparser/__tests__/kitchen-sink/misc-launchql-ext-types.test.ts +++ b/packages/deparser/__tests__/kitchen-sink/misc-launchql-ext-types.test.ts @@ -1,6 +1,5 @@ import { FixtureTestUtils } from '../../test-utils'; - const fixtures = new FixtureTestUtils(); it('misc-launchql-ext-types', async () => { diff --git a/packages/deparser/__tests__/kitchen-sink/misc-quotes_etc.test.ts b/packages/deparser/__tests__/kitchen-sink/misc-quotes_etc.test.ts index 093f2b5b..3d1b799a 100644 --- a/packages/deparser/__tests__/kitchen-sink/misc-quotes_etc.test.ts +++ b/packages/deparser/__tests__/kitchen-sink/misc-quotes_etc.test.ts @@ -5,6 +5,34 @@ const fixtures = new FixtureTestUtils(); it('misc-quotes_etc', async () => { await fixtures.runFixtureTests([ "misc/quotes_etc-1.sql", - "misc/quotes_etc-2.sql" + "misc/quotes_etc-2.sql", + "misc/quotes_etc-3.sql", + "misc/quotes_etc-4.sql", + "misc/quotes_etc-5.sql", + "misc/quotes_etc-6.sql", + "misc/quotes_etc-7.sql", + "misc/quotes_etc-8.sql", + "misc/quotes_etc-9.sql", + "misc/quotes_etc-10.sql", + "misc/quotes_etc-11.sql", + "misc/quotes_etc-12.sql", + "misc/quotes_etc-13.sql", + "misc/quotes_etc-14.sql", + "misc/quotes_etc-15.sql", + "misc/quotes_etc-16.sql", + "misc/quotes_etc-17.sql", + "misc/quotes_etc-18.sql", + "misc/quotes_etc-19.sql", + "misc/quotes_etc-20.sql", + "misc/quotes_etc-21.sql", + "misc/quotes_etc-22.sql", + "misc/quotes_etc-23.sql", + "misc/quotes_etc-24.sql", + "misc/quotes_etc-25.sql", + "misc/quotes_etc-26.sql", + "misc/quotes_etc-27.sql", + "misc/quotes_etc-28.sql", + "misc/quotes_etc-29.sql", + "misc/quotes_etc-30.sql" ]); }); diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts index a5ffd22c..454d881c 100644 --- a/packages/deparser/src/deparser.ts +++ b/packages/deparser/src/deparser.ts @@ -1254,18 +1254,18 @@ export class Deparser implements DeparserVisitor { } else if (nodeAny.sval !== undefined) { if (typeof nodeAny.sval === 'object' && nodeAny.sval !== null) { if (nodeAny.sval.sval !== undefined) { - return QuoteUtils.escape(nodeAny.sval.sval); + return QuoteUtils.formatEString(nodeAny.sval.sval); } else if (nodeAny.sval.String && nodeAny.sval.String.sval !== undefined) { - return QuoteUtils.escape(nodeAny.sval.String.sval); + return QuoteUtils.formatEString(nodeAny.sval.String.sval); } else if (Object.keys(nodeAny.sval).length === 0) { return "''"; } else { - return QuoteUtils.escape(nodeAny.sval.toString()); + return QuoteUtils.formatEString(nodeAny.sval.toString()); } } else if (nodeAny.sval === null) { return 'NULL'; } else { - return QuoteUtils.escape(nodeAny.sval); + return QuoteUtils.formatEString(nodeAny.sval); } } else if (nodeAny.boolval !== undefined) { if (typeof nodeAny.boolval === 'object' && nodeAny.boolval !== null) { @@ -2014,9 +2014,11 @@ export class Deparser implements DeparserVisitor { return caseMap[defName.toLowerCase()] || defName; } + + String(node: t.String, context: DeparserContext): string { if (context.isStringLiteral || context.isEnumValue) { - return `'${node.sval || ''}'`; + return QuoteUtils.formatEString(node.sval || ''); } const value = node.sval || ''; @@ -6215,8 +6217,7 @@ export class Deparser implements DeparserVisitor { if (node.comment === null || node.comment === undefined) { output.push('NULL'); } else if (node.comment) { - const escapedComment = node.comment.replace(/'/g, "''"); - output.push(`'${escapedComment}'`); + output.push(QuoteUtils.formatEString(node.comment)); } return output.join(' '); diff --git a/packages/deparser/src/utils/quote-utils.ts b/packages/deparser/src/utils/quote-utils.ts index f55fd710..babb5384 100644 --- a/packages/deparser/src/utils/quote-utils.ts +++ b/packages/deparser/src/utils/quote-utils.ts @@ -60,4 +60,37 @@ export class QuoteUtils { static escape(literal: string): string { return `'${literal.replace(/'/g, "''")}'`; } -} + + /** + * Escapes a string value for use in E-prefixed string literals + * Handles both backslashes and single quotes properly + */ + static escapeEString(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/'/g, "''"); + } + + /** + * Formats a string as an E-prefixed string literal with proper escaping + * This wraps the complete E-prefix logic including detection and formatting + */ + static formatEString(value: string): string { + const needsEscape = QuoteUtils.needsEscapePrefix(value); + if (needsEscape) { + const escapedValue = QuoteUtils.escapeEString(value); + return `E'${escapedValue}'`; + } else { + return QuoteUtils.escape(value); + } + } + + /** + * Determines if a string value needs E-prefix for escaped string literals + * Detects backslash escape sequences that require E-prefix in PostgreSQL + */ + static needsEscapePrefix(value: string): boolean { + // Always use E'' if the string contains any backslashes, + // unless it's a raw \x... bytea-style literal. + return !/^\\x[0-9a-fA-F]+$/i.test(value) && value.includes('\\'); + } + +} \ No newline at end of file