Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions __fixtures__/generated/generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -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])?)*$')",
Expand Down
105 changes: 104 additions & 1 deletion __fixtures__/kitchen-sink/misc/quotes_etc.sql
Original file line number Diff line number Diff line change
@@ -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');
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');
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@

import { FixtureTestUtils } from '../../test-utils';

const fixtures = new FixtureTestUtils();

it('misc-launchql-ext-types', async () => {
Expand Down
30 changes: 29 additions & 1 deletion packages/deparser/__tests__/kitchen-sink/misc-quotes_etc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]);
});
15 changes: 8 additions & 7 deletions packages/deparser/src/deparser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 || '';
Expand Down Expand Up @@ -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(' ');
Expand Down
35 changes: 34 additions & 1 deletion packages/deparser/src/utils/quote-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('\\');
}

}