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
4 changes: 2 additions & 2 deletions packages/deparser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@
"organize-transformers": "ts-node scripts/organize-transformers-by-version.ts",
"generate-version-deparsers": "ts-node scripts/generate-version-deparsers.ts",
"generate-packages": "ts-node scripts/generate-version-packages.ts",
"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",
"keywords": "ts-node scripts/keywords.ts"
"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"
},
"keywords": [
"sql",
Expand All @@ -58,6 +57,7 @@
"makage": "^0.1.8"
},
"dependencies": {
"@pgsql/quotes": "workspace:*",
"@pgsql/types": "^17.6.2"
}
}
2 changes: 1 addition & 1 deletion packages/deparser/src/deparser.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Node } from '@pgsql/types';
import { DeparserContext, DeparserVisitor } from './visitors/base';
import { SqlFormatter } from './utils/sql-formatter';
import { QuoteUtils } from './utils/quote-utils';
import { QuoteUtils } from '@pgsql/quotes';
import { ListUtils } from './utils/list-utils';
import * as t from '@pgsql/types';

Expand Down
2 changes: 1 addition & 1 deletion packages/deparser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ export const deparse = async (...args: Parameters<typeof deparseMethod>): Promis
};

export { Deparser, DeparserOptions };
export { QuoteUtils } from './utils/quote-utils';
export { QuoteUtils } from '@pgsql/quotes';
156 changes: 156 additions & 0 deletions packages/quotes/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# @pgsql/quotes

<p align="center" width="100%">
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
</p>


<p align="center" width="100%">
<a href="https://github.com/constructive-io/pgsql-parser/actions/workflows/run-tests.yaml">
<img height="20" src="https://github.com/constructive-io/pgsql-parser/actions/workflows/run-tests.yaml/badge.svg" />
</a>
<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>
</p>


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`.

## Installation

```bash
npm install @pgsql/quotes
```

## Usage

### Quoting Identifiers

```typescript
import { QuoteUtils } from '@pgsql/quotes';

// Simple identifiers are not quoted
QuoteUtils.quoteIdentifier('my_table'); // 'my_table'

// Reserved keywords are quoted
QuoteUtils.quoteIdentifier('select'); // '"select"'
QuoteUtils.quoteIdentifier('table'); // '"table"'

// Unreserved keywords are not quoted
QuoteUtils.quoteIdentifier('schema'); // 'schema'

// Identifiers with uppercase or special chars are quoted
QuoteUtils.quoteIdentifier('MyTable'); // '"MyTable"'
QuoteUtils.quoteIdentifier('my-table'); // '"my-table"'

// Embedded double quotes are escaped
QuoteUtils.quoteIdentifier('a"b'); // '"a""b"'
```

### Qualified Names

```typescript
import { QuoteUtils } from '@pgsql/quotes';

// Schema-qualified names
QuoteUtils.quoteQualifiedIdentifier('public', 'my_table');
// 'public.my_table'

// Dotted names (first part strict, rest relaxed)
QuoteUtils.quoteDottedName(['public', 'my_table']);
// 'public.my_table'

// Keywords after dot don't need quoting (PostgreSQL grammar rule)
QuoteUtils.quoteDottedName(['select', 'select']);
// '"select".select'
```

### Type Names

```typescript
import { QuoteUtils } from '@pgsql/quotes';

// Type names allow col_name and type_func_name keywords unquoted
QuoteUtils.quoteIdentifierTypeName('json'); // 'json'
QuoteUtils.quoteIdentifierTypeName('integer'); // 'integer'
QuoteUtils.quoteIdentifierTypeName('boolean'); // 'boolean'

// Only reserved keywords are quoted in type position
QuoteUtils.quoteIdentifierTypeName('select'); // '"select"'

// Schema-qualified type names
QuoteUtils.quoteTypeDottedName(['public', 'json']); // 'public.json'
```

### String Escaping

```typescript
import { QuoteUtils } from '@pgsql/quotes';

// Escape string literals
QuoteUtils.escape('hello'); // "'hello'"
QuoteUtils.escape("it's"); // "'it''s'"

// E-string formatting (auto-detects need for E prefix)
QuoteUtils.formatEString('a\\b'); // "E'a\\\\b'"
QuoteUtils.formatEString('hello'); // "'hello'"
```

### Keyword Classification

```typescript
import { keywordKindOf } from '@pgsql/quotes';
import type { KeywordKind } from '@pgsql/quotes';

keywordKindOf('select'); // 'RESERVED_KEYWORD'
keywordKindOf('schema'); // 'UNRESERVED_KEYWORD'
keywordKindOf('json'); // 'COL_NAME_KEYWORD'
keywordKindOf('join'); // 'TYPE_FUNC_NAME_KEYWORD'
keywordKindOf('foo'); // 'NO_KEYWORD'
```

### Raw Keyword Sets

```typescript
import {
RESERVED_KEYWORDS,
UNRESERVED_KEYWORDS,
COL_NAME_KEYWORDS,
TYPE_FUNC_NAME_KEYWORDS,
} from '@pgsql/quotes';

