diff --git a/docs/guide/formatting-recipes.md b/docs/guide/formatting-recipes.md index 5391c6c..a2c7d5d 100644 --- a/docs/guide/formatting-recipes.md +++ b/docs/guide/formatting-recipes.md @@ -176,3 +176,38 @@ Anonymous style prints bare symbols such as `?` or `%s`. `SqlFormatter` still re ## Learn More Check the full [`SqlFormatterOptions` API](../api/interfaces/SqlFormatterOptions.md) documentation for every toggle, including advanced preset configuration and default values. + +## Formatting DDL statements + +`SqlFormatter` now understands schema-definition statements. You can parse `CREATE TABLE`, `DROP TABLE`, `ALTER TABLE` constraint changes, and index management statements and feed the resulting ASTs through the formatter to keep them consistent with query output. + +```ts +import { + CreateTableParser, + DropTableParser, + CreateIndexParser, + DropIndexParser, + DropConstraintParser, + AlterTableParser, + SqlFormatter +} from 'rawsql-ts'; + +const ddl = `CREATE TABLE IF NOT EXISTS public.users ( + id BIGINT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + role_id INT REFERENCES auth.roles(id) +) WITH (fillfactor = 80)`; + +const ast = CreateTableParser.parse(ddl); +const { formattedSql } = new SqlFormatter({ keywordCase: 'lower' }).format(ast); +// formattedSql => drop-in-ready canonical SQL +``` + +Use the dedicated parsers when working with other DDL statements: + +- `DropTableParser` for `DROP TABLE` with multi-table targets and cascading options. +- `AlterTableParser` to capture `ADD CONSTRAINT`/`DROP CONSTRAINT` actions on existing tables. +- `CreateIndexParser` and `DropIndexParser` to normalize index definitions, including INCLUDE lists, storage parameters, and partial index predicates. +- `DropConstraintParser` when databases support standalone constraint removal. + +These parsers emit strongly typed models (`CreateTableQuery`, `CreateIndexStatement`, `AlterTableStatement`, and more) so the formatter and other visitors can treat DDL alongside queries. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2b1aa35..99e5023 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,11 @@ export * from './parsers/DeleteQueryParser'; export * from './parsers/WithClauseParser'; export * from './parsers/CreateTableParser'; export * from './parsers/MergeQueryParser'; +export * from './parsers/CreateIndexParser'; +export * from './parsers/DropTableParser'; +export * from './parsers/DropIndexParser'; +export * from './parsers/AlterTableParser'; +export * from './parsers/DropConstraintParser'; export * from './models/BinarySelectQuery'; export * from './models/SelectQuery'; @@ -22,6 +27,7 @@ export * from './models/UpdateQuery'; export * from './models/DeleteQuery'; export * from './models/CreateTableQuery'; export * from './models/MergeQuery'; +export * from './models/DDLStatements'; export * from './transformers/CTECollector'; export * from './transformers/CTENormalizer'; diff --git a/packages/core/src/models/CreateTableQuery.ts b/packages/core/src/models/CreateTableQuery.ts index 65657e4..253138f 100644 --- a/packages/core/src/models/CreateTableQuery.ts +++ b/packages/core/src/models/CreateTableQuery.ts @@ -1,34 +1,187 @@ import { SqlComponent } from "./SqlComponent"; import type { SelectQuery } from "./SelectQuery"; -import { ColumnReference, FunctionCall, IdentifierString, RawString } from "./ValueComponent"; +import { + ColumnReference, + FunctionCall, + IdentifierString, + RawString, + ValueComponent, + TypeValue, + QualifiedName +} from "./ValueComponent"; import { SimpleSelectQuery } from "./SimpleSelectQuery"; import { SelectClause, SelectItem, FromClause, TableSource, SourceExpression } from "./Clause"; import { SelectValueCollector } from "../transformers/SelectValueCollector"; -// Represents a CREATE TABLE query model -// Supports temporary tables and AS SELECT ... +export type ReferentialAction = 'cascade' | 'restrict' | 'no action' | 'set null' | 'set default'; +export type ConstraintDeferrability = 'deferrable' | 'not deferrable' | null; +export type ConstraintInitially = 'immediate' | 'deferred' | null; +export type MatchType = 'full' | 'partial' | 'simple' | null; + +/** + * Represents a REFERENCES clause definition that can be shared between column and table constraints. + */ +export class ReferenceDefinition extends SqlComponent { + static kind = Symbol("ReferenceDefinition"); + targetTable: QualifiedName; + columns: IdentifierString[] | null; + matchType: MatchType; + onDelete: ReferentialAction | null; + onUpdate: ReferentialAction | null; + deferrable: ConstraintDeferrability; + initially: ConstraintInitially; + + constructor(params: { + targetTable: QualifiedName; + columns?: IdentifierString[] | null; + matchType?: MatchType; + onDelete?: ReferentialAction | null; + onUpdate?: ReferentialAction | null; + deferrable?: ConstraintDeferrability; + initially?: ConstraintInitially; + }) { + super(); + this.targetTable = params.targetTable; + this.columns = params.columns ? [...params.columns] : null; + this.matchType = params.matchType ?? null; + this.onDelete = params.onDelete ?? null; + this.onUpdate = params.onUpdate ?? null; + this.deferrable = params.deferrable ?? null; + this.initially = params.initially ?? null; + } +} + +export type ColumnConstraintKind = + | 'not-null' + | 'null' + | 'default' + | 'primary-key' + | 'unique' + | 'references' + | 'check' + | 'generated-always-identity' + | 'generated-by-default-identity' + | 'raw'; + +/** + * Column-level constraint definition. + */ +export class ColumnConstraintDefinition extends SqlComponent { + static kind = Symbol("ColumnConstraintDefinition"); + kind: ColumnConstraintKind; + constraintName?: IdentifierString; + defaultValue?: ValueComponent; + checkExpression?: ValueComponent; + reference?: ReferenceDefinition; + rawClause?: RawString; + + constructor(params: { + kind: ColumnConstraintKind; + constraintName?: IdentifierString; + defaultValue?: ValueComponent; + checkExpression?: ValueComponent; + reference?: ReferenceDefinition; + rawClause?: RawString; + }) { + super(); + this.kind = params.kind; + this.constraintName = params.constraintName; + this.defaultValue = params.defaultValue; + this.checkExpression = params.checkExpression; + this.reference = params.reference; + this.rawClause = params.rawClause; + } +} + +export type TableConstraintKind = 'primary-key' | 'unique' | 'foreign-key' | 'check' | 'raw'; + +/** + * Table-level constraint definition. + */ +export class TableConstraintDefinition extends SqlComponent { + static kind = Symbol("TableConstraintDefinition"); + kind: TableConstraintKind; + constraintName?: IdentifierString; + columns: IdentifierString[] | null; + reference?: ReferenceDefinition; + checkExpression?: ValueComponent; + rawClause?: RawString; + deferrable: ConstraintDeferrability; + initially: ConstraintInitially; + + constructor(params: { + kind: TableConstraintKind; + constraintName?: IdentifierString; + columns?: IdentifierString[] | null; + reference?: ReferenceDefinition; + checkExpression?: ValueComponent; + rawClause?: RawString; + deferrable?: ConstraintDeferrability; + initially?: ConstraintInitially; + }) { + super(); + this.kind = params.kind; + this.constraintName = params.constraintName; + this.columns = params.columns ? [...params.columns] : null; + this.reference = params.reference; + this.checkExpression = params.checkExpression; + this.rawClause = params.rawClause; + this.deferrable = params.deferrable ?? null; + this.initially = params.initially ?? null; + } +} + +/** + * Represents a single column definition within CREATE TABLE. + */ +export class TableColumnDefinition extends SqlComponent { + static kind = Symbol("TableColumnDefinition"); + name: IdentifierString; + dataType?: TypeValue | RawString; + constraints: ColumnConstraintDefinition[]; + + constructor(params: { + name: IdentifierString; + dataType?: TypeValue | RawString; + constraints?: ColumnConstraintDefinition[]; + }) { + super(); + this.name = params.name; + this.dataType = params.dataType; + this.constraints = params.constraints ? [...params.constraints] : []; + } +} + +// Represents a CREATE TABLE query model that supports column definitions and AS SELECT variants. export class CreateTableQuery extends SqlComponent { - /** SqlComponent kind symbol for visitor pattern */ static kind = Symbol("CreateTableQuery"); - /** Table name (with optional schema) */ tableName: IdentifierString; - /** If true, this is a temporary table */ + namespaces: string[] | null; isTemporary: boolean; - /** If true, the statement includes IF NOT EXISTS */ ifNotExists: boolean; - /** Optional: SELECT query for AS SELECT ... */ + columns: TableColumnDefinition[]; + tableConstraints: TableConstraintDefinition[]; + tableOptions?: RawString | null; asSelectQuery?: SelectQuery; constructor(params: { tableName: string; + namespaces?: string[] | null; isTemporary?: boolean; ifNotExists?: boolean; + columns?: TableColumnDefinition[]; + tableConstraints?: TableConstraintDefinition[]; + tableOptions?: RawString | null; asSelectQuery?: SelectQuery; }) { super(); this.tableName = new IdentifierString(params.tableName); + this.namespaces = params.namespaces ? [...params.namespaces] : null; this.isTemporary = params.isTemporary ?? false; this.ifNotExists = params.ifNotExists ?? false; + this.columns = params.columns ? [...params.columns] : []; + this.tableConstraints = params.tableConstraints ? [...params.tableConstraints] : []; + this.tableOptions = params.tableOptions ?? null; this.asSelectQuery = params.asSelectQuery; } @@ -37,23 +190,36 @@ export class CreateTableQuery extends SqlComponent { */ getSelectQuery(): SimpleSelectQuery { let selectItems: SelectItem[]; + + // Prefer explicit AS SELECT query columns when present. if (this.asSelectQuery) { - // Use SelectValueCollector to get columns from asSelectQuery const collector = new SelectValueCollector(); const values = collector.collect(this.asSelectQuery); selectItems = values.map(val => new SelectItem(val.value, val.name)); + } else if (this.columns.length > 0) { + // Use defined column names when the table definition is DDL-based. + selectItems = this.columns.map(column => new SelectItem( + new ColumnReference(null, column.name), + column.name.name + )); } else { - // fallback: wildcard + // Fallback to wild-card selection when no column metadata is available. selectItems = [new SelectItem(new RawString("*"))]; } + + // Build a simple SELECT ... FROM table query. + const qualifiedName = this.namespaces && this.namespaces.length > 0 + ? [...this.namespaces, this.tableName.name].join(".") + : this.tableName.name; + return new SimpleSelectQuery({ selectClause: new SelectClause(selectItems), fromClause: new FromClause( new SourceExpression( - new TableSource(null, this.tableName.name), + new TableSource(null, qualifiedName), null ), - null // joins + null ), }); } @@ -62,16 +228,20 @@ export class CreateTableQuery extends SqlComponent { * Returns a SelectQuery that counts all rows in this table. */ getCountQuery(): SimpleSelectQuery { + const qualifiedName = this.namespaces && this.namespaces.length > 0 + ? [...this.namespaces, this.tableName.name].join(".") + : this.tableName.name; + return new SimpleSelectQuery({ selectClause: new SelectClause([ new SelectItem(new FunctionCall(null, "count", new ColumnReference(null, "*"), null)) ]), fromClause: new FromClause( new SourceExpression( - new TableSource(null, this.tableName.name), + new TableSource(null, qualifiedName), null ), - null // joins + null ), }); } diff --git a/packages/core/src/models/DDLStatements.ts b/packages/core/src/models/DDLStatements.ts new file mode 100644 index 0000000..97a791a --- /dev/null +++ b/packages/core/src/models/DDLStatements.ts @@ -0,0 +1,218 @@ +import { SqlComponent } from "./SqlComponent"; +import { + QualifiedName, + IdentifierString, + ValueComponent, + RawString +} from "./ValueComponent"; +import { + TableConstraintDefinition +} from "./CreateTableQuery"; + +export type DropBehavior = 'cascade' | 'restrict' | null; +export type IndexSortOrder = 'asc' | 'desc' | null; +export type IndexNullsOrder = 'first' | 'last' | null; + +/** + * DROP TABLE statement representation. + */ +export class DropTableStatement extends SqlComponent { + static kind = Symbol("DropTableStatement"); + tables: QualifiedName[]; + ifExists: boolean; + behavior: DropBehavior; + + constructor(params: { tables: QualifiedName[]; ifExists?: boolean; behavior?: DropBehavior }) { + super(); + this.tables = params.tables.map(table => new QualifiedName(table.namespaces, table.name)); + this.ifExists = params.ifExists ?? false; + this.behavior = params.behavior ?? null; + } +} + +/** + * DROP INDEX statement representation. + */ +export class DropIndexStatement extends SqlComponent { + static kind = Symbol("DropIndexStatement"); + indexNames: QualifiedName[]; + ifExists: boolean; + concurrently: boolean; + behavior: DropBehavior; + + constructor(params: { + indexNames: QualifiedName[]; + ifExists?: boolean; + concurrently?: boolean; + behavior?: DropBehavior; + }) { + super(); + this.indexNames = params.indexNames.map(index => new QualifiedName(index.namespaces, index.name)); + this.ifExists = params.ifExists ?? false; + this.concurrently = params.concurrently ?? false; + this.behavior = params.behavior ?? null; + } +} + +/** + * Column definition within CREATE INDEX clause. + */ +export class IndexColumnDefinition extends SqlComponent { + static kind = Symbol("IndexColumnDefinition"); + expression: ValueComponent; + sortOrder: IndexSortOrder; + nullsOrder: IndexNullsOrder; + collation?: QualifiedName | null; + operatorClass?: QualifiedName | null; + + constructor(params: { + expression: ValueComponent; + sortOrder?: IndexSortOrder; + nullsOrder?: IndexNullsOrder; + collation?: QualifiedName | null; + operatorClass?: QualifiedName | null; + }) { + super(); + this.expression = params.expression; + this.sortOrder = params.sortOrder ?? null; + this.nullsOrder = params.nullsOrder ?? null; + this.collation = params.collation ?? null; + this.operatorClass = params.operatorClass ?? null; + } +} + +/** + * CREATE INDEX statement representation. + */ +export class CreateIndexStatement extends SqlComponent { + static kind = Symbol("CreateIndexStatement"); + unique: boolean; + concurrently: boolean; + ifNotExists: boolean; + indexName: QualifiedName; + tableName: QualifiedName; + usingMethod?: IdentifierString | RawString | null; + columns: IndexColumnDefinition[]; + include?: IdentifierString[] | null; + where?: ValueComponent; + withOptions?: RawString | null; + tablespace?: IdentifierString | null; + + constructor(params: { + unique?: boolean; + concurrently?: boolean; + ifNotExists?: boolean; + indexName: QualifiedName; + tableName: QualifiedName; + usingMethod?: IdentifierString | RawString | null; + columns: IndexColumnDefinition[]; + include?: IdentifierString[] | null; + where?: ValueComponent; + withOptions?: RawString | null; + tablespace?: IdentifierString | null; + }) { + super(); + this.unique = params.unique ?? false; + this.concurrently = params.concurrently ?? false; + this.ifNotExists = params.ifNotExists ?? false; + this.indexName = new QualifiedName(params.indexName.namespaces, params.indexName.name); + this.tableName = new QualifiedName(params.tableName.namespaces, params.tableName.name); + this.usingMethod = params.usingMethod ?? null; + this.columns = params.columns.map(col => new IndexColumnDefinition({ + expression: col.expression, + sortOrder: col.sortOrder, + nullsOrder: col.nullsOrder, + collation: col.collation ?? null, + operatorClass: col.operatorClass ?? null + })); + this.include = params.include ? [...params.include] : null; + this.where = params.where; + this.withOptions = params.withOptions ?? null; + this.tablespace = params.tablespace ?? null; + } +} + +/** + * ALTER TABLE ... ADD CONSTRAINT action. + */ +export class AlterTableAddConstraint extends SqlComponent { + static kind = Symbol("AlterTableAddConstraint"); + constraint: TableConstraintDefinition; + ifNotExists: boolean; + notValid: boolean; + + constructor(params: { + constraint: TableConstraintDefinition; + ifNotExists?: boolean; + notValid?: boolean; + }) { + super(); + this.constraint = params.constraint; + this.ifNotExists = params.ifNotExists ?? false; + this.notValid = params.notValid ?? false; + } +} + +/** + * ALTER TABLE ... DROP CONSTRAINT action. + */ +export class AlterTableDropConstraint extends SqlComponent { + static kind = Symbol("AlterTableDropConstraint"); + constraintName: IdentifierString; + ifExists: boolean; + behavior: DropBehavior; + + constructor(params: { + constraintName: IdentifierString; + ifExists?: boolean; + behavior?: DropBehavior; + }) { + super(); + this.constraintName = params.constraintName; + this.ifExists = params.ifExists ?? false; + this.behavior = params.behavior ?? null; + } +} + +export type AlterTableAction = AlterTableAddConstraint | AlterTableDropConstraint; + +/** + * ALTER TABLE statement representation with constraint-centric actions. + */ +export class AlterTableStatement extends SqlComponent { + static kind = Symbol("AlterTableStatement"); + table: QualifiedName; + only: boolean; + ifExists: boolean; + actions: AlterTableAction[]; + + constructor(params: { + table: QualifiedName; + only?: boolean; + ifExists?: boolean; + actions: AlterTableAction[]; + }) { + super(); + this.table = new QualifiedName(params.table.namespaces, params.table.name); + this.only = params.only ?? false; + this.ifExists = params.ifExists ?? false; + this.actions = params.actions.map(action => action); + } +} + +/** + * Standalone DROP CONSTRAINT statement representation. + */ +export class DropConstraintStatement extends SqlComponent { + static kind = Symbol("DropConstraintStatement"); + constraintName: IdentifierString; + ifExists: boolean; + behavior: DropBehavior; + + constructor(params: { constraintName: IdentifierString; ifExists?: boolean; behavior?: DropBehavior }) { + super(); + this.constraintName = params.constraintName; + this.ifExists = params.ifExists ?? false; + this.behavior = params.behavior ?? null; + } +} diff --git a/packages/core/src/models/SqlPrintToken.ts b/packages/core/src/models/SqlPrintToken.ts index 76f1d71..5c8b3d7 100644 --- a/packages/core/src/models/SqlPrintToken.ts +++ b/packages/core/src/models/SqlPrintToken.ts @@ -83,6 +83,20 @@ export enum SqlPrintTokenContainerType { ReturningClause = "ReturningClause", SetClauseItem = "SetClauseItem", CreateTableQuery = "CreateTableQuery", + CreateTableDefinition = "CreateTableDefinition", + TableColumnDefinition = "TableColumnDefinition", + ColumnConstraintDefinition = "ColumnConstraintDefinition", + TableConstraintDefinition = "TableConstraintDefinition", + ReferenceDefinition = "ReferenceDefinition", + CreateIndexStatement = "CreateIndexStatement", + IndexColumnList = "IndexColumnList", + IndexColumnDefinition = "IndexColumnDefinition", + DropTableStatement = "DropTableStatement", + DropIndexStatement = "DropIndexStatement", + AlterTableStatement = "AlterTableStatement", + AlterTableAddConstraint = "AlterTableAddConstraint", + AlterTableDropConstraint = "AlterTableDropConstraint", + DropConstraintStatement = "DropConstraintStatement", MergeQuery = "MergeQuery", MergeWhenClause = "MergeWhenClause", MergeUpdateAction = "MergeUpdateAction", diff --git a/packages/core/src/parsers/AlterTableParser.ts b/packages/core/src/parsers/AlterTableParser.ts new file mode 100644 index 0000000..6aaddf1 --- /dev/null +++ b/packages/core/src/parsers/AlterTableParser.ts @@ -0,0 +1,447 @@ +import { SqlTokenizer } from "./SqlTokenizer"; +import { + AlterTableStatement, + AlterTableAction, + AlterTableAddConstraint, + AlterTableDropConstraint, + DropBehavior +} from "../models/DDLStatements"; +import { + TableConstraintDefinition, + ReferenceDefinition, + MatchType, + ReferentialAction, + ConstraintDeferrability, + ConstraintInitially, + TableConstraintKind +} from "../models/CreateTableQuery"; +import { Lexeme, TokenType } from "../models/Lexeme"; +import { FullNameParser } from "./FullNameParser"; +import { QualifiedName, IdentifierString, RawString, ValueComponent } from "../models/ValueComponent"; +import { ValueParser } from "./ValueParser"; +import { joinLexemeValues } from "../utils/ParserStringUtils"; + +/** + * Parses ALTER TABLE statements focused on constraint operations. + */ +export class AlterTableParser { + private static readonly CONSTRAINT_TYPE_TOKENS = new Set([ + "primary key", + "unique", + "unique key", + "foreign key", + "check" + ]); + + private static readonly MATCH_KEYWORDS = new Map([ + ["match full", "full"], + ["match partial", "partial"], + ["match simple", "simple"] + ]); + + private static readonly REFERENTIAL_ACTIONS = new Map([ + ["cascade", "cascade"], + ["restrict", "restrict"], + ["no action", "no action"], + ["set null", "set null"], + ["set default", "set default"] + ]); + + private static readonly DEFERRABILITY_KEYWORDS = new Map([ + ["deferrable", "deferrable"], + ["not deferrable", "not deferrable"] + ]); + + private static readonly INITIALLY_KEYWORDS = new Map([ + ["initially immediate", "immediate"], + ["initially deferred", "deferred"] + ]); + + public static parse(sql: string): AlterTableStatement { + const tokenizer = new SqlTokenizer(sql); + const lexemes = tokenizer.readLexemes(); + const result = this.parseFromLexeme(lexemes, 0); + if (result.newIndex < lexemes.length) { + throw new Error(`[AlterTableParser] Unexpected token "${lexemes[result.newIndex].value}" after ALTER TABLE statement.`); + } + return result.value; + } + + public static parseFromLexeme(lexemes: Lexeme[], index: number): { value: AlterTableStatement; newIndex: number } { + let idx = index; + + if (lexemes[idx]?.value.toLowerCase() !== "alter table") { + throw new Error(`[AlterTableParser] Expected ALTER TABLE at index ${idx}.`); + } + idx++; + + let ifExists = false; + if (lexemes[idx]?.value.toLowerCase() === "if exists") { + ifExists = true; + idx++; + } + + let only = false; + if (lexemes[idx]?.value.toLowerCase() === "only") { + only = true; + idx++; + } + + const tableResult = FullNameParser.parseFromLexeme(lexemes, idx); + const tableName = new QualifiedName(tableResult.namespaces, tableResult.name); + idx = tableResult.newIndex; + + const actions: AlterTableAction[] = []; + + while (idx < lexemes.length) { + const value = lexemes[idx].value.toLowerCase(); + + if (value === "add constraint") { + const result = this.parseAddConstraintAction(lexemes, idx); + actions.push(result.value); + idx = result.newIndex; + } else if (value === "drop constraint") { + const result = this.parseDropConstraintAction(lexemes, idx); + actions.push(result.value); + idx = result.newIndex; + } else { + throw new Error(`[AlterTableParser] Unsupported ALTER TABLE action '${lexemes[idx].value}' at index ${idx}.`); + } + + if (lexemes[idx]?.type === TokenType.Comma) { + idx++; + continue; + } + + break; + } + + if (actions.length === 0) { + throw new Error("[AlterTableParser] ALTER TABLE requires at least one action."); + } + + return { + value: new AlterTableStatement({ table: tableName, only, ifExists, actions }), + newIndex: idx + }; + } + + private static parseAddConstraintAction(lexemes: Lexeme[], index: number): { value: AlterTableAddConstraint; newIndex: number } { + let idx = index; + + const initialToken = lexemes[idx]?.value.toLowerCase(); + if (initialToken !== "add" && initialToken !== "add constraint") { + throw new Error(`[AlterTableParser] Expected ADD or ADD CONSTRAINT at index ${idx}.`); + } + idx++; + + // If the token was plain ADD, consume optional CONSTRAINT keyword. + if (initialToken === "add" && lexemes[idx]?.value.toLowerCase() === "constraint") { + idx++; + } + + let ifNotExists = false; + if (lexemes[idx]?.value.toLowerCase() === "if not exists") { + ifNotExists = true; + idx++; + } + + let constraintName: IdentifierString | undefined; + const nextValue = lexemes[idx]?.value.toLowerCase(); + if (nextValue && !this.CONSTRAINT_TYPE_TOKENS.has(nextValue)) { + const nameResult = FullNameParser.parseFromLexeme(lexemes, idx); + constraintName = nameResult.name; + idx = nameResult.newIndex; + } + + const constraintResult = this.parseTableConstraintDefinition(lexemes, idx, constraintName); + idx = constraintResult.newIndex; + + let notValid = false; + if (lexemes[idx]?.value.toLowerCase() === "not valid") { + notValid = true; + idx++; + } + + return { + value: new AlterTableAddConstraint({ + constraint: constraintResult.constraint, + ifNotExists, + notValid + }), + newIndex: idx + }; + } + + private static parseDropConstraintAction(lexemes: Lexeme[], index: number): { value: AlterTableDropConstraint; newIndex: number } { + let idx = index; + + const initialValue = lexemes[idx]?.value.toLowerCase(); + if (initialValue === "drop constraint") { + idx++; + } else if (initialValue === "drop") { + idx++; + if (lexemes[idx]?.value.toLowerCase() !== "constraint") { + throw new Error(`[AlterTableParser] Expected CONSTRAINT keyword after DROP at index ${idx}.`); + } + idx++; + } else { + throw new Error(`[AlterTableParser] Expected DROP CONSTRAINT at index ${idx}.`); + } + + let ifExists = false; + if (lexemes[idx]?.value.toLowerCase() === "if exists") { + ifExists = true; + idx++; + } + + const nameResult = FullNameParser.parseFromLexeme(lexemes, idx); + idx = nameResult.newIndex; + + let behavior: DropBehavior = null; + const nextValue = lexemes[idx]?.value.toLowerCase(); + if (nextValue === "cascade" || nextValue === "restrict") { + behavior = nextValue as DropBehavior; + idx++; + } + + return { + value: new AlterTableDropConstraint({ + constraintName: nameResult.name, + ifExists, + behavior + }), + newIndex: idx + }; + } + + private static parseTableConstraintDefinition( + lexemes: Lexeme[], + index: number, + constraintName?: IdentifierString + ): { constraint: TableConstraintDefinition; newIndex: number } { + let idx = index; + const token = lexemes[idx]; + if (!token) { + throw new Error(`[AlterTableParser] Missing constraint definition at index ${idx}.`); + } + const value = token.value.toLowerCase(); + + if (value === "primary key") { + idx++; + const listResult = this.parseIdentifierList(lexemes, idx); + idx = listResult.newIndex; + return { + constraint: new TableConstraintDefinition({ + kind: "primary-key", + constraintName, + columns: listResult.identifiers + }), + newIndex: idx + }; + } + + if (value === "unique" || value === "unique key") { + idx++; + const listResult = this.parseIdentifierList(lexemes, idx); + idx = listResult.newIndex; + return { + constraint: new TableConstraintDefinition({ + kind: "unique", + constraintName, + columns: listResult.identifiers + }), + newIndex: idx + }; + } + + if (value === "foreign key") { + idx++; + const listResult = this.parseIdentifierList(lexemes, idx); + idx = listResult.newIndex; + const referenceResult = this.parseReferenceDefinition(lexemes, idx); + idx = referenceResult.newIndex; + return { + constraint: new TableConstraintDefinition({ + kind: "foreign-key", + constraintName, + columns: listResult.identifiers, + reference: referenceResult.reference, + deferrable: referenceResult.reference.deferrable, + initially: referenceResult.reference.initially + }), + newIndex: idx + }; + } + + if (value === "check") { + idx++; + const checkExpression = this.parseParenExpression(lexemes, idx); + idx = checkExpression.newIndex; + return { + constraint: new TableConstraintDefinition({ + kind: "check", + constraintName, + checkExpression: checkExpression.value + }), + newIndex: idx + }; + } + + const rawEnd = this.findConstraintClauseEnd(lexemes, idx + 1); + const rawText = joinLexemeValues(lexemes, idx, rawEnd); + return { + constraint: new TableConstraintDefinition({ + kind: "raw" as TableConstraintKind, + constraintName, + rawClause: new RawString(rawText) + }), + newIndex: rawEnd + }; + } + + private static parseIdentifierList(lexemes: Lexeme[], index: number): { identifiers: IdentifierString[]; newIndex: number } { + let idx = index; + const identifiers: IdentifierString[] = []; + + if (lexemes[idx]?.type !== TokenType.OpenParen) { + throw new Error(`[AlterTableParser] Expected '(' to start identifier list at index ${idx}.`); + } + idx++; + + while (idx < lexemes.length) { + const nameResult = FullNameParser.parseFromLexeme(lexemes, idx); + identifiers.push(nameResult.name); + idx = nameResult.newIndex; + + if (lexemes[idx]?.type === TokenType.Comma) { + idx++; + continue; + } + + if (lexemes[idx]?.type === TokenType.CloseParen) { + idx++; + break; + } + } + + return { identifiers, newIndex: idx }; + } + + private static parseReferenceDefinition(lexemes: Lexeme[], index: number): { reference: ReferenceDefinition; newIndex: number } { + let idx = index; + if (lexemes[idx]?.value.toLowerCase() !== "references") { + throw new Error(`[AlterTableParser] Expected REFERENCES clause at index ${idx}.`); + } + idx++; + + const tableResult = FullNameParser.parseFromLexeme(lexemes, idx); + const targetTable = new QualifiedName(tableResult.namespaces, tableResult.name); + idx = tableResult.newIndex; + + let columns: IdentifierString[] | null = null; + if (lexemes[idx]?.type === TokenType.OpenParen) { + const listResult = this.parseIdentifierList(lexemes, idx); + columns = listResult.identifiers; + idx = listResult.newIndex; + } + + let matchType: MatchType = null; + let onDelete: ReferentialAction | null = null; + let onUpdate: ReferentialAction | null = null; + let deferrable: ConstraintDeferrability = null; + let initially: ConstraintInitially = null; + + while (idx < lexemes.length) { + const current = lexemes[idx].value.toLowerCase(); + + if (this.MATCH_KEYWORDS.has(current)) { + matchType = this.MATCH_KEYWORDS.get(current)!; + idx++; + continue; + } + + if (current === "match") { + idx++; + const descriptor = lexemes[idx]?.value.toLowerCase() ?? ""; + matchType = descriptor as MatchType; + idx++; + continue; + } + + if (current === "on delete") { + idx++; + const action = lexemes[idx]?.value.toLowerCase() ?? ""; + onDelete = this.REFERENTIAL_ACTIONS.get(action) ?? null; + idx++; + continue; + } + + if (current === "on update") { + idx++; + const action = lexemes[idx]?.value.toLowerCase() ?? ""; + onUpdate = this.REFERENTIAL_ACTIONS.get(action) ?? null; + idx++; + continue; + } + + if (this.DEFERRABILITY_KEYWORDS.has(current)) { + deferrable = this.DEFERRABILITY_KEYWORDS.get(current)!; + idx++; + continue; + } + + if (this.INITIALLY_KEYWORDS.has(current)) { + initially = this.INITIALLY_KEYWORDS.get(current)!; + idx++; + continue; + } + + break; + } + + return { + reference: new ReferenceDefinition({ + targetTable, + columns, + matchType, + onDelete, + onUpdate, + deferrable, + initially + }), + newIndex: idx + }; + } + + private static parseParenExpression(lexemes: Lexeme[], index: number): { value: ValueComponent; newIndex: number } { + let idx = index; + if (lexemes[idx]?.type !== TokenType.OpenParen) { + throw new Error(`[AlterTableParser] Expected '(' starting CHECK expression at index ${idx}.`); + } + idx++; + const result = ValueParser.parseFromLexeme(lexemes, idx); + idx = result.newIndex; + if (lexemes[idx]?.type !== TokenType.CloseParen) { + throw new Error(`[AlterTableParser] Expected ')' closing CHECK expression at index ${idx}.`); + } + idx++; + return { value: result.value, newIndex: idx }; + } + + private static findConstraintClauseEnd(lexemes: Lexeme[], index: number): number { + let idx = index; + while (idx < lexemes.length) { + const token = lexemes[idx]; + if (token.type & (TokenType.Comma | TokenType.CloseParen)) { + break; + } + if (token.value.toLowerCase() === "not valid") { + break; + } + idx++; + } + return idx; + } + +} diff --git a/packages/core/src/parsers/CreateIndexParser.ts b/packages/core/src/parsers/CreateIndexParser.ts new file mode 100644 index 0000000..432cfab --- /dev/null +++ b/packages/core/src/parsers/CreateIndexParser.ts @@ -0,0 +1,278 @@ +import { SqlTokenizer } from "./SqlTokenizer"; +import { + CreateIndexStatement, + IndexColumnDefinition, + IndexSortOrder, + IndexNullsOrder +} from "../models/DDLStatements"; +import { Lexeme, TokenType } from "../models/Lexeme"; +import { FullNameParser } from "./FullNameParser"; +import { QualifiedName, IdentifierString, RawString, ValueComponent } from "../models/ValueComponent"; +import { ValueParser } from "./ValueParser"; +import { joinLexemeValues } from "../utils/ParserStringUtils"; + +/** + * Parses CREATE INDEX statements. + */ +export class CreateIndexParser { + public static parse(sql: string): CreateIndexStatement { + const tokenizer = new SqlTokenizer(sql); + const lexemes = tokenizer.readLexemes(); + const result = this.parseFromLexeme(lexemes, 0); + if (result.newIndex < lexemes.length) { + throw new Error(`[CreateIndexParser] Unexpected token "${lexemes[result.newIndex].value}" after CREATE INDEX.`); + } + return result.value; + } + + public static parseFromLexeme(lexemes: Lexeme[], index: number): { value: CreateIndexStatement; newIndex: number } { + let idx = index; + + const firstToken = lexemes[idx]?.value.toLowerCase(); + if (firstToken !== "create index" && firstToken !== "create unique index") { + throw new Error(`[CreateIndexParser] Expected CREATE INDEX at index ${idx}.`); + } + const unique = firstToken === "create unique index"; + idx++; + + let concurrently = false; + if (lexemes[idx]?.value.toLowerCase() === "concurrently") { + concurrently = true; + idx++; + } + + let ifNotExists = false; + if (lexemes[idx]?.value.toLowerCase() === "if not exists") { + ifNotExists = true; + idx++; + } + + const indexNameResult = FullNameParser.parseFromLexeme(lexemes, idx); + const indexName = new QualifiedName(indexNameResult.namespaces, indexNameResult.name); + idx = indexNameResult.newIndex; + + if (lexemes[idx]?.value.toLowerCase() !== "on") { + throw new Error(`[CreateIndexParser] Expected ON keyword before table name at index ${idx}.`); + } + idx++; + + const tableResult = FullNameParser.parseFromLexeme(lexemes, idx); + const tableName = new QualifiedName(tableResult.namespaces, tableResult.name); + idx = tableResult.newIndex; + + let usingMethod: IdentifierString | RawString | null = null; + if (lexemes[idx]?.value.toLowerCase() === "using") { + idx++; + const methodResult = FullNameParser.parseFromLexeme(lexemes, idx); + usingMethod = methodResult.name; + idx = methodResult.newIndex; + } + + const columnsResult = this.parseIndexColumnList(lexemes, idx); + const columns = columnsResult.columns; + idx = columnsResult.newIndex; + + let include: IdentifierString[] | null = null; + if (lexemes[idx]?.value.toLowerCase() === "include") { + idx++; + const includeResult = this.parseIdentifierList(lexemes, idx); + include = includeResult.identifiers; + idx = includeResult.newIndex; + } + + let withOptions: RawString | null = null; + if (lexemes[idx]?.value.toLowerCase() === "with") { + const withResult = this.parseWithOptions(lexemes, idx); + withOptions = withResult.options; + idx = withResult.newIndex; + } + + let tablespace: IdentifierString | null = null; + if (lexemes[idx]?.value.toLowerCase() === "tablespace") { + idx++; + const tablespaceResult = FullNameParser.parseFromLexeme(lexemes, idx); + tablespace = tablespaceResult.name; + idx = tablespaceResult.newIndex; + } + + let whereClause: ValueComponent | undefined; + if (lexemes[idx]?.value.toLowerCase() === "where") { + idx++; + const whereResult = ValueParser.parseFromLexeme(lexemes, idx); + whereClause = whereResult.value; + idx = whereResult.newIndex; + } + + return { + value: new CreateIndexStatement({ + unique, + concurrently, + ifNotExists, + indexName, + tableName, + usingMethod, + columns, + include, + withOptions, + tablespace, + where: whereClause + }), + newIndex: idx + }; + } + + private static parseIndexColumnList(lexemes: Lexeme[], index: number): { columns: IndexColumnDefinition[]; newIndex: number } { + let idx = index; + if (lexemes[idx]?.type !== TokenType.OpenParen) { + throw new Error(`[CreateIndexParser] Expected '(' starting column list at index ${idx}.`); + } + idx++; + + const columns: IndexColumnDefinition[] = []; + + while (idx < lexemes.length) { + const expressionResult = ValueParser.parseFromLexeme(lexemes, idx); + idx = expressionResult.newIndex; + + let sortOrder: IndexSortOrder = null; + let nullsOrder: IndexNullsOrder = null; + let collation: QualifiedName | null = null; + let operatorClass: QualifiedName | null = null; + + while (idx < lexemes.length) { + const tokenValue = lexemes[idx].value.toLowerCase(); + + if (tokenValue === "asc" || tokenValue === "desc") { + sortOrder = tokenValue as IndexSortOrder; + idx++; + continue; + } + + if (tokenValue === "nulls first" || tokenValue === "nulls last") { + nullsOrder = tokenValue.endsWith("first") ? "first" : "last"; + idx++; + continue; + } + + if (tokenValue === "collate") { + idx++; + const collateResult = FullNameParser.parseFromLexeme(lexemes, idx); + collation = new QualifiedName(collateResult.namespaces, collateResult.name); + idx = collateResult.newIndex; + continue; + } + + if (this.isClauseTerminator(tokenValue) || (lexemes[idx].type & (TokenType.Comma | TokenType.CloseParen))) { + break; + } + + if (lexemes[idx].type & (TokenType.Identifier | TokenType.Type | TokenType.Function)) { + const opClassResult = FullNameParser.parseFromLexeme(lexemes, idx); + operatorClass = new QualifiedName(opClassResult.namespaces, opClassResult.name); + idx = opClassResult.newIndex; + continue; + } + + break; + } + + columns.push(new IndexColumnDefinition({ + expression: expressionResult.value, + sortOrder, + nullsOrder, + collation, + operatorClass + })); + + if (lexemes[idx]?.type === TokenType.Comma) { + idx++; + continue; + } + + if (lexemes[idx]?.type === TokenType.CloseParen) { + idx++; + break; + } + } + + if (lexemes[idx - 1]?.type !== TokenType.CloseParen) { + throw new Error(`[CreateIndexParser] Expected ')' to close column list starting at index ${index}.`); + } + + return { columns, newIndex: idx }; + } + + private static parseIdentifierList(lexemes: Lexeme[], index: number): { identifiers: IdentifierString[]; newIndex: number } { + let idx = index; + if (lexemes[idx]?.type !== TokenType.OpenParen) { + throw new Error(`[CreateIndexParser] Expected '(' starting identifier list at index ${idx}.`); + } + idx++; + const identifiers: IdentifierString[] = []; + + while (idx < lexemes.length) { + const result = FullNameParser.parseFromLexeme(lexemes, idx); + identifiers.push(result.name); + idx = result.newIndex; + + if (lexemes[idx]?.type === TokenType.Comma) { + idx++; + continue; + } + + if (lexemes[idx]?.type === TokenType.CloseParen) { + idx++; + break; + } + } + + if (lexemes[idx - 1]?.type !== TokenType.CloseParen) { + throw new Error(`[CreateIndexParser] Expected ')' to close identifier list starting at index ${index}.`); + } + + return { identifiers, newIndex: idx }; + } + + private static parseWithOptions(lexemes: Lexeme[], index: number): { options: RawString; newIndex: number } { + let idx = index; + const start = idx; + if (lexemes[idx]?.value.toLowerCase() !== "with") { + throw new Error(`[CreateIndexParser] Expected WITH keyword at index ${idx}.`); + } + idx++; + if (lexemes[idx]?.type !== TokenType.OpenParen) { + throw new Error(`[CreateIndexParser] Expected '(' after WITH at index ${idx}.`); + } + + let depth = 0; + while (idx < lexemes.length) { + if (lexemes[idx].type === TokenType.OpenParen) { + depth++; + } else if (lexemes[idx].type === TokenType.CloseParen) { + depth--; + if (depth === 0) { + idx++; + break; + } + } + idx++; + } + + if (depth !== 0) { + throw new Error(`[CreateIndexParser] Unterminated WITH options starting at index ${start}; unbalanced parentheses.`); + } + + const text = joinLexemeValues(lexemes, start, idx); + return { + options: new RawString(text), + newIndex: idx + }; + } + + private static isClauseTerminator(value: string): boolean { + return value === "include" || + value === "with" || + value === "where" || + value === "tablespace"; + } +} diff --git a/packages/core/src/parsers/CreateTableParser.ts b/packages/core/src/parsers/CreateTableParser.ts index 4331ba4..b8e0075 100644 --- a/packages/core/src/parsers/CreateTableParser.ts +++ b/packages/core/src/parsers/CreateTableParser.ts @@ -1,18 +1,102 @@ import { SqlTokenizer } from "./SqlTokenizer"; import { SelectQueryParser } from "./SelectQueryParser"; -import { CreateTableQuery } from "../models/CreateTableQuery"; +import { + CreateTableQuery, + TableColumnDefinition, + TableConstraintDefinition, + ColumnConstraintDefinition, + ReferenceDefinition, + ColumnConstraintKind, + TableConstraintKind, + ReferentialAction, + ConstraintDeferrability, + ConstraintInitially, + MatchType +} from "../models/CreateTableQuery"; import { Lexeme, TokenType } from "../models/Lexeme"; import { FullNameParser } from "./FullNameParser"; import type { SelectQuery } from "../models/SelectQuery"; +import { FunctionExpressionParser } from "./FunctionExpressionParser"; +import { ValueParser } from "./ValueParser"; +import { IdentifierString, RawString, TypeValue, ValueComponent, QualifiedName } from "../models/ValueComponent"; +import { joinLexemeValues } from "../utils/ParserStringUtils"; + +interface QualifiedNameResult { + namespaces: string[] | null; + name: IdentifierString; + newIndex: number; +} + +interface ColumnParseResult { + value: TableColumnDefinition; + newIndex: number; +} + +interface TableConstraintParseResult { + value: TableConstraintDefinition; + newIndex: number; +} + +interface ReferenceParseResult { + value: ReferenceDefinition; + newIndex: number; +} /** - * Parses CREATE TABLE ... [AS SELECT ...] statements into CreateTableQuery ASTs. - * Currently focuses on CREATE [TEMPORARY] TABLE ... AS SELECT patterns. + * Parses CREATE TABLE statements (DDL or AS SELECT) into CreateTableQuery models. */ export class CreateTableParser { + private static readonly TABLE_CONSTRAINT_STARTERS = new Set([ + "constraint", + "primary key", + "unique", + "unique key", + "foreign key", + "check" + ]); + + private static readonly COLUMN_CONSTRAINT_STARTERS = new Set([ + "constraint", + "not null", + "null", + "default", + "primary key", + "unique", + "unique key", + "references", + "check", + "generated always", + "generated always as identity", + "generated by default", + "generated by default as identity" + ]); + + private static readonly MATCH_KEYWORDS = new Map([ + ["match full", "full"], + ["match partial", "partial"], + ["match simple", "simple"] + ]); + + private static readonly REFERENTIAL_ACTIONS = new Map([ + ["cascade", "cascade"], + ["restrict", "restrict"], + ["no action", "no action"], + ["set null", "set null"], + ["set default", "set default"] + ]); + + private static readonly DEFERRABILITY_KEYWORDS = new Map([ + ["deferrable", "deferrable"], + ["not deferrable", "not deferrable"] + ]); + + private static readonly INITIALLY_KEYWORDS = new Map([ + ["initially immediate", "immediate"], + ["initially deferred", "deferred"] + ]); + /** * Parse SQL string to CreateTableQuery AST. - * @param query SQL string */ public static parse(query: string): CreateTableQuery { const tokenizer = new SqlTokenizer(query); @@ -25,16 +109,17 @@ export class CreateTableParser { } /** - * Parse from lexeme array (for internal use and tests) + * Parse from lexeme array (for internal use and tests). */ public static parseFromLexeme(lexemes: Lexeme[], index: number): { value: CreateTableQuery; newIndex: number } { let idx = index; + // Guard against unexpected end of input before parsing begins. if (idx >= lexemes.length) { throw new Error(`[CreateTableParser] Unexpected end of input at position ${idx}.`); } - const commandToken = lexemes[idx].value; + const commandToken = lexemes[idx].value.toLowerCase(); const isTemporary = commandToken === "create temporary table"; if (commandToken !== "create table" && !isTemporary) { @@ -42,70 +127,588 @@ export class CreateTableParser { } idx++; - // Optional IF NOT EXISTS - const tokenAt = (offset: number) => { - const value = lexemes[idx + offset]?.value; - return typeof value === "string" ? value.toLowerCase() : undefined; - }; - + // Handle optional IF NOT EXISTS clause. + const tokenAt = (offset: number) => lexemes[idx + offset]?.value.toLowerCase(); let ifNotExists = false; - const currentToken = tokenAt(0); - if (currentToken === "if not exists") { - idx += 1; + if (tokenAt(0) === "if not exists") { + idx++; ifNotExists = true; } - if (idx >= lexemes.length) { - throw new Error(`[CreateTableParser] Expected table name at position ${idx}.`); - } - - const tableNameResult = FullNameParser.parseFromLexeme(lexemes, idx); - const tableIdentifier = tableNameResult.name; - const namespaces = tableNameResult.namespaces; + // Parse qualified table name. + const tableNameResult = this.parseQualifiedName(lexemes, idx); idx = tableNameResult.newIndex; + const tableName = tableNameResult.name; + const tableNamespaces = tableNameResult.namespaces; - const tableParts: string[] = []; - if (namespaces && namespaces.length > 0) { - tableParts.push(...namespaces); - } - tableParts.push(tableIdentifier.name); - const tableName = tableParts.join("."); + // Place captured comments from the identifier on the CreateTableQuery after instantiation. + const positionedComments = tableName.positionedComments ? [...tableName.positionedComments] : null; + const legacyComments = tableName.comments ? [...tableName.comments] : null; - // Prevent unsupported column definition syntax. + let columns: TableColumnDefinition[] = []; + let tableConstraints: TableConstraintDefinition[] = []; + let tableOptions: RawString | null = null; + let asSelectQuery: SelectQuery | undefined; + + // Parse DDL column definitions when present. if (lexemes[idx]?.type === TokenType.OpenParen) { - throw new Error("[CreateTableParser] Column definition syntax is not supported. Use CREATE TABLE ... AS SELECT ... instead."); + ({ columns, tableConstraints, newIndex: idx } = this.parseDefinitionList(lexemes, idx)); } - let asSelectQuery: SelectQuery | undefined; - const nextValue = tokenAt(0); - if (nextValue === "as") { + // Capture trailing table options that appear before an AS SELECT clause. + if (idx < lexemes.length) { + const nextValue = tokenAt(0); + if (!this.isSelectKeyword(nextValue, lexemes[idx + 1])) { + const optionsEnd = this.findClauseBoundary(lexemes, idx); + if (optionsEnd > idx) { + tableOptions = new RawString(joinLexemeValues(lexemes, idx, optionsEnd)); + idx = optionsEnd; + } + } + } + + // Parse optional AS SELECT / SELECT clause. + const nextToken = tokenAt(0); + if (nextToken === "as") { idx++; const selectResult = SelectQueryParser.parseFromLexeme(lexemes, idx); asSelectQuery = selectResult.value; idx = selectResult.newIndex; - } else if (nextValue === "select" || nextValue === "with" || nextValue === "values") { - // Allow omitting AS keyword before SELECT / WITH. + } else if (nextToken === "select" || nextToken === "with" || nextToken === "values") { const selectResult = SelectQueryParser.parseFromLexeme(lexemes, idx); asSelectQuery = selectResult.value; idx = selectResult.newIndex; } - const resultQuery = new CreateTableQuery({ - tableName, + const query = new CreateTableQuery({ + tableName: tableName.name, + namespaces: tableNamespaces, isTemporary, ifNotExists, + columns, + tableConstraints, + tableOptions, asSelectQuery }); - if (tableIdentifier?.positionedComments?.length) { - resultQuery.tableName.positionedComments = tableIdentifier.positionedComments.map(pc => ({ + + // Re-attach positioned comments captured on the identifier. + if (positionedComments) { + query.tableName.positionedComments = positionedComments.map(pc => ({ position: pc.position, comments: [...pc.comments] })); } - if (tableIdentifier?.comments?.length) { - resultQuery.tableName.comments = [...tableIdentifier.comments]; + if (legacyComments) { + query.tableName.comments = [...legacyComments]; + } + + return { value: query, newIndex: idx }; + } + + private static parseQualifiedName(lexemes: Lexeme[], index: number): QualifiedNameResult { + const { namespaces, name, newIndex } = FullNameParser.parseFromLexeme(lexemes, index); + return { + namespaces: namespaces ? [...namespaces] : null, + name, + newIndex + }; + } + + private static parseDefinitionList( + lexemes: Lexeme[], + index: number + ): { columns: TableColumnDefinition[]; tableConstraints: TableConstraintDefinition[]; newIndex: number } { + let idx = index; + const columns: TableColumnDefinition[] = []; + const constraints: TableConstraintDefinition[] = []; + + // Skip opening parenthesis. + idx++; + + // Parse individual column or constraint entries until closing parenthesis. + while (idx < lexemes.length) { + const lexeme = lexemes[idx]; + if (lexeme.type === TokenType.CloseParen) { + idx++; + break; + } + + const tokenValue = lexeme.value.toLowerCase(); + + if (this.TABLE_CONSTRAINT_STARTERS.has(tokenValue)) { + const constraintResult = this.parseTableConstraint(lexemes, idx); + constraints.push(constraintResult.value); + idx = constraintResult.newIndex; + } else { + const columnResult = this.parseColumnDefinition(lexemes, idx); + columns.push(columnResult.value); + idx = columnResult.newIndex; + } + + // Consume delimiter comma between definitions. + if (idx < lexemes.length && (lexemes[idx].type & TokenType.Comma)) { + idx++; + continue; + } + + // Break when encountering the closing parenthesis. + if (idx < lexemes.length && lexemes[idx].type === TokenType.CloseParen) { + idx++; + break; + } + } + + return { columns, tableConstraints: constraints, newIndex: idx }; + } + + private static parseColumnDefinition(lexemes: Lexeme[], index: number): ColumnParseResult { + let idx = index; + + // Parse the column name as a qualified identifier. + const columnNameResult = this.parseQualifiedName(lexemes, idx); + idx = columnNameResult.newIndex; + if (columnNameResult.namespaces && columnNameResult.namespaces.length > 0) { + const qualified = [...columnNameResult.namespaces, columnNameResult.name.name].join("."); + throw new Error(`[CreateTableParser] Column name '${qualified}' must not include a schema or namespace qualifier.`); + } + const columnName = columnNameResult.name; + + // Parse optional data type immediately following the column name. + let dataType: TypeValue | RawString | undefined; + if (idx < lexemes.length && !this.isColumnConstraintStart(lexemes[idx]) && !this.isColumnTerminator(lexemes[idx])) { + const typeResult = this.parseColumnType(lexemes, idx); + dataType = typeResult.value; + idx = typeResult.newIndex; + } + + // Collect column constraints until termination. + const constraints: ColumnConstraintDefinition[] = []; + while (idx < lexemes.length && !this.isColumnTerminator(lexemes[idx])) { + const constraintResult = this.parseColumnConstraint(lexemes, idx); + constraints.push(constraintResult.value); + idx = constraintResult.newIndex; + } + + const columnDef = new TableColumnDefinition({ + name: columnName, + dataType, + constraints + }); + + return { value: columnDef, newIndex: idx }; + } + + private static parseColumnType(lexemes: Lexeme[], index: number): { value: TypeValue | RawString; newIndex: number } { + try { + const result = FunctionExpressionParser.parseTypeValue(lexemes, index); + return { value: result.value, newIndex: result.newIndex }; + } catch { + const typeEnd = this.findFirstConstraintIndex(lexemes, index); + const rawText = joinLexemeValues(lexemes, index, typeEnd); + return { value: new RawString(rawText), newIndex: typeEnd }; + } + } + + private static parseColumnConstraint(lexemes: Lexeme[], index: number): { value: ColumnConstraintDefinition; newIndex: number } { + let idx = index; + let constraintName: IdentifierString | undefined; + + // Handle optional CONSTRAINT prefix. + if (lexemes[idx]?.value.toLowerCase() === "constraint") { + idx++; + const nameResult = this.parseQualifiedName(lexemes, idx); + constraintName = nameResult.name; + idx = nameResult.newIndex; + } + + const token = lexemes[idx]; + if (!token) { + throw new Error(`[CreateTableParser] Expected constraint definition at index ${idx}.`); + } + const value = token.value.toLowerCase(); + + // Parse NOT NULL / NULL constraints. + if (value === "not null" || value === "null") { + idx++; + return { + value: new ColumnConstraintDefinition({ + kind: value === "not null" ? "not-null" : "null", + constraintName + }), + newIndex: idx + }; + } + + // Parse DEFAULT constraint with arbitrary expressions. + if (value === "default") { + idx++; + const exprResult = ValueParser.parseFromLexeme(lexemes, idx); + idx = exprResult.newIndex; + return { + value: new ColumnConstraintDefinition({ + kind: "default", + constraintName, + defaultValue: exprResult.value + }), + newIndex: idx + }; + } + + // Parse PRIMARY KEY constraint. + if (value === "primary key") { + idx++; + return { + value: new ColumnConstraintDefinition({ + kind: "primary-key", + constraintName + }), + newIndex: idx + }; + } + + // Parse UNIQUE / UNIQUE KEY constraint. + if (value === "unique" || value === "unique key") { + idx++; + return { + value: new ColumnConstraintDefinition({ + kind: "unique", + constraintName + }), + newIndex: idx + }; + } + + // Parse REFERENCES clause. + if (value === "references") { + const referenceResult = this.parseReferenceDefinition(lexemes, idx); + idx = referenceResult.newIndex; + return { + value: new ColumnConstraintDefinition({ + kind: "references", + constraintName, + reference: referenceResult.value + }), + newIndex: idx + }; + } + + // Parse CHECK constraint. + if (value === "check") { + idx++; + const checkExpression = this.parseParenExpression(lexemes, idx); + idx = checkExpression.newIndex; + return { + value: new ColumnConstraintDefinition({ + kind: "check", + constraintName, + checkExpression: checkExpression.value + }), + newIndex: idx + }; + } + + // Parse identity-style generated clauses. + if (value.startsWith("generated")) { + const clauseEnd = this.findFirstConstraintIndex(lexemes, idx + 1); + const text = joinLexemeValues(lexemes, idx, clauseEnd); + idx = clauseEnd; + const kind: ColumnConstraintKind = value.startsWith("generated always") + ? "generated-always-identity" + : "generated-by-default-identity"; + return { + value: new ColumnConstraintDefinition({ + kind, + constraintName, + rawClause: new RawString(text) + }), + newIndex: idx + }; + } + + // Fallback to raw clause capture for unsupported constraints. + const rawEnd = this.findFirstConstraintIndex(lexemes, idx + 1); + const rawText = joinLexemeValues(lexemes, idx, rawEnd); + return { + value: new ColumnConstraintDefinition({ + kind: "raw", + constraintName, + rawClause: new RawString(rawText) + }), + newIndex: rawEnd + }; + } + + private static parseTableConstraint(lexemes: Lexeme[], index: number): TableConstraintParseResult { + let idx = index; + let constraintName: IdentifierString | undefined; + + // Capture optional CONSTRAINT prefix. + if (lexemes[idx]?.value.toLowerCase() === "constraint") { + idx++; + const nameResult = this.parseQualifiedName(lexemes, idx); + constraintName = nameResult.name; + idx = nameResult.newIndex; + } + + const token = lexemes[idx]; + if (!token) { + throw new Error(`[CreateTableParser] Expected table constraint at index ${idx}.`); + } + + const value = token.value.toLowerCase(); + + if (value === "primary key") { + idx++; + const { identifiers, newIndex } = this.parseIdentifierList(lexemes, idx); + idx = newIndex; + return { + value: new TableConstraintDefinition({ + kind: "primary-key", + constraintName, + columns: identifiers + }), + newIndex: idx + }; + } + + if (value === "unique" || value === "unique key") { + idx++; + const { identifiers, newIndex } = this.parseIdentifierList(lexemes, idx); + idx = newIndex; + return { + value: new TableConstraintDefinition({ + kind: "unique", + constraintName, + columns: identifiers + }), + newIndex: idx + }; + } + + if (value === "foreign key") { + idx++; + const { identifiers, newIndex } = this.parseIdentifierList(lexemes, idx); + idx = newIndex; + const referenceResult = this.parseReferenceDefinition(lexemes, idx); + idx = referenceResult.newIndex; + return { + value: new TableConstraintDefinition({ + kind: "foreign-key", + constraintName, + columns: identifiers, + reference: referenceResult.value, + deferrable: referenceResult.value.deferrable, + initially: referenceResult.value.initially + }), + newIndex: idx + }; + } + + if (value === "check") { + idx++; + const checkExpression = this.parseParenExpression(lexemes, idx); + idx = checkExpression.newIndex; + return { + value: new TableConstraintDefinition({ + kind: "check", + constraintName, + checkExpression: checkExpression.value + }), + newIndex: idx + }; + } + + // Fallback to capturing the raw text when the constraint is not recognized. + const rawEnd = this.findFirstConstraintIndex(lexemes, idx + 1); + const rawText = joinLexemeValues(lexemes, idx, rawEnd); + return { + value: new TableConstraintDefinition({ + kind: "raw", + constraintName, + rawClause: new RawString(rawText) + }), + newIndex: rawEnd + }; + } + + private static parseIdentifierList(lexemes: Lexeme[], index: number): { identifiers: IdentifierString[]; newIndex: number } { + let idx = index; + const identifiers: IdentifierString[] = []; + + if (lexemes[idx]?.type !== TokenType.OpenParen) { + throw new Error(`[CreateTableParser] Expected '(' to start identifier list at index ${idx}.`); + } + idx++; + + while (idx < lexemes.length) { + const nameResult = this.parseQualifiedName(lexemes, idx); + identifiers.push(nameResult.name); + idx = nameResult.newIndex; + + if (lexemes[idx]?.type === TokenType.Comma) { + idx++; + continue; + } + + if (lexemes[idx]?.type === TokenType.CloseParen) { + idx++; + break; + } + } + + return { identifiers, newIndex: idx }; + } + + private static parseReferenceDefinition(lexemes: Lexeme[], index: number): ReferenceParseResult { + let idx = index; + + if (lexemes[idx]?.value.toLowerCase() !== "references") { + throw new Error(`[CreateTableParser] Expected REFERENCES clause at index ${idx}.`); + } + idx++; + + const tableNameResult = this.parseQualifiedName(lexemes, idx); + idx = tableNameResult.newIndex; + const targetTable = new QualifiedName(tableNameResult.namespaces, tableNameResult.name); + + // Parse optional column list in the REFERENCES clause. + let columns: IdentifierString[] | null = null; + if (lexemes[idx]?.type === TokenType.OpenParen) { + const listResult = this.parseIdentifierList(lexemes, idx); + columns = listResult.identifiers; + idx = listResult.newIndex; + } + + let matchType: MatchType = null; + let onDelete: ReferentialAction | null = null; + let onUpdate: ReferentialAction | null = null; + let deferrable: ConstraintDeferrability = null; + let initially: ConstraintInitially = null; + + // Parse optional trailing reference options. + while (idx < lexemes.length) { + const current = lexemes[idx].value.toLowerCase(); + + if (this.MATCH_KEYWORDS.has(current)) { + matchType = this.MATCH_KEYWORDS.get(current)!; + idx++; + continue; + } + + if (current === "match") { + idx++; + const descriptor = lexemes[idx]?.value.toLowerCase() ?? ""; + matchType = descriptor as MatchType; + idx++; + continue; + } + + if (current === "on delete") { + idx++; + const action = lexemes[idx]?.value.toLowerCase() ?? ""; + onDelete = this.REFERENTIAL_ACTIONS.get(action) ?? null; + idx++; + continue; + } + + if (current === "on update") { + idx++; + const action = lexemes[idx]?.value.toLowerCase() ?? ""; + onUpdate = this.REFERENTIAL_ACTIONS.get(action) ?? null; + idx++; + continue; + } + + if (this.DEFERRABILITY_KEYWORDS.has(current)) { + deferrable = this.DEFERRABILITY_KEYWORDS.get(current)!; + idx++; + continue; + } + + if (this.INITIALLY_KEYWORDS.has(current)) { + initially = this.INITIALLY_KEYWORDS.get(current)!; + idx++; + continue; + } + + break; } - return { value: resultQuery, newIndex: idx }; + return { + value: new ReferenceDefinition({ + targetTable, + columns, + matchType, + onDelete, + onUpdate, + deferrable, + initially + }), + newIndex: idx + }; } + + private static parseParenExpression(lexemes: Lexeme[], index: number): { value: ValueComponent; newIndex: number } { + let idx = index; + if (lexemes[idx]?.type !== TokenType.OpenParen) { + throw new Error(`[CreateTableParser] Expected '(' introducing expression at index ${idx}.`); + } + idx++; + const expressionResult = ValueParser.parseFromLexeme(lexemes, idx); + idx = expressionResult.newIndex; + if (lexemes[idx]?.type !== TokenType.CloseParen) { + throw new Error(`[CreateTableParser] Expected ')' terminating expression at index ${idx}.`); + } + idx++; + return { value: expressionResult.value, newIndex: idx }; + } + + private static isColumnConstraintStart(lexeme: Lexeme | undefined): boolean { + if (!lexeme) { + return false; + } + const lower = lexeme.value.toLowerCase(); + return this.COLUMN_CONSTRAINT_STARTERS.has(lower); + } + + private static isColumnTerminator(lexeme: Lexeme | undefined): boolean { + if (!lexeme) { + return true; + } + if (lexeme.type & (TokenType.Comma | TokenType.CloseParen)) { + return true; + } + return false; + } + + private static isSelectKeyword(value: string | undefined, nextLexeme?: Lexeme): boolean { + if (!value) { + return false; + } + if (value === 'with' && nextLexeme?.type === TokenType.OpenParen) { + return false; + } + return value === "as" || value === "select" || value === "with" || value === "values"; + } + + private static findClauseBoundary(lexemes: Lexeme[], index: number): number { + let idx = index; + while (idx < lexemes.length) { + const lower = lexemes[idx].value.toLowerCase(); + if (this.isSelectKeyword(lower, lexemes[idx + 1])) { + break; + } + idx++; + } + return idx; + } + + private static findFirstConstraintIndex(lexemes: Lexeme[], index: number): number { + let idx = index; + while (idx < lexemes.length && !this.isColumnConstraintStart(lexemes[idx]) && !this.isColumnTerminator(lexemes[idx])) { + idx++; + } + return idx; + } + } diff --git a/packages/core/src/parsers/DropConstraintParser.ts b/packages/core/src/parsers/DropConstraintParser.ts new file mode 100644 index 0000000..096da1a --- /dev/null +++ b/packages/core/src/parsers/DropConstraintParser.ts @@ -0,0 +1,53 @@ +import { SqlTokenizer } from "./SqlTokenizer"; +import { DropConstraintStatement, DropBehavior } from "../models/DDLStatements"; +import { Lexeme } from "../models/Lexeme"; +import { FullNameParser } from "./FullNameParser"; + +/** + * Parses standalone DROP CONSTRAINT statements. + */ +export class DropConstraintParser { + public static parse(sql: string): DropConstraintStatement { + const tokenizer = new SqlTokenizer(sql); + const lexemes = tokenizer.readLexemes(); + const result = this.parseFromLexeme(lexemes, 0); + if (result.newIndex < lexemes.length) { + throw new Error(`[DropConstraintParser] Unexpected token "${lexemes[result.newIndex].value}" after DROP CONSTRAINT statement.`); + } + return result.value; + } + + public static parseFromLexeme(lexemes: Lexeme[], index: number): { value: DropConstraintStatement; newIndex: number } { + let idx = index; + + if (lexemes[idx]?.value.toLowerCase() !== "drop constraint") { + throw new Error(`[DropConstraintParser] Expected DROP CONSTRAINT at index ${idx}.`); + } + idx++; + + let ifExists = false; + if (lexemes[idx]?.value.toLowerCase() === "if exists") { + ifExists = true; + idx++; + } + + const { name, newIndex } = FullNameParser.parseFromLexeme(lexemes, idx); + idx = newIndex; + + let behavior: DropBehavior = null; + const nextValue = lexemes[idx]?.value.toLowerCase(); + if (nextValue === "cascade" || nextValue === "restrict") { + behavior = nextValue as DropBehavior; + idx++; + } + + return { + value: new DropConstraintStatement({ + constraintName: name, + ifExists, + behavior + }), + newIndex: idx + }; + } +} diff --git a/packages/core/src/parsers/DropIndexParser.ts b/packages/core/src/parsers/DropIndexParser.ts new file mode 100644 index 0000000..332ee53 --- /dev/null +++ b/packages/core/src/parsers/DropIndexParser.ts @@ -0,0 +1,79 @@ +import { SqlTokenizer } from "./SqlTokenizer"; +import { DropIndexStatement, DropBehavior } from "../models/DDLStatements"; +import { Lexeme, TokenType } from "../models/Lexeme"; +import { FullNameParser } from "./FullNameParser"; +import { QualifiedName } from "../models/ValueComponent"; + +/** + * Parses DROP INDEX statements. + */ +export class DropIndexParser { + public static parse(sql: string): DropIndexStatement { + const tokenizer = new SqlTokenizer(sql); + const lexemes = tokenizer.readLexemes(); + const result = this.parseFromLexeme(lexemes, 0); + if (result.newIndex < lexemes.length) { + throw new Error(`[DropIndexParser] Unexpected token "${lexemes[result.newIndex].value}" after DROP INDEX statement.`); + } + return result.value; + } + + public static parseFromLexeme(lexemes: Lexeme[], index: number): { value: DropIndexStatement; newIndex: number } { + let idx = index; + + if (lexemes[idx]?.value.toLowerCase() !== "drop index") { + throw new Error(`[DropIndexParser] Expected DROP INDEX at index ${idx}.`); + } + idx++; + + // Parse optional CONCURRENTLY modifier. + let concurrently = false; + if (lexemes[idx]?.value.toLowerCase() === "concurrently") { + concurrently = true; + idx++; + } + + // Parse optional IF EXISTS modifier. + let ifExists = false; + if (lexemes[idx]?.value.toLowerCase() === "if exists") { + ifExists = true; + idx++; + const next = lexemes[idx]; + if (!next || next.type !== TokenType.Identifier) { + throw new Error("[DropIndexParser] Expected index name immediately after IF EXISTS."); + } + } + + const indexNames: QualifiedName[] = []; + + // Parse comma-separated index identifiers. + while (idx < lexemes.length) { + const { namespaces, name, newIndex } = FullNameParser.parseFromLexeme(lexemes, idx); + indexNames.push(new QualifiedName(namespaces, name)); + idx = newIndex; + + if (lexemes[idx]?.type === TokenType.Comma) { + idx++; + continue; + } + + break; + } + + if (indexNames.length === 0) { + throw new Error("[DropIndexParser] DROP INDEX must specify at least one index name."); + } + + let behavior: DropBehavior = null; + const nextValue = lexemes[idx]?.value.toLowerCase(); + if (nextValue === "cascade" || nextValue === "restrict") { + behavior = nextValue as DropBehavior; + idx++; + } + + return { + value: new DropIndexStatement({ indexNames, concurrently, ifExists, behavior }), + newIndex: idx + }; + } +} diff --git a/packages/core/src/parsers/DropTableParser.ts b/packages/core/src/parsers/DropTableParser.ts new file mode 100644 index 0000000..7686642 --- /dev/null +++ b/packages/core/src/parsers/DropTableParser.ts @@ -0,0 +1,68 @@ +import { SqlTokenizer } from "./SqlTokenizer"; +import { DropTableStatement, DropBehavior } from "../models/DDLStatements"; +import { Lexeme, TokenType } from "../models/Lexeme"; +import { FullNameParser } from "./FullNameParser"; +import { QualifiedName } from "../models/ValueComponent"; + +/** + * Parses DROP TABLE statements. + */ +export class DropTableParser { + public static parse(sql: string): DropTableStatement { + const tokenizer = new SqlTokenizer(sql); + const lexemes = tokenizer.readLexemes(); + const result = this.parseFromLexeme(lexemes, 0); + if (result.newIndex < lexemes.length) { + throw new Error(`[DropTableParser] Unexpected token "${lexemes[result.newIndex].value}" after DROP TABLE statement.`); + } + return result.value; + } + + public static parseFromLexeme(lexemes: Lexeme[], index: number): { value: DropTableStatement; newIndex: number } { + let idx = index; + + if (lexemes[idx]?.value.toLowerCase() !== "drop table") { + throw new Error(`[DropTableParser] Expected DROP TABLE at index ${idx}.`); + } + idx++; + + // Handle optional IF EXISTS modifier. + let ifExists = false; + if (lexemes[idx]?.value.toLowerCase() === "if exists") { + ifExists = true; + idx++; + } + + const tables: QualifiedName[] = []; + + // Parse comma-separated table list. + while (idx < lexemes.length) { + const { namespaces, name, newIndex } = FullNameParser.parseFromLexeme(lexemes, idx); + tables.push(new QualifiedName(namespaces, name)); + idx = newIndex; + + if (lexemes[idx]?.type === TokenType.Comma) { + idx++; + continue; + } + + break; + } + + if (tables.length === 0) { + throw new Error("[DropTableParser] DROP TABLE must specify at least one table."); + } + + let behavior: DropBehavior = null; + const nextValue = lexemes[idx]?.value.toLowerCase(); + if (nextValue === "cascade" || nextValue === "restrict") { + behavior = nextValue as DropBehavior; + idx++; + } + + return { + value: new DropTableStatement({ tables, ifExists, behavior }), + newIndex: idx + }; + } +} diff --git a/packages/core/src/parsers/SqlParser.ts b/packages/core/src/parsers/SqlParser.ts index 49194ed..fab8f26 100644 --- a/packages/core/src/parsers/SqlParser.ts +++ b/packages/core/src/parsers/SqlParser.ts @@ -5,6 +5,13 @@ import type { UpdateQuery } from '../models/UpdateQuery'; import type { DeleteQuery } from '../models/DeleteQuery'; import type { CreateTableQuery } from '../models/CreateTableQuery'; import type { MergeQuery } from '../models/MergeQuery'; +import type { + DropTableStatement, + DropIndexStatement, + CreateIndexStatement, + AlterTableStatement, + DropConstraintStatement +} from '../models/DDLStatements'; import { SqlTokenizer, StatementLexemeResult } from './SqlTokenizer'; import { SelectQueryParser } from './SelectQueryParser'; import { InsertQueryParser } from './InsertQueryParser'; @@ -13,8 +20,24 @@ import { DeleteQueryParser } from './DeleteQueryParser'; import { CreateTableParser } from './CreateTableParser'; import { MergeQueryParser } from './MergeQueryParser'; import { WithClauseParser } from './WithClauseParser'; - -export type ParsedStatement = SelectQuery | InsertQuery | UpdateQuery | DeleteQuery | CreateTableQuery | MergeQuery; +import { DropTableParser } from './DropTableParser'; +import { DropIndexParser } from './DropIndexParser'; +import { CreateIndexParser } from './CreateIndexParser'; +import { AlterTableParser } from './AlterTableParser'; +import { DropConstraintParser } from './DropConstraintParser'; + +export type ParsedStatement = + | SelectQuery + | InsertQuery + | UpdateQuery + | DeleteQuery + | CreateTableQuery + | MergeQuery + | DropTableStatement + | DropIndexStatement + | CreateIndexStatement + | AlterTableStatement + | DropConstraintStatement; export interface SqlParserOptions { mode?: 'single' | 'multiple'; @@ -138,6 +161,26 @@ export class SqlParser { return this.parseMergeStatement(segment, statementIndex); } + if (firstToken === 'create index' || firstToken === 'create unique index') { + return this.parseCreateIndexStatement(segment, statementIndex); + } + + if (firstToken === 'drop table') { + return this.parseDropTableStatement(segment, statementIndex); + } + + if (firstToken === 'drop index') { + return this.parseDropIndexStatement(segment, statementIndex); + } + + if (firstToken === 'alter table') { + return this.parseAlterTableStatement(segment, statementIndex); + } + + if (firstToken === 'drop constraint') { + return this.parseDropConstraintStatement(segment, statementIndex); + } + throw new Error(`[SqlParser] Statement ${statementIndex} starts with unsupported token "${segment.lexemes[0].value}". Support for additional statement types will be introduced soon.`); } @@ -237,6 +280,91 @@ export class SqlParser { } } + private static parseDropTableStatement(segment: StatementLexemeResult, statementIndex: number): DropTableStatement { + try { + const result = DropTableParser.parseFromLexeme(segment.lexemes, 0); + if (result.newIndex < segment.lexemes.length) { + const unexpected = segment.lexemes[result.newIndex]; + const position = unexpected.position?.startPosition ?? segment.statementStart; + throw new Error( + `[SqlParser] Unexpected token "${unexpected.value}" in statement ${statementIndex} at character ${position}.` + ); + } + return result.value; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`[SqlParser] Failed to parse DROP TABLE statement ${statementIndex}: ${message}`); + } + } + + private static parseDropIndexStatement(segment: StatementLexemeResult, statementIndex: number): DropIndexStatement { + try { + const result = DropIndexParser.parseFromLexeme(segment.lexemes, 0); + if (result.newIndex < segment.lexemes.length) { + const unexpected = segment.lexemes[result.newIndex]; + const position = unexpected.position?.startPosition ?? segment.statementStart; + throw new Error( + `[SqlParser] Unexpected token "${unexpected.value}" in statement ${statementIndex} at character ${position}.` + ); + } + return result.value; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`[SqlParser] Failed to parse DROP INDEX statement ${statementIndex}: ${message}`); + } + } + + private static parseCreateIndexStatement(segment: StatementLexemeResult, statementIndex: number): CreateIndexStatement { + try { + const result = CreateIndexParser.parseFromLexeme(segment.lexemes, 0); + if (result.newIndex < segment.lexemes.length) { + const unexpected = segment.lexemes[result.newIndex]; + const position = unexpected.position?.startPosition ?? segment.statementStart; + throw new Error( + `[SqlParser] Unexpected token "${unexpected.value}" in statement ${statementIndex} at character ${position}.` + ); + } + return result.value; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`[SqlParser] Failed to parse CREATE INDEX statement ${statementIndex}: ${message}`); + } + } + + private static parseAlterTableStatement(segment: StatementLexemeResult, statementIndex: number): AlterTableStatement { + try { + const result = AlterTableParser.parseFromLexeme(segment.lexemes, 0); + if (result.newIndex < segment.lexemes.length) { + const unexpected = segment.lexemes[result.newIndex]; + const position = unexpected.position?.startPosition ?? segment.statementStart; + throw new Error( + `[SqlParser] Unexpected token "${unexpected.value}" in statement ${statementIndex} at character ${position}.` + ); + } + return result.value; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`[SqlParser] Failed to parse ALTER TABLE statement ${statementIndex}: ${message}`); + } + } + + private static parseDropConstraintStatement(segment: StatementLexemeResult, statementIndex: number): DropConstraintStatement { + try { + const result = DropConstraintParser.parseFromLexeme(segment.lexemes, 0); + if (result.newIndex < segment.lexemes.length) { + const unexpected = segment.lexemes[result.newIndex]; + const position = unexpected.position?.startPosition ?? segment.statementStart; + throw new Error( + `[SqlParser] Unexpected token "${unexpected.value}" in statement ${statementIndex} at character ${position}.` + ); + } + return result.value; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`[SqlParser] Failed to parse DROP CONSTRAINT statement ${statementIndex}: ${message}`); + } + } + private static parseMergeStatement(segment: StatementLexemeResult, statementIndex: number): MergeQuery { try { const result = MergeQueryParser.parseFromLexeme(segment.lexemes, 0); diff --git a/packages/core/src/parsers/SqlPrintTokenParser.ts b/packages/core/src/parsers/SqlPrintTokenParser.ts index 1565730..b565a22 100644 --- a/packages/core/src/parsers/SqlPrintTokenParser.ts +++ b/packages/core/src/parsers/SqlPrintTokenParser.ts @@ -39,8 +39,24 @@ import { ParameterDecorator } from "./ParameterDecorator"; import { InsertQuery } from "../models/InsertQuery"; import { UpdateQuery } from "../models/UpdateQuery"; import { DeleteQuery } from "../models/DeleteQuery"; -import { CreateTableQuery } from "../models/CreateTableQuery"; +import { + CreateTableQuery, + TableColumnDefinition, + ColumnConstraintDefinition, + TableConstraintDefinition, + ReferenceDefinition +} from "../models/CreateTableQuery"; import { MergeQuery, MergeWhenClause, MergeUpdateAction, MergeDeleteAction, MergeInsertAction, MergeDoNothingAction, MergeMatchType } from "../models/MergeQuery"; +import { + DropTableStatement, + DropIndexStatement, + CreateIndexStatement, + IndexColumnDefinition, + AlterTableStatement, + AlterTableAddConstraint, + AlterTableDropConstraint, + DropConstraintStatement +} from "../models/DDLStatements"; export enum ParameterStyle { Anonymous = 'anonymous', @@ -323,6 +339,18 @@ export class SqlPrintTokenParser implements SqlComponentVisitor { this.handlers.set(SetClauseItem.kind, (expr) => this.visitSetClauseItem(expr as SetClauseItem)); this.handlers.set(ReturningClause.kind, (expr) => this.visitReturningClause(expr as ReturningClause)); this.handlers.set(CreateTableQuery.kind, (expr) => this.visitCreateTableQuery(expr as CreateTableQuery)); + this.handlers.set(TableColumnDefinition.kind, (expr) => this.visitTableColumnDefinition(expr as TableColumnDefinition)); + this.handlers.set(ColumnConstraintDefinition.kind, (expr) => this.visitColumnConstraintDefinition(expr as ColumnConstraintDefinition)); + this.handlers.set(TableConstraintDefinition.kind, (expr) => this.visitTableConstraintDefinition(expr as TableConstraintDefinition)); + this.handlers.set(ReferenceDefinition.kind, (expr) => this.visitReferenceDefinition(expr as ReferenceDefinition)); + this.handlers.set(CreateIndexStatement.kind, (expr) => this.visitCreateIndexStatement(expr as CreateIndexStatement)); + this.handlers.set(IndexColumnDefinition.kind, (expr) => this.visitIndexColumnDefinition(expr as IndexColumnDefinition)); + this.handlers.set(DropTableStatement.kind, (expr) => this.visitDropTableStatement(expr as DropTableStatement)); + this.handlers.set(DropIndexStatement.kind, (expr) => this.visitDropIndexStatement(expr as DropIndexStatement)); + this.handlers.set(AlterTableStatement.kind, (expr) => this.visitAlterTableStatement(expr as AlterTableStatement)); + this.handlers.set(AlterTableAddConstraint.kind, (expr) => this.visitAlterTableAddConstraint(expr as AlterTableAddConstraint)); + this.handlers.set(AlterTableDropConstraint.kind, (expr) => this.visitAlterTableDropConstraint(expr as AlterTableDropConstraint)); + this.handlers.set(DropConstraintStatement.kind, (expr) => this.visitDropConstraintStatement(expr as DropConstraintStatement)); this.handlers.set(MergeQuery.kind, (expr) => this.visitMergeQuery(expr as MergeQuery)); this.handlers.set(MergeWhenClause.kind, (expr) => this.visitMergeWhenClause(expr as MergeWhenClause)); this.handlers.set(MergeUpdateAction.kind, (expr) => this.visitMergeUpdateAction(expr as MergeUpdateAction)); @@ -2624,11 +2652,32 @@ export class SqlPrintTokenParser implements SqlComponentVisitor { public visitCreateTableQuery(arg: CreateTableQuery): SqlPrintToken { const baseKeyword = arg.isTemporary ? 'create temporary table' : 'create table'; - const keywordText = arg.ifNotExists ? `${baseKeyword} if not exists` : baseKeyword; + let keywordText = arg.ifNotExists ? `${baseKeyword} if not exists` : baseKeyword; const token = new SqlPrintToken(SqlPrintTokenType.keyword, keywordText, SqlPrintTokenContainerType.CreateTableQuery); token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); - token.innerTokens.push(arg.tableName.accept(this)); + const qualifiedName = new QualifiedName(arg.namespaces ?? null, arg.tableName); + token.innerTokens.push(qualifiedName.accept(this)); + + const definitionEntries: SqlComponent[] = [...arg.columns, ...arg.tableConstraints]; + if (definitionEntries.length > 0) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(SqlPrintTokenParser.PAREN_OPEN_TOKEN); + const definitionToken = new SqlPrintToken(SqlPrintTokenType.container, '', SqlPrintTokenContainerType.CreateTableDefinition); + for (let i = 0; i < definitionEntries.length; i++) { + if (i > 0) { + definitionToken.innerTokens.push(...SqlPrintTokenParser.commaSpaceTokens()); + } + definitionToken.innerTokens.push(definitionEntries[i].accept(this)); + } + token.innerTokens.push(definitionToken); + token.innerTokens.push(SqlPrintTokenParser.PAREN_CLOSE_TOKEN); + } + + if (arg.tableOptions) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.tableOptions.accept(this)); + } if (arg.asSelectQuery) { token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); @@ -2639,5 +2688,454 @@ export class SqlPrintTokenParser implements SqlComponentVisitor { return token; } -} + private visitTableColumnDefinition(arg: TableColumnDefinition): SqlPrintToken { + const token = new SqlPrintToken(SqlPrintTokenType.container, '', SqlPrintTokenContainerType.TableColumnDefinition); + token.innerTokens.push(arg.name.accept(this)); + + if (arg.dataType) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.dataType.accept(this)); + } + + for (const constraint of arg.constraints) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(constraint.accept(this)); + } + + return token; + } + + private visitColumnConstraintDefinition(arg: ColumnConstraintDefinition): SqlPrintToken { + const token = new SqlPrintToken(SqlPrintTokenType.container, '', SqlPrintTokenContainerType.ColumnConstraintDefinition); + + if (arg.constraintName) { + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'constraint')); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.constraintName.accept(this)); + } + + const appendKeyword = (text: string) => { + if (token.innerTokens.length > 0) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + } + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, text)); + }; + + const appendComponent = (component: SqlComponent) => { + if (token.innerTokens.length > 0) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + } + token.innerTokens.push(component.accept(this)); + }; + + switch (arg.kind) { + case 'not-null': + appendKeyword('not null'); + break; + case 'null': + appendKeyword('null'); + break; + case 'default': + appendKeyword('default'); + if (arg.defaultValue) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.defaultValue.accept(this)); + } + break; + case 'primary-key': + appendKeyword('primary key'); + break; + case 'unique': + appendKeyword('unique'); + break; + case 'references': + if (arg.reference) { + appendComponent(arg.reference); + } + break; + case 'check': + if (arg.checkExpression) { + appendKeyword('check'); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(SqlPrintTokenParser.PAREN_OPEN_TOKEN); + token.innerTokens.push(this.visit(arg.checkExpression)); + token.innerTokens.push(SqlPrintTokenParser.PAREN_CLOSE_TOKEN); + } + break; + case 'generated-always-identity': + case 'generated-by-default-identity': + case 'raw': + if (arg.rawClause) { + appendComponent(arg.rawClause); + } + break; + } + + return token; + } + + private visitTableConstraintDefinition(arg: TableConstraintDefinition): SqlPrintToken { + const token = new SqlPrintToken(SqlPrintTokenType.container, '', SqlPrintTokenContainerType.TableConstraintDefinition); + + const appendKeyword = (text: string) => { + if (token.innerTokens.length > 0) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + } + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, text)); + }; + + const appendComponent = (component: SqlComponent) => { + if (token.innerTokens.length > 0) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + } + token.innerTokens.push(component.accept(this)); + }; + + const appendColumns = (columns: IdentifierString[] | null) => { + if (!columns || columns.length === 0) { + return; + } + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(SqlPrintTokenParser.PAREN_OPEN_TOKEN); + const listToken = new SqlPrintToken(SqlPrintTokenType.container, '', SqlPrintTokenContainerType.ValueList); + for (let i = 0; i < columns.length; i++) { + if (i > 0) { + listToken.innerTokens.push(...SqlPrintTokenParser.commaSpaceTokens()); + } + listToken.innerTokens.push(columns[i].accept(this)); + } + token.innerTokens.push(listToken); + token.innerTokens.push(SqlPrintTokenParser.PAREN_CLOSE_TOKEN); + }; + + if (arg.constraintName) { + appendKeyword('constraint'); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.constraintName.accept(this)); + } + + switch (arg.kind) { + case 'primary-key': + appendKeyword('primary key'); + appendColumns(arg.columns ?? []); + break; + case 'unique': + appendKeyword('unique'); + appendColumns(arg.columns ?? []); + break; + case 'foreign-key': + appendKeyword('foreign key'); + appendColumns(arg.columns ?? []); + if (arg.reference) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.reference.accept(this)); + } + break; + case 'check': + if (arg.checkExpression) { + appendKeyword('check'); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(SqlPrintTokenParser.PAREN_OPEN_TOKEN); + token.innerTokens.push(this.visit(arg.checkExpression)); + token.innerTokens.push(SqlPrintTokenParser.PAREN_CLOSE_TOKEN); + } + break; + case 'raw': + if (arg.rawClause) { + appendComponent(arg.rawClause); + } + break; + } + + return token; + } + + private visitReferenceDefinition(arg: ReferenceDefinition): SqlPrintToken { + const token = new SqlPrintToken(SqlPrintTokenType.container, '', SqlPrintTokenContainerType.ReferenceDefinition); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'references')); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.targetTable.accept(this)); + + if (arg.columns && arg.columns.length > 0) { + token.innerTokens.push(SqlPrintTokenParser.PAREN_OPEN_TOKEN); + const columnList = new SqlPrintToken(SqlPrintTokenType.container, '', SqlPrintTokenContainerType.ValueList); + for (let i = 0; i < arg.columns.length; i++) { + if (i > 0) { + columnList.innerTokens.push(...SqlPrintTokenParser.commaSpaceTokens()); + } + columnList.innerTokens.push(arg.columns[i].accept(this)); + } + token.innerTokens.push(columnList); + token.innerTokens.push(SqlPrintTokenParser.PAREN_CLOSE_TOKEN); + } + + if (arg.matchType) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, `match ${arg.matchType}`)); + } + if (arg.onDelete) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'on delete')); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, arg.onDelete)); + } + if (arg.onUpdate) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'on update')); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, arg.onUpdate)); + } + if (arg.deferrable === 'deferrable') { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'deferrable')); + } else if (arg.deferrable === 'not deferrable') { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'not deferrable')); + } + if (arg.initially === 'immediate') { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'initially immediate')); + } else if (arg.initially === 'deferred') { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'initially deferred')); + } + + return token; + } + + private visitCreateIndexStatement(arg: CreateIndexStatement): SqlPrintToken { + const keywordParts = ['create']; + if (arg.unique) { + keywordParts.push('unique'); + } + keywordParts.push('index'); + if (arg.concurrently) { + keywordParts.push('concurrently'); + } + if (arg.ifNotExists) { + keywordParts.push('if not exists'); + } + const token = new SqlPrintToken(SqlPrintTokenType.keyword, keywordParts.join(' '), SqlPrintTokenContainerType.CreateIndexStatement); + + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.indexName.accept(this)); + + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'on')); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.tableName.accept(this)); + + if (arg.usingMethod) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'using')); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.usingMethod.accept(this)); + } + + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(SqlPrintTokenParser.PAREN_OPEN_TOKEN); + const columnList = new SqlPrintToken(SqlPrintTokenType.container, '', SqlPrintTokenContainerType.IndexColumnList); + for (let i = 0; i < arg.columns.length; i++) { + if (i > 0) { + columnList.innerTokens.push(...SqlPrintTokenParser.commaSpaceTokens()); + } + columnList.innerTokens.push(arg.columns[i].accept(this)); + } + token.innerTokens.push(columnList); + token.innerTokens.push(SqlPrintTokenParser.PAREN_CLOSE_TOKEN); + + if (arg.include && arg.include.length > 0) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'include')); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(SqlPrintTokenParser.PAREN_OPEN_TOKEN); + const includeList = new SqlPrintToken(SqlPrintTokenType.container, '', SqlPrintTokenContainerType.ValueList); + for (let i = 0; i < arg.include.length; i++) { + if (i > 0) { + includeList.innerTokens.push(...SqlPrintTokenParser.commaSpaceTokens()); + } + includeList.innerTokens.push(arg.include[i].accept(this)); + } + token.innerTokens.push(includeList); + token.innerTokens.push(SqlPrintTokenParser.PAREN_CLOSE_TOKEN); + } + + if (arg.withOptions) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.withOptions.accept(this)); + } + + if (arg.tablespace) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'tablespace')); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.tablespace.accept(this)); + } + + if (arg.where) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'where')); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(this.visit(arg.where)); + } + + return token; + } + + private visitIndexColumnDefinition(arg: IndexColumnDefinition): SqlPrintToken { + const token = new SqlPrintToken(SqlPrintTokenType.container, '', SqlPrintTokenContainerType.IndexColumnDefinition); + token.innerTokens.push(this.visit(arg.expression)); + + if (arg.collation) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'collate')); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.collation.accept(this)); + } + + if (arg.operatorClass) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.operatorClass.accept(this)); + } + + if (arg.sortOrder) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, arg.sortOrder)); + } + + if (arg.nullsOrder) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, `nulls ${arg.nullsOrder}`)); + } + + return token; + } + + private visitDropTableStatement(arg: DropTableStatement): SqlPrintToken { + const keyword = arg.ifExists ? 'drop table if exists' : 'drop table'; + const token = new SqlPrintToken(SqlPrintTokenType.keyword, keyword, SqlPrintTokenContainerType.DropTableStatement); + + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + const tableList = new SqlPrintToken(SqlPrintTokenType.container, '', SqlPrintTokenContainerType.ValueList); + for (let i = 0; i < arg.tables.length; i++) { + if (i > 0) { + tableList.innerTokens.push(...SqlPrintTokenParser.commaSpaceTokens()); + } + tableList.innerTokens.push(arg.tables[i].accept(this)); + } + token.innerTokens.push(tableList); + + if (arg.behavior) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, arg.behavior)); + } + + return token; + } + + private visitDropIndexStatement(arg: DropIndexStatement): SqlPrintToken { + const keywordParts = ['drop', 'index']; + if (arg.concurrently) { + keywordParts.push('concurrently'); + } + if (arg.ifExists) { + keywordParts.push('if exists'); + } + const token = new SqlPrintToken(SqlPrintTokenType.keyword, keywordParts.join(' '), SqlPrintTokenContainerType.DropIndexStatement); + + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + const indexList = new SqlPrintToken(SqlPrintTokenType.container, '', SqlPrintTokenContainerType.ValueList); + for (let i = 0; i < arg.indexNames.length; i++) { + if (i > 0) { + indexList.innerTokens.push(...SqlPrintTokenParser.commaSpaceTokens()); + } + indexList.innerTokens.push(arg.indexNames[i].accept(this)); + } + token.innerTokens.push(indexList); + + if (arg.behavior) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, arg.behavior)); + } + + return token; + } + + private visitAlterTableStatement(arg: AlterTableStatement): SqlPrintToken { + const keywordParts = ['alter', 'table']; + if (arg.ifExists) { + keywordParts.push('if exists'); + } + if (arg.only) { + keywordParts.push('only'); + } + const token = new SqlPrintToken(SqlPrintTokenType.keyword, keywordParts.join(' '), SqlPrintTokenContainerType.AlterTableStatement); + + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.table.accept(this)); + + for (let i = 0; i < arg.actions.length; i++) { + if (i === 0) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + } else { + token.innerTokens.push(SqlPrintTokenParser.COMMA_TOKEN); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + } + token.innerTokens.push(arg.actions[i].accept(this)); + } + + return token; + } + + private visitAlterTableAddConstraint(arg: AlterTableAddConstraint): SqlPrintToken { + const keyword = arg.ifNotExists ? 'add if not exists' : 'add'; + const token = new SqlPrintToken(SqlPrintTokenType.container, '', SqlPrintTokenContainerType.AlterTableAddConstraint); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, keyword)); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.constraint.accept(this)); + + if (arg.notValid) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, 'not valid')); + } + + return token; + } + + private visitAlterTableDropConstraint(arg: AlterTableDropConstraint): SqlPrintToken { + let keyword = 'drop constraint'; + if (arg.ifExists) { + keyword += ' if exists'; + } + const token = new SqlPrintToken(SqlPrintTokenType.container, '', SqlPrintTokenContainerType.AlterTableDropConstraint); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, keyword)); + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.constraintName.accept(this)); + + if (arg.behavior) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, arg.behavior)); + } + + return token; + } + + private visitDropConstraintStatement(arg: DropConstraintStatement): SqlPrintToken { + let keyword = 'drop constraint'; + if (arg.ifExists) { + keyword += ' if exists'; + } + const token = new SqlPrintToken(SqlPrintTokenType.keyword, keyword, SqlPrintTokenContainerType.DropConstraintStatement); + + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(arg.constraintName.accept(this)); + + if (arg.behavior) { + token.innerTokens.push(SqlPrintTokenParser.SPACE_TOKEN); + token.innerTokens.push(new SqlPrintToken(SqlPrintTokenType.keyword, arg.behavior)); + } + + return token; + } +} diff --git a/packages/core/src/tokenReaders/CommandTokenReader.ts b/packages/core/src/tokenReaders/CommandTokenReader.ts index a56f4f1..32856f9 100644 --- a/packages/core/src/tokenReaders/CommandTokenReader.ts +++ b/packages/core/src/tokenReaders/CommandTokenReader.ts @@ -108,6 +108,7 @@ const keywordTrie = new KeywordTrie([ ["not", "matched", "by", "target"], ["update", "set"], ["if", "not", "exists"], + ["if", "exists"], ["do", "nothing"], ["insert", "default", "values"], ["values"], @@ -115,6 +116,50 @@ const keywordTrie = new KeywordTrie([ ["returning"], ["create", "table"], ["create", "temporary", "table"], + ["alter", "table"], + ["drop", "table"], + ["drop", "index"], + ["drop", "constraint"], + ["create", "index"], + ["create", "unique", "index"], + ["add"], + ["add", "constraint"], + ["constraint"], + ["primary", "key"], + ["unique"], + ["unique", "key"], + ["foreign", "key"], + ["references"], + ["check"], + ["default"], + ["not", "null"], + ["null"], + ["generated", "always"], + ["generated", "always", "as", "identity"], + ["generated", "by", "default"], + ["generated", "by", "default", "as", "identity"], + ["identity"], + ["collate"], + ["deferrable"], + ["not", "deferrable"], + ["initially", "immediate"], + ["initially", "deferred"], + ["match"], + ["match", "full"], + ["match", "partial"], + ["match", "simple"], + ["not", "valid"], + ["on", "delete"], + ["on", "update"], + ["cascade"], + ["restrict"], + ["no", "action"], + ["set", "null"], + ["set", "default"], + ["include"], + ["only"], + ["concurrently"], + ["tablespace"], ["tablesample"], // cast ["as"], diff --git a/packages/core/src/transformers/SqlPrinter.ts b/packages/core/src/transformers/SqlPrinter.ts index 04e18c3..d0bbdd3 100644 --- a/packages/core/src/transformers/SqlPrinter.ts +++ b/packages/core/src/transformers/SqlPrinter.ts @@ -165,7 +165,10 @@ export class SqlPrinter { SqlPrintTokenContainerType.CaseThenValue, SqlPrintTokenContainerType.ElseClause, SqlPrintTokenContainerType.CaseElseValue, - SqlPrintTokenContainerType.SimpleSelectQuery + SqlPrintTokenContainerType.SimpleSelectQuery, + SqlPrintTokenContainerType.CreateTableDefinition, + SqlPrintTokenContainerType.AlterTableStatement, + SqlPrintTokenContainerType.IndexColumnList // Note: CommentBlock is intentionally excluded from indentIncrementContainers // because it serves as a grouping mechanism without affecting indentation. // CaseExpression, SwitchCaseArgument, CaseKeyValuePair, and ElseClause diff --git a/packages/core/src/utils/ParserStringUtils.ts b/packages/core/src/utils/ParserStringUtils.ts new file mode 100644 index 0000000..850a0e3 --- /dev/null +++ b/packages/core/src/utils/ParserStringUtils.ts @@ -0,0 +1,33 @@ +import type { Lexeme } from "../models/Lexeme"; + +const NO_SPACE_BEFORE = new Set([",", ")", "]", "}", ";"]); +const NO_SPACE_AFTER = new Set(["(", "[", "{"]); + +/** + * Join lexeme values into a whitespace-normalized SQL fragment. + * Keeps punctuation tight while preserving spacing elsewhere. + */ +export function joinLexemeValues(lexemes: Lexeme[], start: number, end: number): string { + let result = ""; + for (let i = start; i < end; i++) { + const current = lexemes[i]; + if (!current) { + continue; + } + + if (result.length === 0) { + result = current.value; + continue; + } + + const previous = lexemes[i - 1]?.value ?? ""; + const omitSpace = + NO_SPACE_BEFORE.has(current.value) || + NO_SPACE_AFTER.has(previous) || + current.value === "." || + previous === "."; + + result += omitSpace ? current.value : ` ${current.value}`; + } + return result; +} diff --git a/packages/core/tests/parsers/CreateTableParser.test.ts b/packages/core/tests/parsers/CreateTableParser.test.ts index 7661c20..a5f8bb3 100644 --- a/packages/core/tests/parsers/CreateTableParser.test.ts +++ b/packages/core/tests/parsers/CreateTableParser.test.ts @@ -45,7 +45,7 @@ describe("CreateTableParser", () => { // Assert expect(ast.asSelectQuery).not.toBeUndefined(); expect(ast.ifNotExists).toBe(true); - expect(formatted).toContain('create table if not exists "reporting.daily" as'); + expect(formatted).toContain('create table if not exists "reporting"."daily" as'); expect(formatted).toContain('with "recent" as (select "id" from "users") select "id" from "recent"'); }); @@ -60,11 +60,40 @@ describe("CreateTableParser", () => { // Assert expect(ast.asSelectQuery).toBeUndefined(); expect(ast.ifNotExists).toBe(false); - expect(formatted).toBe('create table "archive.users"'); + expect(formatted).toBe('create table "archive"."users"'); }); - it("throws when encountering column definitions", () => { - const sql = "CREATE TABLE users (id INT)"; - expect(() => CreateTableParser.parse(sql)).toThrow(/not supported/i); + it("parses column definitions with constraints", () => { + const sql = `CREATE TABLE public.users ( + id BIGINT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + role_id INT REFERENCES auth.roles(id) ON DELETE CASCADE, + CONSTRAINT users_role_fk FOREIGN KEY (role_id) REFERENCES auth.roles(id) DEFERRABLE INITIALLY DEFERRED + ) WITH (fillfactor = 80)`; + + const ast = CreateTableParser.parse(sql); + const formatted = new SqlFormatter().format(ast).formattedSql; + + expect(ast.columns).toHaveLength(3); + expect(ast.tableConstraints).toHaveLength(1); + expect(ast.tableOptions?.value.toLowerCase().replace(/\s+/g, " ")).toBe("with (fillfactor = 80)"); + + const idColumn = ast.columns[0]; + expect(idColumn.constraints.map(c => c.kind)).toContain("primary-key"); + const emailColumn = ast.columns[1]; + expect(emailColumn.constraints.some(c => c.kind === "not-null")).toBe(true); + expect(emailColumn.constraints.some(c => c.kind === "unique")).toBe(true); + const fkConstraint = ast.tableConstraints[0]; + expect(fkConstraint.kind).toBe("foreign-key"); + expect(fkConstraint.reference?.deferrable).toBe("deferrable"); + expect(fkConstraint.reference?.initially).toBe("deferred"); + const roleColumn = ast.columns[2]; + const referenceConstraint = roleColumn.constraints.find(c => c.kind === "references"); + expect(referenceConstraint?.reference?.onDelete).toBe("cascade"); + + expect(formatted).toContain('create table "public"."users"'); + expect(formatted).toContain('foreign key ("role_id") references "auth"."roles"("id") deferrable initially deferred'); + expect(formatted).toContain('with (fillfactor = 80)'); }); }); + diff --git a/packages/core/tests/parsers/DDLParsers.test.ts b/packages/core/tests/parsers/DDLParsers.test.ts new file mode 100644 index 0000000..44d36fc --- /dev/null +++ b/packages/core/tests/parsers/DDLParsers.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { DropTableParser } from "../../src/parsers/DropTableParser"; +import { DropIndexParser } from "../../src/parsers/DropIndexParser"; +import { CreateIndexParser } from "../../src/parsers/CreateIndexParser"; +import { AlterTableParser } from "../../src/parsers/AlterTableParser"; +import { DropConstraintParser } from "../../src/parsers/DropConstraintParser"; +import { SqlFormatter } from "../../src/transformers/SqlFormatter"; +import { + CreateIndexStatement, + AlterTableStatement, + AlterTableAddConstraint, + AlterTableDropConstraint +} from "../../src/models/DDLStatements"; + + +describe("DDL Parsers", () => { + it("parses DROP TABLE with behavior", () => { + const sql = "DROP TABLE IF EXISTS public.users, audit.log CASCADE"; + const ast = DropTableParser.parse(sql); + const formatted = new SqlFormatter().format(ast).formattedSql; + + expect(ast.ifExists).toBe(true); + expect(ast.tables).toHaveLength(2); + expect(ast.behavior).toBe("cascade"); + expect(formatted).toBe('drop table if exists "public"."users", "audit"."log" cascade'); + }); + + it("parses CREATE INDEX with options", () => { + const sql = `CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email +ON public.users USING btree (lower(email) DESC NULLS LAST, created_at ASC) +INCLUDE (tenant_id) +WITH (fillfactor = 80) +TABLESPACE fastdisk +WHERE active = true`; + + const ast = CreateIndexParser.parse(sql) as CreateIndexStatement; + const formatted = new SqlFormatter().format(ast).formattedSql; + + expect(ast.unique).toBe(true); + expect(ast.concurrently).toBe(true); + expect(ast.ifNotExists).toBe(true); + expect(ast.columns).toHaveLength(2); + expect(ast.include).toHaveLength(1); + expect(ast.where).toBeTruthy(); + expect(formatted).toContain('create unique index concurrently if not exists "idx_users_email" on "public"."users" using "btree"'); + expect(formatted).toContain('include ("tenant_id")'); + expect(formatted).toContain('where "active" = true'); + }); + + it("parses DROP INDEX with modifiers", () => { + const sql = "DROP INDEX CONCURRENTLY IF EXISTS idx_users_email, idx_users_active RESTRICT"; + const ast = DropIndexParser.parse(sql); + const formatted = new SqlFormatter().format(ast).formattedSql; + + expect(ast.concurrently).toBe(true); + expect(ast.ifExists).toBe(true); + expect(ast.indexNames).toHaveLength(2); + expect(ast.behavior).toBe("restrict"); + expect(formatted).toBe('drop index concurrently if exists "idx_users_email", "idx_users_active" restrict'); + }); + + it("rejects DROP INDEX when options are out of order", () => { + const sql = "DROP INDEX IF EXISTS CONCURRENTLY idx_users_email"; + expect(() => DropIndexParser.parse(sql)).toThrow(/expected index name immediately after if exists/i); + }); + + it("parses ALTER TABLE constraint actions", () => { + const sql = `ALTER TABLE IF EXISTS ONLY public.users + ADD CONSTRAINT users_email_key UNIQUE (email), + ADD CONSTRAINT users_role_fk FOREIGN KEY (role_id) REFERENCES public.roles(id) DEFERRABLE INITIALLY DEFERRED, + DROP CONSTRAINT IF EXISTS users_old_fk CASCADE`; + + const ast = AlterTableParser.parse(sql) as AlterTableStatement; + const formatted = new SqlFormatter().format(ast).formattedSql; + + expect(ast.ifExists).toBe(true); + expect(ast.only).toBe(true); + expect(ast.actions).toHaveLength(3); + const addAction = ast.actions[0] as AlterTableAddConstraint; + expect(addAction.constraint.kind).toBe("unique"); + const dropAction = ast.actions[2] as AlterTableDropConstraint; + expect(dropAction.ifExists).toBe(true); + expect(dropAction.behavior).toBe("cascade"); + expect(formatted).toContain('alter table if exists only "public"."users" add constraint "users_email_key" unique ("email"),'); + expect(formatted).toContain('drop constraint if exists "users_old_fk" cascade'); + }); + + it("parses standalone DROP CONSTRAINT", () => { + const sql = "DROP CONSTRAINT IF EXISTS orphan_check RESTRICT"; + const ast = DropConstraintParser.parse(sql); + const formatted = new SqlFormatter().format(ast).formattedSql; + + expect(ast.ifExists).toBe(true); + expect(ast.behavior).toBe("restrict"); + expect(formatted).toBe('drop constraint if exists "orphan_check" restrict'); + }); +});