Skip to content

Commit dd0f563

Browse files
authored
Merge pull request #34 from launchql/fix/quotes
Fix/quotes
2 parents 3219b4f + 5621f04 commit dd0f563

File tree

5 files changed

+203
-10
lines changed

5 files changed

+203
-10
lines changed

__fixtures__/generated/generated.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21037,6 +21037,34 @@
2103721037
"original/alter/alter-97.sql": "ALTER SCHEMA schemaname OWNER TO newowner",
2103821038
"misc/quotes_etc-1.sql": "CREATE USER MAPPING FOR local_user SERVER foreign_server OPTIONS (\"user\" 'remote_user', password 'secret123')",
2103921039
"misc/quotes_etc-2.sql": "CREATE USER MAPPING FOR local_user SERVER foreign_server OPTIONS (\"user\" 'remote_user', password 'secret123')",
21040+
"misc/quotes_etc-3.sql": "SELECT 'Line 1\nLine 2'",
21041+
"misc/quotes_etc-4.sql": "SELECT 'Column\tValue with quote: ''' AS formatted_string",
21042+
"misc/quotes_etc-5.sql": "SELECT E'Path is C:\\\\Program Files\\\\PostgreSQL\r\nDone.'",
21043+
"misc/quotes_etc-6.sql": "SELECT 'Unicode heart: ❤' AS unicode_heart",
21044+
"misc/quotes_etc-7.sql": "SELECT 'Extended Unicode: 🚀' AS rocket_emoji",
21045+
"misc/quotes_etc-8.sql": "SELECT 'Bell sound: \u0007' AS octal_escape",
21046+
"misc/quotes_etc-9.sql": "SELECT E'This is not a bytea literal: \\\\xDEAD and a newline \n'",
21047+
"misc/quotes_etc-10.sql": "SELECT E'\\\\\\\\xDEADBEEF'::bytea",
21048+
"misc/quotes_etc-11.sql": "INSERT INTO messages (content) VALUES ('Line one.\nLine two with tab:\tEnd.')",
21049+
"misc/quotes_etc-12.sql": "INSERT INTO logs (message) VALUES ('Escaped comment info: \nAuthor said: ''yes''')",
21050+
"misc/quotes_etc-13.sql": "SELECT E'Invalid path: C:\\\\Users\\\\Me\\\\Documents'",
21051+
"misc/quotes_etc-14.sql": "SELECT 'Page break here:\fNext page'",
21052+
"misc/quotes_etc-15.sql": "INSERT INTO configs (data) VALUES (E'{\"theme\": \"dark\", \"alert\": \"bell\\\\nchime\"}')",
21053+
"misc/quotes_etc-16.sql": "INSERT INTO docs (note) VALUES ('This value includes a SQL-style comment -- tricky!\nBut it''s safe here.')",
21054+
"misc/quotes_etc-17.sql": "SELECT 'Just a plain string, nothing to escape.'",
21055+
"misc/quotes_etc-18.sql": "SELECT 'Just a plain string, nothing to escape.'",
21056+
"misc/quotes_etc-19.sql": "SELECT E'This string has \"quotes\" and \\\\slashes\\\\' AS tricky_string",
21057+
"misc/quotes_etc-20.sql": "SELECT E'String with null byte: \\\\0 after this' AS null_char",
21058+
"misc/quotes_etc-21.sql": "SELECT E'This ends in backslash: \\\\' AS trailing_backslash",
21059+
"misc/quotes_etc-22.sql": "SELECT E'Config path: C:\\\\\\\\Temp\\\\\\\\Files\\\\' AS double_slash",
21060+
"misc/quotes_etc-23.sql": "SELECT 'First line\nSecond line\nThird line' AS multiline_string",
21061+
"misc/quotes_etc-24.sql": "WITH msg AS (SELECT 'CTE with newline\nand tab\tinside' AS txt) SELECT * FROM msg",
21062+
"misc/quotes_etc-25.sql": "SELECT 'Some string' AS \"select\"",
21063+
"misc/quotes_etc-26.sql": "SELECT E'Escapes: \\\\ \b \f \n \r \t \u000b ''' AS all_escapes",
21064+
"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",
21065+
"misc/quotes_etc-28.sql": "DO $$\nBEGIN\n RAISE NOTICE 'Line one\\nLine two';\nEND;\n$$ LANGUAGE plpgsql",
21066+
"misc/quotes_etc-29.sql": "CREATE USER MAPPING FOR local_user SERVER foreign_server OPTIONS (\"user\" 'remote_user', password 'secret123')",
21067+
"misc/quotes_etc-30.sql": "CREATE USER MAPPING FOR local_user SERVER foreign_server OPTIONS (\"user\" 'remote_user', password 'secret123')",
2104021068
"misc/launchql-ext-types-1.sql": "CREATE DOMAIN attachment AS jsonb CHECK (value ?& ARRAY['url', 'mime'] AND (value ->> 'url') ~ E'^(https?)://[^\\\\s/$.?#].[^\\\\s]*$')",
2104121069
"misc/launchql-ext-types-2.sql": "COMMENT ON DOMAIN attachment IS '@name launchqlInternalTypeAttachment'",
2104221070
"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])?)*$')",
Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,105 @@
11
CREATE USER MAPPING FOR local_user SERVER "foreign_server" OPTIONS (user 'remote_user', password 'secret123');
2-
CREATE USER MAPPING FOR local_user SERVER foreign_server OPTIONS (user 'remote_user', password 'secret123');
2+
CREATE USER MAPPING FOR local_user SERVER foreign_server OPTIONS (user 'remote_user', password 'secret123');
3+
4+
-- This file contains examples of SQL string literals requiring E-prefixed strings
5+
6+
-- Basic string with newline
7+
SELECT E'Line 1\nLine 2';
8+
9+
-- Tab character and single quote
10+
SELECT E'Column\tValue with quote: \'' AS formatted_string;
11+
12+
-- Escaped backslash and carriage return
13+
SELECT E'Path is C:\\Program Files\\PostgreSQL\r\nDone.';
14+
15+
-- Unicode escapes
16+
SELECT E'Unicode heart: \u2764' AS unicode_heart;
17+
SELECT E'Extended Unicode: \U0001F680' AS rocket_emoji;
18+
19+
-- Octal escape
20+
SELECT E'Bell sound: \007' AS octal_escape;
21+
22+
-- Hex-looking string that is NOT bytea
23+
-- This needs E because it has \x but also other escapes
24+
SELECT E'This is not a bytea literal: \\xDEAD and a newline \n';
25+
26+
-- Proper bytea hex string (should NOT need E prefix)
27+
SELECT '\\xDEADBEEF'::bytea;
28+
29+
-- A_Const-style literal in INSERT
30+
INSERT INTO messages (content) VALUES (
31+
E'Line one.\nLine two with tab:\tEnd.'
32+
);
33+
34+
-- Another INSERT with a tricky string in a comment
35+
-- Comment: escaped quote and newline: \n and \'
36+
INSERT INTO logs (message) VALUES (
37+
E'Escaped comment info: \nAuthor said: \'yes\''
38+
);
39+
40+
-- String that would cause parsing issues without E
41+
SELECT E'Invalid path: C:\\Users\\Me\\Documents';
42+
43+
-- Control character (form feed)
44+
SELECT E'Page break here:\fNext page';
45+
46+
-- JSON-like string that *requires* E because of escaped quotes
47+
INSERT INTO configs (data) VALUES (
48+
E'{\"theme\": \"dark\", \"alert\": \"bell\\nchime\"}'
49+
);
50+
51+
-- Nested comment trick: using E-string inside a comment-containing SQL
52+
-- This shows a string literal *inside* a SQL statement that also includes a SQL comment
53+
INSERT INTO docs (note) VALUES (
54+
E'This value includes a SQL-style comment -- tricky!\nBut it''s safe here.'
55+
);
56+
57+
-- Example where normal string is okay (no E needed)
58+
SELECT E'Just a plain string, nothing to escape.';
59+
SELECT 'Just a plain string, nothing to escape.';
60+
61+
-- Just to make sure we're parsing string types correctly
62+
-- sval.String.str with embedded backslashes and quotes
63+
SELECT E'This string has \"quotes\" and \\slashes\\' AS tricky_string;
64+
65+
66+
SELECT E'String with null byte: \\0 after this' AS null_char;
67+
68+
-- Backslash at end of string
69+
SELECT E'This ends in backslash: \\' AS trailing_backslash;
70+
71+
-- Double escaped path
72+
SELECT E'Config path: C:\\\\Temp\\\\Files\\' AS double_slash;
73+
74+
-- Multi-line string (escaped newlines)
75+
SELECT E'First line\nSecond line\nThird line' AS multiline_string;
76+
77+
-- E-string inside CTE
78+
WITH msg AS (
79+
SELECT E'CTE with newline\nand tab\tinside' AS txt
80+
)
81+
SELECT * FROM msg;
82+
83+
-- Reserved keyword as alias (quoted identifier)
84+
SELECT E'Some string' AS "select";
85+
86+
-- All common escapes in one go
87+
SELECT E'Escapes: \\ \b \f \n \r \t \v \'' AS all_escapes;
88+
89+
-- E-string inside a dollar-quoted PL/pgSQL block
90+
CREATE FUNCTION escape_example() RETURNS text AS $$
91+
BEGIN
92+
RETURN E'This has a newline\\nand tab\\twith quotes: \\'hello\\'';
93+
END;
94+
$$ LANGUAGE plpgsql;
95+
96+
-- Dollar-quoted function without E-string — for contrast
97+
DO $$
98+
BEGIN
99+
RAISE NOTICE 'Line one\nLine two';
100+
END;
101+
$$ LANGUAGE plpgsql;
102+
103+
-- CREATE USER MAPPING with and without quoted identifiers
104+
CREATE USER MAPPING FOR local_user SERVER "foreign_server" OPTIONS (user 'remote_user', password 'secret123');
105+
CREATE USER MAPPING FOR local_user SERVER foreign_server OPTIONS (user 'remote_user', password 'secret123');