RESERVED_KEYWORDS.has('select'); // true
COL_NAME_KEYWORDS.has('json'); // true
```

## API

### QuoteUtils

| Method | Description |
|--------|-------------|
| `escape(literal)` | Wraps a string in single quotes, escaping embedded quotes |
| `escapeEString(value)` | Escapes backslashes and single quotes for E-string literals |
| `formatEString(value)` | Auto-detects and formats E-prefixed string literals |
| `needsEscapePrefix(value)` | Checks if a value needs E-prefix escaping |
| `quoteIdentifier(ident)` | Quotes an identifier if needed (port of PG's `quote_identifier`) |
| `quoteIdentifierAfterDot(ident)` | Quotes for lexical reasons only (post-dot position) |
| `quoteDottedName(parts)` | Quotes a multi-part dotted name (e.g., `schema.table`) |
| `quoteQualifiedIdentifier(qualifier, ident)` | Quotes a two-part qualified name |
| `quoteIdentifierTypeName(ident)` | Quotes an identifier in type-name context |
| `quoteTypeDottedName(parts)` | Quotes a multi-part dotted type name |

### keywordKindOf(word)

Returns the keyword classification for a given word. Case-insensitive.

Returns one of: `'NO_KEYWORD'`, `'UNRESERVED_KEYWORD'`, `'COL_NAME_KEYWORD'`, `'TYPE_FUNC_NAME_KEYWORD'`, `'RESERVED_KEYWORD'`.

## Updating Keywords

To regenerate the keyword list from a PostgreSQL source tree:

```bash
npm run keywords -- ~/path/to/postgres/src/include/parser/kwlist.h
```

This parses PostgreSQL's `kwlist.h` and regenerates `src/kwlist.ts`.
69 changes: 69 additions & 0 deletions packages/quotes/__tests__/kwlist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {
keywordKindOf,
kwlist,
RESERVED_KEYWORDS,
UNRESERVED_KEYWORDS,
COL_NAME_KEYWORDS,
TYPE_FUNC_NAME_KEYWORDS,
} from '../src/kwlist';

describe('kwlist', () => {
it('should have all four keyword categories', () => {
expect(kwlist.UNRESERVED_KEYWORD.length).toBeGreaterThan(0);
expect(kwlist.RESERVED_KEYWORD.length).toBeGreaterThan(0);
expect(kwlist.TYPE_FUNC_NAME_KEYWORD.length).toBeGreaterThan(0);
expect(kwlist.COL_NAME_KEYWORD.length).toBeGreaterThan(0);
});

it('should have pre-built Sets matching arrays', () => {
expect(RESERVED_KEYWORDS.size).toBe(kwlist.RESERVED_KEYWORD.length);
expect(UNRESERVED_KEYWORDS.size).toBe(kwlist.UNRESERVED_KEYWORD.length);
expect(COL_NAME_KEYWORDS.size).toBe(kwlist.COL_NAME_KEYWORD.length);
expect(TYPE_FUNC_NAME_KEYWORDS.size).toBe(kwlist.TYPE_FUNC_NAME_KEYWORD.length);
});
});

describe('keywordKindOf', () => {
it('should classify reserved keywords', () => {
expect(keywordKindOf('select')).toBe('RESERVED_KEYWORD');
expect(keywordKindOf('from')).toBe('RESERVED_KEYWORD');
expect(keywordKindOf('where')).toBe('RESERVED_KEYWORD');
expect(keywordKindOf('table')).toBe('RESERVED_KEYWORD');
expect(keywordKindOf('create')).toBe('RESERVED_KEYWORD');
});

it('should classify unreserved keywords', () => {
expect(keywordKindOf('abort')).toBe('UNRESERVED_KEYWORD');
expect(keywordKindOf('begin')).toBe('UNRESERVED_KEYWORD');
expect(keywordKindOf('commit')).toBe('UNRESERVED_KEYWORD');
expect(keywordKindOf('schema')).toBe('UNRESERVED_KEYWORD');
expect(keywordKindOf('index')).toBe('UNRESERVED_KEYWORD');
});

it('should classify col_name keywords', () => {
expect(keywordKindOf('int')).toBe('COL_NAME_KEYWORD');
expect(keywordKindOf('integer')).toBe('COL_NAME_KEYWORD');
expect(keywordKindOf('boolean')).toBe('COL_NAME_KEYWORD');
expect(keywordKindOf('json')).toBe('COL_NAME_KEYWORD');
expect(keywordKindOf('varchar')).toBe('COL_NAME_KEYWORD');
});

it('should classify type_func_name keywords', () => {
expect(keywordKindOf('authorization')).toBe('TYPE_FUNC_NAME_KEYWORD');
expect(keywordKindOf('cross')).toBe('TYPE_FUNC_NAME_KEYWORD');
expect(keywordKindOf('join')).toBe('TYPE_FUNC_NAME_KEYWORD');
expect(keywordKindOf('left')).toBe('TYPE_FUNC_NAME_KEYWORD');
});

it('should return NO_KEYWORD for non-keywords', () => {
expect(keywordKindOf('my_table')).toBe('NO_KEYWORD');
expect(keywordKindOf('foo')).toBe('NO_KEYWORD');
expect(keywordKindOf('bar_baz')).toBe('NO_KEYWORD');
});

it('should be case-insensitive', () => {
expect(keywordKindOf('SELECT')).toBe('RESERVED_KEYWORD');
expect(keywordKindOf('Select')).toBe('RESERVED_KEYWORD');
expect(keywordKindOf('BEGIN')).toBe('UNRESERVED_KEYWORD');
});
});
Loading