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
126 changes: 126 additions & 0 deletions packages/pgsql-types/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# pgsql-types

<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>
<a href="https://www.npmjs.com/package/pgsql-types"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/pgsql-parser?filename=packages%2Fpgsql-types%2Fpackage.json"/></a>
</p>

Narrowed TypeScript type definitions for PostgreSQL AST nodes.

> **Experimental:** This package provides narrowed types inferred from real SQL usage patterns. For production use, see [`@pgsql/types`](https://www.npmjs.com/package/@pgsql/types). However, please kick the tires and let us know what you think!

## Overview

This package provides TypeScript type definitions for PostgreSQL AST nodes with **narrowed `Node` unions**. Instead of generic `Node` types that could be any of ~250 node types, fields are narrowed to only the specific types that actually appear in practice.

The narrowed types are inferred by parsing thousands of SQL statements from PostgreSQL's test suite and tracking which node types appear in each field.

## Installation

```bash
npm install pgsql-types
```

## The Problem

With `@pgsql/types`, the `arg` field in `DefElem` is typed as `Node` - a union of all possible AST node types:

```typescript
// @pgsql/types
export interface DefElem {
defnamespace?: string;
defname?: string;
arg?: Node; // Could be any of ~250 types!
defaction?: DefElemAction;
location?: number;
}
```

When processing the AST, you have no guidance on what types to actually handle.

## The Solution

With `pgsql-types`, the same field is narrowed to only the types that actually appear:

```typescript
// pgsql-types
export interface DefElem {
defnamespace?: string;
defname?: string;
arg?: { A_Const: A_Const }
| { A_Star: A_Star }
| { Boolean: Boolean }
| { Float: Float }
| { Integer: Integer }
| { List: List }
| { String: String }
| { TypeName: TypeName }
| { VariableSetStmt: VariableSetStmt };
defaction?: DefElemAction;
location?: number;
}
```

Now you know exactly which cases to handle when processing `DefElem.arg`.

## Usage

```typescript
import { DefElem, SelectStmt, CreateStmt } from 'pgsql-types';

function processDefElem(elem: DefElem) {
if (elem.arg) {
// TypeScript knows arg can only be one of 9 specific types
if ('String' in elem.arg) {
console.log('String value:', elem.arg.String.sval);
} else if ('Integer' in elem.arg) {
console.log('Integer value:', elem.arg.Integer.ival);
} else if ('List' in elem.arg) {
console.log('List with', elem.arg.List.items?.length, 'items');
}
// ... handle other cases
}
}
```

## How It Works

The narrowed types are generated by:

1. Parsing all SQL fixtures from PostgreSQL's regression test suite (~70,000 statements)
2. Walking each AST and tracking which node types appear in each `Node`-typed field
3. Generating TypeScript interfaces with narrowed unions based on the observed types

This approach ensures the narrowed types reflect real-world usage patterns from PostgreSQL's own test suite.

## Exports

This package exports:

- All narrowed interfaces (e.g., `SelectStmt`, `CreateStmt`, `DefElem`, etc.)
- The `Node` type from `@pgsql/types`
- All enums from `@pgsql/enums`

## Limitations

- The narrowed types are based on SQL fixtures and may not cover every possible valid AST structure
- Some rarely-used node combinations may not be included in the narrowed unions
- For maximum type safety in production, consider using `@pgsql/types` with runtime validation

## Related Packages

- [`@pgsql/types`](https://www.npmjs.com/package/@pgsql/types) - Production TypeScript types for PostgreSQL AST
- [`@pgsql/enums`](https://www.npmjs.com/package/@pgsql/enums) - PostgreSQL enum definitions
- [`pgsql-parser`](https://www.npmjs.com/package/pgsql-parser) - Parse SQL to AST
- [`pgsql-deparser`](https://www.npmjs.com/package/pgsql-deparser) - Convert AST back to SQL

## License

MIT
52 changes: 52 additions & 0 deletions packages/pgsql-types/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "pgsql-types",
"version": "17.0.0",
"author": "Constructive <developers@constructive.io>",
"description": "Narrowed PostgreSQL AST type definitions with specific Node unions",
"main": "index.js",
"module": "esm/index.js",
"types": "index.d.ts",
"homepage": "https://github.com/constructive-io/pgsql-parser",
"license": "MIT",
"publishConfig": {
"access": "public",
"directory": "dist"
},
"repository": {
"type": "git",
"url": "https://github.com/constructive-io/pgsql-parser"
},
"bugs": {
"url": "https://github.com/constructive-io/pgsql-parser/issues"
},
"scripts": {
"copy": "makage assets",
"clean": "makage clean dist",
"prepublishOnly": "npm run build",
"build": "npm run infer && npm run clean && tsc && tsc -p tsconfig.esm.json && npm run copy",
"build:dev": "npm run clean && tsc --declarationMap && tsc -p tsconfig.esm.json && npm run copy",
"infer": "ts-node scripts/infer-field-metadata.ts",
"generate": "ts-node scripts/generate-types.ts",
"lint": "eslint . --fix",
"test": "jest",
"test:watch": "jest --watch"
},
"devDependencies": {
"makage": "^0.1.8",
"libpg-query": "17.7.3"
},
"dependencies": {
"@pgsql/types": "^17.6.2",
"@pgsql/enums": "^17.6.2",
"@pgsql/utils": "^17.8.9"
},
"keywords": [
"sql",
"postgres",
"postgresql",
"pg",
"ast",
"types",
"typescript"
]
}
204 changes: 204 additions & 0 deletions packages/pgsql-types/scripts/generate-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { runtimeSchema, NodeSpec, FieldSpec } from '../../utils/src/runtime-schema';
import * as fs from 'fs';
import * as path from 'path';

interface FieldMetadata {
nullable: boolean;
tags: string[];
isArray: boolean;
}

interface NodeFieldMetadata {
[fieldName: string]: FieldMetadata;
}

interface AllFieldMetadata {
[nodeName: string]: NodeFieldMetadata;
}

const schemaMap = new Map<string, NodeSpec>(
runtimeSchema.map((spec: NodeSpec) => [spec.name, spec])
);

const primitiveTypeMap: Record<string, string> = {
'string': 'string',
'bool': 'boolean',
'int32': 'number',
'int64': 'number',
'uint32': 'number',
'uint64': 'number',
'float': 'number',
'double': 'number',
'bytes': 'Uint8Array',
};

function isPrimitiveType(type: string): boolean {
return type in primitiveTypeMap;
}

function isEnumType(type: string): boolean {
return !isPrimitiveType(type) && !schemaMap.has(type) && type !== 'Node';
}

function getTsType(type: string): string {
return primitiveTypeMap[type] || type;
}

function collectEnumTypes(): Set<string> {
const enumTypes = new Set<string>();
for (const nodeSpec of runtimeSchema) {
for (const field of nodeSpec.fields) {
if (isEnumType(field.type)) {
enumTypes.add(field.type);
}
}
}
return enumTypes;
}

function generateWrappedUnion(tags: string[]): string {
if (tags.length === 0) {
return 'Node';
}

const sortedTags = [...tags].sort();
return sortedTags.map(tag => `{ ${tag}: ${tag} }`).join(' | ');
}

function generateTypeAlias(nodeName: string, fieldName: string, tags: string[]): string {
const aliasName = `${nodeName}_${fieldName}`;
const union = generateWrappedUnion(tags);
return `type ${aliasName} = ${union};`;
}

function generateInterface(
nodeSpec: NodeSpec,
fieldMetadata: NodeFieldMetadata | undefined
): string {
const lines: string[] = [];
lines.push(`export interface ${nodeSpec.name} {`);

for (const field of nodeSpec.fields) {
const tsType = getFieldType(nodeSpec.name, field, fieldMetadata);
const optional = field.optional ? '?' : '';
lines.push(` ${field.name}${optional}: ${tsType};`);
}

lines.push('}');
return lines.join('\n');
}

function getFieldType(
nodeName: string,
field: FieldSpec,
fieldMetadata: NodeFieldMetadata | undefined
): string {
let baseType: string;

if (field.type === 'Node') {
const meta = fieldMetadata?.[field.name];
if (meta && meta.tags.length > 0) {
baseType = `${nodeName}_${field.name}`;
} else {
baseType = 'Node';
}
} else if (isPrimitiveType(field.type)) {
baseType = getTsType(field.type);
} else {
if (schemaMap.has(field.type)) {
baseType = `{ ${field.type}: ${field.type} }`;
} else {
baseType = field.type;
}
}

if (field.isArray) {
if (baseType.includes('|') || baseType.includes('{')) {
return `(${baseType})[]`;
}
return `${baseType}[]`;
}

return baseType;
}

function generateTypes(metadata: AllFieldMetadata): string {
const lines: string[] = [];

lines.push('/**');
lines.push(' * This file was automatically generated by pgsql-types.');
lines.push(' * DO NOT MODIFY IT BY HAND.');
lines.push(' * ');
lines.push(' * These types provide narrowed Node unions based on actual usage');
lines.push(' * patterns discovered by parsing SQL fixtures.');
lines.push(' */');
lines.push('');

const enumTypes = collectEnumTypes();
const sortedEnums = [...enumTypes].sort();

lines.push("import type { Node } from '@pgsql/types';");
if (sortedEnums.length > 0) {
lines.push(`import { ${sortedEnums.join(', ')} } from '@pgsql/enums';`);
}
lines.push("export type { Node } from '@pgsql/types';");
lines.push("export * from '@pgsql/enums';");
lines.push('');

const typeAliases: string[] = [];
for (const nodeName of Object.keys(metadata).sort()) {
const nodeMetadata = metadata[nodeName];
for (const fieldName of Object.keys(nodeMetadata).sort()) {
const fieldMeta = nodeMetadata[fieldName];
if (fieldMeta.tags.length > 0) {
typeAliases.push(generateTypeAlias(nodeName, fieldName, fieldMeta.tags));
}
}
}

if (typeAliases.length > 0) {
lines.push('// Internal type aliases for narrowed Node-typed fields (not exported)');
lines.push(typeAliases.join('\n'));
lines.push('');
}

lines.push('// Interfaces with narrowed Node types');
for (const nodeSpec of runtimeSchema) {
const nodeMetadata = metadata[nodeSpec.name];
lines.push(generateInterface(nodeSpec, nodeMetadata));
lines.push('');
}

return lines.join('\n');
}

async function main() {
const metadataPath = path.resolve(__dirname, '../src/field-metadata.json');
const outputPath = path.resolve(__dirname, '../src/types.ts');

if (!fs.existsSync(metadataPath)) {
console.error('Field metadata not found. Run "npm run infer" first.');
process.exit(1);
}

const metadata: AllFieldMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));

console.log('Generating narrowed types...');
const typesContent = generateTypes(metadata);

fs.writeFileSync(outputPath, typesContent);
console.log(`Wrote narrowed types to ${outputPath}`);

let totalAliases = 0;
for (const nodeName of Object.keys(metadata)) {
for (const fieldName of Object.keys(metadata[nodeName])) {
if (metadata[nodeName][fieldName].tags.length > 0) {
totalAliases++;
}
}
}

console.log(`Generated ${totalAliases} narrowed type aliases`);
}

main().catch(console.error);
Loading