packages/deparser/__tests__/kitchen-sink/misc-quotes_etc.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,34 @@ const fixtures = new FixtureTestUtils();
55
it('misc-quotes_etc', async () => {
66
await fixtures.runFixtureTests([
77
"misc/quotes_etc-1.sql",
8-
"misc/quotes_etc-2.sql"
8+
"misc/quotes_etc-2.sql",
9+
"misc/quotes_etc-3.sql",
10+
"misc/quotes_etc-4.sql",
11+
"misc/quotes_etc-5.sql",
12+
"misc/quotes_etc-6.sql",
13+
"misc/quotes_etc-7.sql",
14+
"misc/quotes_etc-8.sql",
15+
"misc/quotes_etc-9.sql",
16+
"misc/quotes_etc-10.sql",
17+
"misc/quotes_etc-11.sql",
18+
"misc/quotes_etc-12.sql",
19+
"misc/quotes_etc-13.sql",
20+
"misc/quotes_etc-14.sql",
21+
"misc/quotes_etc-15.sql",
22+
"misc/quotes_etc-16.sql",
23+
"misc/quotes_etc-17.sql",
24+
"misc/quotes_etc-18.sql",
25+
"misc/quotes_etc-19.sql",
26+
"misc/quotes_etc-20.sql",
27+
"misc/quotes_etc-21.sql",
28+
"misc/quotes_etc-22.sql",
29+
"misc/quotes_etc-23.sql",
30+
"misc/quotes_etc-24.sql",
31+
"misc/quotes_etc-25.sql",
32+
"misc/quotes_etc-26.sql",
33+
"misc/quotes_etc-27.sql",
34+
"misc/quotes_etc-28.sql",
35+
"misc/quotes_etc-29.sql",
36+
"misc/quotes_etc-30.sql"
937
]);
1038
});

