Skip to content

Commit 7ffcc68

Browse files
authored
Merge pull request #284 from constructive-io/devin/1772593921-pgsql-quotes
feat: create @pgsql/quotes package for PostgreSQL identifier quoting and keyword classification
2 parents c801dc4 + 77d5da0 commit 7ffcc68

15 files changed

Lines changed: 3242 additions & 5586 deletions

File tree

packages/deparser/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,7 @@
4040
"organize-transformers": "ts-node scripts/organize-transformers-by-version.ts",
4141
"generate-version-deparsers": "ts-node scripts/generate-version-deparsers.ts",
4242
"generate-packages": "ts-node scripts/generate-version-packages.ts",
43-
"prepare-versions": "npm run strip-transformer-types && npm run strip-direct-transformer-types && npm run strip-deparser-types && npm run organize-transformers && npm run generate-version-deparsers && npm run generate-packages",
44-
"keywords": "ts-node scripts/keywords.ts"
43+
"prepare-versions": "npm run strip-transformer-types && npm run strip-direct-transformer-types && npm run strip-deparser-types && npm run organize-transformers && npm run generate-version-deparsers && npm run generate-packages"
4544
},
4645
"keywords": [
4746
"sql",
@@ -58,6 +57,7 @@
5857
"makage": "^0.1.8"
5958
},
6059
"dependencies": {
60+
"@pgsql/quotes": "workspace:*",
6161
"@pgsql/types": "^17.6.2"
6262
}
6363
}

packages/deparser/src/deparser.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Node } from '@pgsql/types';
22
import { DeparserContext, DeparserVisitor } from './visitors/base';
33
import { SqlFormatter } from './utils/sql-formatter';
4-
import { QuoteUtils } from './utils/quote-utils';
4+
import { QuoteUtils } from '@pgsql/quotes';
55
import { ListUtils } from './utils/list-utils';
66
import * as t from '@pgsql/types';
77

packages/deparser/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ export const deparse = async (...args: Parameters<typeof deparseMethod>): Promis
1111
};
1212

1313
export { Deparser, DeparserOptions };
14-
export { QuoteUtils } from './utils/quote-utils';
14+
export { QuoteUtils } from '@pgsql/quotes';

