Skip to content

Commit ef51c09

Browse files
committed
feat: add relaxed quoting for qualified name tails
PostgreSQL's grammar accepts all keyword categories (including RESERVED_KEYWORD) in qualified name positions (after a dot). This change adds a new quoteIdentifierQualifiedTail() method that only quotes for lexical reasons (uppercase, special characters, leading digits) not for keyword reasons. This allows the deparser to emit: - faker.float instead of faker."float" - myschema.select instead of myschema."select" - t.from instead of t."from" while still correctly quoting unqualified identifiers that are keywords. Empirically verified with libpg-query that all keyword categories parse successfully in qualified positions across DDL and DML contexts.
1 parent 1923251 commit ef51c09

2 files changed

Lines changed: 62 additions & 5 deletions

File tree

packages/deparser/src/deparser.ts

Lines changed: 4 additions & 4 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.quoteIdentifier(item.String.sval || item.String.str)}`;
1320+
return `.${QuoteUtils.quoteIdentifierQualifiedTail(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.quoteIdentifier(item.String.sval || item.String.str)}`;
1338+
return `.${QuoteUtils.quoteIdentifierQualifiedTail(item.String.sval || item.String.str)}`;
13391339
}
13401340
return this.visit(item, context);
13411341
});
@@ -2018,9 +2018,9 @@ export class Deparser implements DeparserVisitor {
20182018
if (node.catalogname) {
20192019
tableName = QuoteUtils.quoteIdentifier(node.catalogname);
20202020
if (node.schemaname) {
2021-
tableName += '.' + QuoteUtils.quoteIdentifier(node.schemaname);
2021+
tableName += '.' + QuoteUtils.quoteIdentifierQualifiedTail(node.schemaname);
20222022
}
2023-
tableName += '.' + QuoteUtils.quoteIdentifier(node.relname);
2023+
tableName += '.' + QuoteUtils.quoteIdentifierQualifiedTail(node.relname);
20242024
} else if (node.schemaname) {
20252025
tableName = QuoteUtils.quoteQualifiedIdentifier(node.schemaname, node.relname);
20262026
} else {

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

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,59 @@ export class QuoteUtils {
104104
return result;
105105
}
106106

107+
/**
108+
* Quote an identifier for use as a qualified name tail (after a dot).
109+
*
110+
* In PostgreSQL's grammar, identifiers that appear after a dot (e.g., schema.name,
111+
* table.column) are in a more permissive position that accepts all keyword categories
112+
* including RESERVED_KEYWORD. This means we only need to quote for lexical reasons
113+
* (uppercase, special characters, leading digits) not for keyword reasons.
114+
*
115+
* Empirically verified: `myschema.select`, `myschema.float`, `t.from` all parse
116+
* successfully in PostgreSQL without quotes.
117+
*/
118+
static quoteIdentifierQualifiedTail(ident: string): string {
119+
if (!ident) return ident;
120+
121+
let nquotes = 0;
122+
let safe = true;
123+
124+
const firstChar = ident[0];
125+
if (!((firstChar >= 'a' && firstChar <= 'z') || firstChar === '_')) {
126+
safe = false;
127+
}
128+
129+
for (let i = 0; i < ident.length; i++) {
130+
const ch = ident[i];
131+
if ((ch >= 'a' && ch <= 'z') ||
132+
(ch >= '0' && ch <= '9') ||
133+
(ch === '_')) {
134+
// okay
135+
} else {
136+
safe = false;
137+
if (ch === '"') {
138+
nquotes++;
139+
}
140+
}
141+
}
142+
143+
if (safe) {
144+
return ident;
145+
}
146+
147+
let result = '"';
148+
for (let i = 0; i < ident.length; i++) {
149+
const ch = ident[i];
150+
if (ch === '"') {
151+
result += '"';
152+
}
153+
result += ch;
154+
}
155+
result += '"';
156+
157+
return result;
158+
}
159+
107160
/**
108161
* Quote a possibly-qualified identifier
109162
*
@@ -112,10 +165,14 @@ export class QuoteUtils {
112165
*
113166
* Return a name of the form qualifier.ident, or just ident if qualifier
114167
* 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.
115172
*/
116173
static quoteQualifiedIdentifier(qualifier: string | null | undefined, ident: string): string {
117174
if (qualifier) {
118-
return `${QuoteUtils.quoteIdentifier(qualifier)}.${QuoteUtils.quoteIdentifier(ident)}`;
175+
return `${QuoteUtils.quoteIdentifier(qualifier)}.${QuoteUtils.quoteIdentifierQualifiedTail(ident)}`;
119176
}
120177
return QuoteUtils.quoteIdentifier(ident);
121178
}

0 commit comments

Comments
 (0)