packages/deparser/src/deparser.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1254,18 +1254,18 @@ export class Deparser implements DeparserVisitor {
12541254
} else if (nodeAny.sval !== undefined) {
12551255
if (typeof nodeAny.sval === 'object' && nodeAny.sval !== null) {
12561256
if (nodeAny.sval.sval !== undefined) {
1257-
return QuoteUtils.escape(nodeAny.sval.sval);
1257+
return QuoteUtils.formatEString(nodeAny.sval.sval);
12581258
} else if (nodeAny.sval.String && nodeAny.sval.String.sval !== undefined) {
1259-
return QuoteUtils.escape(nodeAny.sval.String.sval);
1259+
return QuoteUtils.formatEString(nodeAny.sval.String.sval);
12601260
} else if (Object.keys(nodeAny.sval).length === 0) {
12611261
return "''";
12621262
} else {
1263-
return QuoteUtils.escape(nodeAny.sval.toString());
1263+
return QuoteUtils.formatEString(nodeAny.sval.toString());
12641264
}
12651265
} else if (nodeAny.sval === null) {
12661266
return 'NULL';
12671267
} else {
1268-
return QuoteUtils.escape(nodeAny.sval);
1268+
return QuoteUtils.formatEString(nodeAny.sval);
12691269
}
12701270
} else if (nodeAny.boolval !== undefined) {
12711271
if (typeof nodeAny.boolval === 'object' && nodeAny.boolval !== null) {
@@ -2014,9 +2014,11 @@ export class Deparser implements DeparserVisitor {
20142014
return caseMap[defName.toLowerCase()] || defName;
20152015
}
20162016

2017+
2018+
20172019
String(node: t.String, context: DeparserContext): string {
20182020
if (context.isStringLiteral || context.isEnumValue) {
2019-
return `'${node.sval || ''}'`;
2021+
return QuoteUtils.formatEString(node.sval || '');
20202022
}
20212023

20222024
const value = node.sval || '';
@@ -6215,8 +6217,7 @@ export class Deparser implements DeparserVisitor {
62156217
if (node.comment === null || node.comment === undefined) {
62166218
output.push('NULL');
62176219
} else if (node.comment) {
6218-
const escapedComment = node.comment.replace(/'/g, "''");
6219-
output.push(`'${escapedComment}'`);
6220+
output.push(QuoteUtils.formatEString(node.comment));
62206221
}
62216222

62226223
return output.join(' ');

packages/deparser/src/utils/quote-utils.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,37 @@ export class QuoteUtils {
6060
static escape(literal: string): string {
6161
return `'${literal.replace(/'/g, "''")}'`;
6262
}
63-
}
63+
64+
/**
65+
* Escapes a string value for use in E-prefixed string literals
66+
* Handles both backslashes and single quotes properly
67+
*/
68+
static escapeEString(value: string): string {
69+
return value.replace(/\\/g, '\\\\').replace(/'/g, "''");
70+
}
71+
72+
/**
73+
* Formats a string as an E-prefixed string literal with proper escaping
74+
* This wraps the complete E-prefix logic including detection and formatting
75+
*/
76+
static formatEString(value: string): string {
77+
const needsEscape = QuoteUtils.needsEscapePrefix(value);
78+
if (needsEscape) {
79+
const escapedValue = QuoteUtils.escapeEString(value);
80+
return `E'${escapedValue}'`;
81+
} else {
82+
return QuoteUtils.escape(value);
83+
}
84+
}
85+
86+
/**
87+
* Determines if a string value needs E-prefix for escaped string literals
88+
* Detects backslash escape sequences that require E-prefix in PostgreSQL
89+
*/
90+
static needsEscapePrefix(value: string): boolean {
91+
// Always use E'' if the string contains any backslashes,
92+
// unless it's a raw \x... bytea-style literal.
93+
return !/^\\x[0-9a-fA-F]+$/i.test(value) && value.includes('\\');
94+
}
95+
96+
}

0 commit comments

Comments
 (0)