Skip to content

Commit 4c81523

Browse files
committed
feat: implement quoteDottedName for relaxed quoting after dots
- Rename quoteIdentifierQualifiedTail to quoteIdentifierAfterDot for clarity - Add quoteDottedName() helper that applies strict quoting to first part and relaxed (lexical-only) quoting to subsequent parts - Update FuncCall and CreateFunctionStmt to use quoteDottedName() - Remove unused nquotes variable from quoteIdentifier() - Update snapshots to show unquoted function names (faker.float, etc.)
1 parent ef51c09 commit 4c81523

File tree

3 files changed

+48
-37
lines changed

3 files changed

+48
-37
lines changed
Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,97 @@
11
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
22

33
exports[`non-pretty: pretty/quoting-1.sql 1`] = `
4-
"CREATE FUNCTION faker."float"(min double precision DEFAULT 0, max double precision DEFAULT 100) RETURNS double precision AS $$
4+
"CREATE FUNCTION faker.float(min double precision DEFAULT 0, max double precision DEFAULT 100) RETURNS double precision AS $$
55
BEGIN
66
RETURN min + random() * (max - min);
77
END;
88
$$ LANGUAGE plpgsql"
99
`;
1010

1111
exports[`non-pretty: pretty/quoting-2.sql 1`] = `
12-
"CREATE FUNCTION faker."float"(min double precision DEFAULT 0, max double precision DEFAULT 100) RETURNS double precision AS $$
12+
"CREATE FUNCTION faker.float(min double precision DEFAULT 0, max double precision DEFAULT 100) RETURNS double precision AS $$
1313
BEGIN
1414
RETURN min + random() * (max - min);
1515
END;
1616
$$ LANGUAGE plpgsql"
1717
`;
1818

1919
exports[`non-pretty: pretty/quoting-3.sql 1`] = `
20-
"CREATE FUNCTION faker."interval"(min int, max int) RETURNS interval AS $$
20+
"CREATE FUNCTION faker.interval(min int, max int) RETURNS interval AS $$
2121
BEGIN
2222
RETURN make_interval(secs => (min + floor(random() * (max - min + 1)))::int);
2323
END;
2424
$$ LANGUAGE plpgsql"
2525
`;
2626

2727
exports[`non-pretty: pretty/quoting-4.sql 1`] = `
28-
"CREATE FUNCTION faker."interval"(min int, max int) RETURNS interval AS $$
28+
"CREATE FUNCTION faker.interval(min int, max int) RETURNS interval AS $$
2929
BEGIN
3030
RETURN make_interval(secs => (min + floor(random() * (max - min + 1)))::int);
3131
END;
3232
$$ LANGUAGE plpgsql"
3333
`;
3434

3535
exports[`non-pretty: pretty/quoting-5.sql 1`] = `
36-
"CREATE FUNCTION faker."boolean"() RETURNS boolean AS $$
36+
"CREATE FUNCTION faker.boolean() RETURNS boolean AS $$
3737
BEGIN
3838
RETURN random() < 0.5;
3939
END;
4040
$$ LANGUAGE plpgsql"
4141
`;
4242

4343
exports[`non-pretty: pretty/quoting-6.sql 1`] = `
44-
"CREATE FUNCTION faker."boolean"() RETURNS boolean AS $$
44+
"CREATE FUNCTION faker.boolean() RETURNS boolean AS $$
4545
BEGIN
4646
RETURN random() < 0.5;
4747
END;
4848
$$ LANGUAGE plpgsql"
4949
`;
5050

51-
exports[`non-pretty: pretty/quoting-7.sql 1`] = `"CREATE DOMAIN origin AS text CHECK (value = pg_catalog."substring"(value, '^(https?://[^/]*)'))"`;
51+
exports[`non-pretty: pretty/quoting-7.sql 1`] = `"CREATE DOMAIN origin AS text CHECK (value = pg_catalog.substring(value, '^(https?://[^/]*)'))"`;
5252

5353
exports[`pretty: pretty/quoting-1.sql 1`] = `
54-
"CREATE FUNCTION faker."float"(min double precision DEFAULT 0, max double precision DEFAULT 100) RETURNS double precision AS $$
54+
"CREATE FUNCTION faker.float(min double precision DEFAULT 0, max double precision DEFAULT 100) RETURNS double precision AS $$
5555
BEGIN
5656
RETURN min + random() * (max - min);
5757
END;
5858
$$ LANGUAGE plpgsql"
5959
`;
6060

6161
exports[`pretty: pretty/quoting-2.sql 1`] = `
62-
"CREATE FUNCTION faker."float"(min double precision DEFAULT 0, max double precision DEFAULT 100) RETURNS double precision AS $$
62+
"CREATE FUNCTION faker.float(min double precision DEFAULT 0, max double precision DEFAULT 100) RETURNS double precision AS $$
6363
BEGIN
6464
RETURN min + random() * (max - min);
6565
END;
6666
$$ LANGUAGE plpgsql"
6767
`;
6868