packages/quotes/README.md

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# @pgsql/quotes
2+
3+
<p align="center" width="100%">
4+
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
5+
</p>
6+
7+
8+
<p align="center" width="100%">
9+
<a href="https://github.com/constructive-io/pgsql-parser/actions/workflows/run-tests.yaml">
10+
<img height="20" src="https://github.com/constructive-io/pgsql-parser/actions/workflows/run-tests.yaml/badge.svg" />
11+
</a>
12+
<a href="https://github.com/constructive-io/pgsql-parser/blob/main/LICENSE-MIT"><img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/></a>
13+
</p>
14+
15+
16+
PostgreSQL identifier quoting and keyword classification utilities. A faithful TypeScript port of PostgreSQL's `quote_identifier()` from `ruleutils.c`, with full keyword classification from `kwlist.h`.
17+
18+
## Installation
19+
20+
```bash
21+
npm install @pgsql/quotes
22+
```
23+
24+
## Usage
25+
26+
### Quoting Identifiers
27+
28+
```typescript
29+
import { QuoteUtils } from '@pgsql/quotes';
30+
31+
// Simple identifiers are not quoted
32+
QuoteUtils.quoteIdentifier('my_table'); // 'my_table'
33+
34+
// Reserved keywords are quoted
35+
QuoteUtils.quoteIdentifier('select'); // '"select"'
36+
QuoteUtils.quoteIdentifier('table'); // '"table"'
37+
38+
// Unreserved keywords are not quoted
39+
QuoteUtils.quoteIdentifier('schema'); // 'schema'
40+
41+
// Identifiers with uppercase or special chars are quoted
42+
QuoteUtils.quoteIdentifier('MyTable'); // '"MyTable"'
43+
QuoteUtils.quoteIdentifier('my-table'); // '"my-table"'
44+
45+
// Embedded double quotes are escaped
46+
QuoteUtils.quoteIdentifier('a"b'); // '"a""b"'
47+
```
48+
49+
### Qualified Names
50+
51+
```typescript
52+
import { QuoteUtils } from '@pgsql/quotes';
53+
54+
// Schema-qualified names
55+
QuoteUtils.quoteQualifiedIdentifier('public', 'my_table');
56+
// 'public.my_table'
57+
58+
// Dotted names (first part strict, rest relaxed)
59+
QuoteUtils.quoteDottedName(['public', 'my_table']);
60+
// 'public.my_table'
61+
62+
// Keywords after dot don't need quoting (PostgreSQL grammar rule)
63+
QuoteUtils.quoteDottedName(['select', 'select']);
64+
// '"select".select'
65+
```
66+
67+
### Type Names
68+
69+
```typescript
70+
import { QuoteUtils } from '@pgsql/quotes';
71+
72+
// Type names allow col_name and type_func_name keywords unquoted
73+
QuoteUtils.quoteIdentifierTypeName('json'); // 'json'
74+
QuoteUtils.quoteIdentifierTypeName('integer'); // 'integer'
75+
QuoteUtils.quoteIdentifierTypeName('boolean'); // 'boolean'
76+
77+
// Only reserved keywords are quoted in type position
78+
QuoteUtils.quoteIdentifierTypeName('select'); // '"select"'
79+
80+
// Schema-qualified type names
81+
QuoteUtils.quoteTypeDottedName(['public', 'json']); // 'public.json'
82+
```
83+
84+
### String Escaping
85+
86+
```typescript
87+
import { QuoteUtils } from '@pgsql/quotes';
88+
89+
// Escape string literals
90+
QuoteUtils.escape('hello'); // "'hello'"
91+
QuoteUtils.escape("it's"); // "'it''s'"
92+
93+
// E-string formatting (auto-detects need for E prefix)
94+
QuoteUtils.formatEString('a\\b'); // "E'a\\\\b'"
95+
QuoteUtils.formatEString('hello'); // "'hello'"
96+
```
97+
98+
### Keyword Classification
99+
100+
```typescript
101+
import { keywordKindOf } from '@pgsql/quotes';
102+
import type { KeywordKind } from '@pgsql/quotes';
103+
104+
keywordKindOf('select'); // 'RESERVED_KEYWORD'
105+
keywordKindOf('schema'); // 'UNRESERVED_KEYWORD'
106+
keywordKindOf('json'); // 'COL_NAME_KEYWORD'
107+
keywordKindOf('join'); // 'TYPE_FUNC_NAME_KEYWORD'
108+
keywordKindOf('foo'); // 'NO_KEYWORD'
109+
```
110+
111+
### Raw Keyword Sets
112+
113+
```typescript
114+
import {
115+
RESERVED_KEYWORDS,
116+
UNRESERVED_KEYWORDS,
117+
COL_NAME_KEYWORDS,
118+
TYPE_FUNC_NAME_KEYWORDS,
119+
} from '@pgsql/quotes';
120+
121+
RESERVED_KEYWORDS.has('select'); // true
122+
COL_NAME_KEYWORDS.has('json'); // true
123+
```
124+
125+
## API
126+
127+
### QuoteUtils
128+
129+
| Method | Description |
130+
|--------|-------------|
131+
| `escape(literal)` | Wraps a string in single quotes, escaping embedded quotes |
132+
| `escapeEString(value)` | Escapes backslashes and single quotes for E-string literals |
133+
| `formatEString(value)` | Auto-detects and formats E-prefixed string literals |
134+
| `needsEscapePrefix(value)` | Checks if a value needs E-prefix escaping |
135+
| `quoteIdentifier(ident)` | Quotes an identifier if needed (port of PG's `quote_identifier`) |
136+
| `quoteIdentifierAfterDot(ident)` | Quotes for lexical reasons only (post-dot position) |
137+
| `quoteDottedName(parts)` | Quotes a multi-part dotted name (e.g., `schema.table`) |
138+
| `quoteQualifiedIdentifier(qualifier, ident)` | Quotes a two-part qualified name |
139+
| `quoteIdentifierTypeName(ident)` | Quotes an identifier in type-name context |
140+
| `quoteTypeDottedName(parts)` | Quotes a multi-part dotted type name |
141+
142+
### keywordKindOf(word)
143+
144+
Returns the keyword classification for a given word. Case-insensitive.
145+
146+
Returns one of: `'NO_KEYWORD'`, `'UNRESERVED_KEYWORD'`, `'COL_NAME_KEYWORD'`, `'TYPE_FUNC_NAME_KEYWORD'`, `'RESERVED_KEYWORD'`.
147+
148+
## Updating Keywords
149+
150+
To regenerate the keyword list from a PostgreSQL source tree:
151+
152+
```bash
153+
npm run keywords -- ~/path/to/postgres/src/include/parser/kwlist.h
154+
```
155+
156+
This parses PostgreSQL's `kwlist.h` and regenerates `src/kwlist.ts`.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {
2+
keywordKindOf,
3+
kwlist,
4+
RESERVED_KEYWORDS,
5+
UNRESERVED_KEYWORDS,
6+
COL_NAME_KEYWORDS,
7+
TYPE_FUNC_NAME_KEYWORDS,
8+
} from '../src/kwlist';
9+
10+
describe('kwlist', () => {
11+
it('should have all four keyword categories', () => {
12+
expect(kwlist.UNRESERVED_KEYWORD.length).toBeGreaterThan(0);
13+
expect(kwlist.RESERVED_KEYWORD.length).toBeGreaterThan(0);
14+
expect(kwlist.TYPE_FUNC_NAME_KEYWORD.length).toBeGreaterThan(0);
15+
expect(kwlist.COL_NAME_KEYWORD.length).toBeGreaterThan(0);
16+
});
17+
18+
it('should have pre-built Sets matching arrays', () => {
19+
expect(RESERVED_KEYWORDS.size).toBe(kwlist.RESERVED_KEYWORD.length);
20+
expect(UNRESERVED_KEYWORDS.size).toBe(kwlist.UNRESERVED_KEYWORD.length);
21+
expect(COL_NAME_KEYWORDS.size).toBe(kwlist.COL_NAME_KEYWORD.length);
22+
expect(TYPE_FUNC_NAME_KEYWORDS.size).toBe(kwlist.TYPE_FUNC_NAME_KEYWORD.length);
23+
});
24+
});
25+
26+
describe('keywordKindOf', () => {
27+
it('should classify reserved keywords', () => {
28+
expect(keywordKindOf('select')).toBe('RESERVED_KEYWORD');
29+
expect(keywordKindOf('from')).toBe('RESERVED_KEYWORD');
30+
expect(keywordKindOf('where')).toBe('RESERVED_KEYWORD');
31+
expect(keywordKindOf('table')).toBe('RESERVED_KEYWORD');
32+
expect(keywordKindOf('create')).toBe('RESERVED_KEYWORD');
33+
});
34+
35+
it('should classify unreserved keywords', () => {
36+
expect(keywordKindOf('abort')).toBe('UNRESERVED_KEYWORD');
37+
expect(keywordKindOf('begin')).toBe('UNRESERVED_KEYWORD');
38+
expect(keywordKindOf('commit')).toBe('UNRESERVED_KEYWORD');
39+
expect(keywordKindOf('schema')).toBe('UNRESERVED_KEYWORD');
40+
expect(keywordKindOf('index')).toBe('UNRESERVED_KEYWORD');
41+
});
42+
43+
it('should classify col_name keywords', () => {
44+
expect(keywordKindOf('int')).toBe('COL_NAME_KEYWORD');
45+
expect(keywordKindOf('integer')).toBe('COL_NAME_KEYWORD');
46+
expect(keywordKindOf('boolean')).toBe('COL_NAME_KEYWORD');
47+
expect(keywordKindOf('json')).toBe('COL_NAME_KEYWORD');
48+
expect(keywordKindOf('varchar')).toBe('COL_NAME_KEYWORD');
49+
});
50+
51+
it('should classify type_func_name keywords', () => {
52+
expect(keywordKindOf('authorization')).toBe('TYPE_FUNC_NAME_KEYWORD');
53+
expect(keywordKindOf('cross')).toBe('TYPE_FUNC_NAME_KEYWORD');
54+
expect(keywordKindOf('join')).toBe('TYPE_FUNC_NAME_KEYWORD');
55+
expect(keywordKindOf('left')).toBe('TYPE_FUNC_NAME_KEYWORD');
56+
});
57+
58+
it('should return NO_KEYWORD for non-keywords', () => {
59+
expect(keywordKindOf('my_table')).toBe('NO_KEYWORD');
60+
expect(keywordKindOf('foo')).toBe('NO_KEYWORD');
61+
expect(keywordKindOf('bar_baz')).toBe('NO_KEYWORD');
62+
});
63+
64+
it('should be case-insensitive', () => {
65+
expect(keywordKindOf('SELECT')).toBe('RESERVED_KEYWORD');
66+
expect(keywordKindOf('Select')).toBe('RESERVED_KEYWORD');
67+
expect(keywordKindOf('BEGIN')).toBe('UNRESERVED_KEYWORD');
68+
});
69+
});

0 commit comments

Comments
 (0)