6969
exports[`pretty: pretty/quoting-3.sql 1`] = `
70-
"CREATE FUNCTION faker."interval"(min int, max int) RETURNS interval AS $$
70+
"CREATE FUNCTION faker.interval(min int, max int) RETURNS interval AS $$
7171
BEGIN
7272
RETURN make_interval(secs => (min + floor(random() * (max - min + 1)))::int);
7373
END;
7474
$$ LANGUAGE plpgsql"
7575
`;
7676

7777
exports[`pretty: pretty/quoting-4.sql 1`] = `
78-
"CREATE FUNCTION faker."interval"(min int, max int) RETURNS interval AS $$
78+
"CREATE FUNCTION faker.interval(min int, max int) RETURNS interval AS $$
7979
BEGIN
8080
RETURN make_interval(secs => (min + floor(random() * (max - min + 1)))::int);
8181
END;
8282
$$ LANGUAGE plpgsql"
8383
`;
8484

8585
exports[`pretty: pretty/quoting-5.sql 1`] = `
86-
"CREATE FUNCTION faker."boolean"() RETURNS boolean AS $$
86+
"CREATE FUNCTION faker.boolean() RETURNS boolean AS $$
8787
BEGIN
8888
RETURN random() < 0.5;
8989
END;
9090
$$ LANGUAGE plpgsql"
9191
`;
9292

9393
exports[`pretty: pretty/quoting-6.sql 1`] = `
94-
"CREATE FUNCTION faker."boolean"() RETURNS boolean AS $$
94+
"CREATE FUNCTION faker.boolean() RETURNS boolean AS $$
9595
BEGIN
9696
RETURN random() < 0.5;
9797
END;
@@ -100,5 +100,5 @@ $$ LANGUAGE plpgsql"
100100

101101
exports[`pretty: pretty/quoting-7.sql 1`] = `
102102
"CREATE DOMAIN origin AS text
103-
CHECK (value = pg_catalog."substring"(value, '^(https?://[^/]*)'))"
103+
CHECK (value = pg_catalog.substring(value, '^(https?://[^/]*)'))"
104104
`;

packages/deparser/src/deparser.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1317,7 +1317,7 @@ export class Deparser implements DeparserVisitor {
13171317
if (node.indirection && node.indirection.length > 0) {
13181318
const indirectionStrs = ListUtils.unwrapList(node.indirection).map(item => {
13191319
if (item.String) {
1320-
return `.${QuoteUtils.quoteIdentifierQualifiedTail(item.String.sval || item.String.str)}`;
1320+
return `.${QuoteUtils.quoteIdentifierAfterDot(item.String.sval || item.String.str)}`;
13211321
}
13221322
return this.visit(item, context);
13231323
});
@@ -1335,7 +1335,7 @@ export class Deparser implements DeparserVisitor {
13351335
if (node.indirection && node.indirection.length > 0) {
13361336
const indirectionStrs = ListUtils.unwrapList(node.indirection).map(item => {
13371337
if (item.String) {
1338-
return `.${QuoteUtils.quoteIdentifierQualifiedTail(item.String.sval || item.String.str)}`;
1338+
return `.${QuoteUtils.quoteIdentifierAfterDot(item.String.sval || item.String.str)}`;
13391339
}
13401340
return this.visit(item, context);
13411341
});
@@ -1421,7 +1421,8 @@ export class Deparser implements DeparserVisitor {
14211421
FuncCall(node: t.FuncCall, context: DeparserContext): string {
14221422
const funcname = ListUtils.unwrapList(node.funcname);
14231423
const args = ListUtils.unwrapList(node.args);
1424-
const name = funcname.map(n => this.visit(n, context)).join('.');
1424+
const funcnameParts = funcname.map((n: any) => n.String?.sval || n.String?.str || '').filter((s: string) => s);
1425+
const name = QuoteUtils.quoteDottedName(funcnameParts);
14251426

14261427
// Handle special SQL syntax functions like XMLEXISTS and EXTRACT
14271428
if (node.funcformat === 'COERCE_SQL_SYNTAX' && name === 'pg_catalog.xmlexists' && args.length >= 2) {
@@ -2018,9 +2019,9 @@ export class Deparser implements DeparserVisitor {
20182019
if (node.catalogname) {
20192020
tableName = QuoteUtils.quoteIdentifier(node.catalogname);
20202021
if (node.schemaname) {
2021-
tableName += '.' + QuoteUtils.quoteIdentifierQualifiedTail(node.schemaname);
2022+
tableName += '.' + QuoteUtils.quoteIdentifierAfterDot(node.schemaname);
20222023
}
2023-
tableName += '.' + QuoteUtils.quoteIdentifierQualifiedTail(node.relname);
2024+
tableName += '.' + QuoteUtils.quoteIdentifierAfterDot(node.relname);
20242025
} else if (node.schemaname) {
20252026
tableName = QuoteUtils.quoteQualifiedIdentifier(node.schemaname, node.relname);
20262027
} else {
@@ -5490,7 +5491,8 @@ export class Deparser implements DeparserVisitor {
54905491
}
54915492

54925493
if (node.funcname && node.funcname.length > 0) {
5493-
const funcName = node.funcname.map((name: any) => this.visit(name, context)).join('.');
5494+
const funcnameParts = node.funcname.map((name: any) => name.String?.sval || name.String?.str || '').filter((s: string) => s);
5495+
const funcName = QuoteUtils.quoteDottedName(funcnameParts);
54945496

54955497
if (node.parameters && node.parameters.length > 0) {
54965498
const params = node.parameters

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

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ export class QuoteUtils {
5252
static quoteIdentifier(ident: string): string {
5353
if (!ident) return ident;
5454

55-
let nquotes = 0;
5655
let safe = true;
5756

5857
// Check first character: must be lowercase letter or underscore
@@ -70,9 +69,6 @@ export class QuoteUtils {
7069
// okay
7170
} else {
7271
safe = false;
73-
if (ch === '"') {
74-
nquotes++;
75-
}
7672
}
7773
}
7874

@@ -105,7 +101,7 @@ export class QuoteUtils {
105101
}
106102

107103
/**
108-
* Quote an identifier for use as a qualified name tail (after a dot).
104+
* Quote an identifier that appears after a dot in a qualified name.
109105
*
110106
* In PostgreSQL's grammar, identifiers that appear after a dot (e.g., schema.name,
111107
* table.column) are in a more permissive position that accepts all keyword categories
@@ -115,10 +111,9 @@ export class QuoteUtils {
115111
* Empirically verified: `myschema.select`, `myschema.float`, `t.from` all parse
116112
* successfully in PostgreSQL without quotes.
117113
*/
118-
static quoteIdentifierQualifiedTail(ident: string): string {
114+
static quoteIdentifierAfterDot(ident: string): string {
119115
if (!ident) return ident;
120116

121-
let nquotes = 0;
122117
let safe = true;
123118

124119
const firstChar = ident[0];
@@ -134,9 +129,6 @@ export class QuoteUtils {
134129
// okay
135130
} else {
136131
safe = false;
137-
if (ch === '"') {
138-
nquotes++;
139-
}
140132
}
141133
}
142134

@@ -157,22 +149,39 @@ export class QuoteUtils {
157149
return result;
158150
}
159151

152+
/**
153+
* Quote a dotted name (e.g., schema.table, catalog.schema.table).
154+
*
155+
* The first part uses strict quoting (keywords are quoted), while subsequent
156+
* parts use relaxed quoting (keywords allowed, only quote for lexical reasons).
157+
*
158+
* This reflects PostgreSQL's grammar where the first identifier in a statement
159+
* may conflict with keywords, but identifiers after a dot are in a more
160+
* permissive position.
161+
*/
162+
static quoteDottedName(parts: string[]): string {
163+
if (!parts || parts.length === 0) return '';
164+
if (parts.length === 1) {
165+
return QuoteUtils.quoteIdentifier(parts[0]);
166+
}
167+
return parts.map((part, index) =>
168+
index === 0 ? QuoteUtils.quoteIdentifier(part) : QuoteUtils.quoteIdentifierAfterDot(part)
169+
).join('.');
170+
}
171+
160172
/**
161173
* Quote a possibly-qualified identifier
162174
*
163-
* This is a TypeScript port of PostgreSQL's quote_qualified_identifier() function from ruleutils.c
164-
* https://github.com/postgres/postgres/blob/fab5cd3dd1323f9e66efeb676c4bb212ff340204/src/backend/utils/adt/ruleutils.c#L13139-L13156
175+
* This is inspired by PostgreSQL's quote_qualified_identifier() function from ruleutils.c
176+
* but uses relaxed quoting for the tail component since PostgreSQL's grammar accepts
177+
* all keywords in qualified name positions.
165178
*
166179
* Return a name of the form qualifier.ident, or just ident if qualifier
167180
* is null/undefined, quoting each component if necessary.
168-
*
169-
* When a qualifier is present, the tail identifier uses relaxed quoting that
170-
* ignores keyword categories, since PostgreSQL's grammar accepts all keywords
171-
* in qualified name positions.
172181
*/
173182
static quoteQualifiedIdentifier(qualifier: string | null | undefined, ident: string): string {
174183
if (qualifier) {
175-
return `${QuoteUtils.quoteIdentifier(qualifier)}.${QuoteUtils.quoteIdentifierQualifiedTail(ident)}`;
184+
return `${QuoteUtils.quoteIdentifier(qualifier)}.${QuoteUtils.quoteIdentifierAfterDot(ident)}`;
176185
}
177186
return QuoteUtils.quoteIdentifier(ident);
178187
}

0 commit comments

Comments
 (0)