metal-orm 1.0.11 → 1.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +21 -18
  2. package/dist/decorators/index.cjs +317 -34
  3. package/dist/decorators/index.cjs.map +1 -1
  4. package/dist/decorators/index.d.cts +1 -1
  5. package/dist/decorators/index.d.ts +1 -1
  6. package/dist/decorators/index.js +317 -34
  7. package/dist/decorators/index.js.map +1 -1
  8. package/dist/index.cjs +1965 -267
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.cts +273 -23
  11. package/dist/index.d.ts +273 -23
  12. package/dist/index.js +1947 -267
  13. package/dist/index.js.map +1 -1
  14. package/dist/{select-654m4qy8.d.cts → select-CCp1oz9p.d.cts} +254 -4
  15. package/dist/{select-654m4qy8.d.ts → select-CCp1oz9p.d.ts} +254 -4
  16. package/package.json +3 -2
  17. package/src/core/ast/query.ts +40 -22
  18. package/src/core/ddl/dialects/base-schema-dialect.ts +48 -0
  19. package/src/core/ddl/dialects/index.ts +5 -0
  20. package/src/core/ddl/dialects/mssql-schema-dialect.ts +97 -0
  21. package/src/core/ddl/dialects/mysql-schema-dialect.ts +109 -0
  22. package/src/core/ddl/dialects/postgres-schema-dialect.ts +99 -0
  23. package/src/core/ddl/dialects/sqlite-schema-dialect.ts +103 -0
  24. package/src/core/ddl/introspect/mssql.ts +149 -0
  25. package/src/core/ddl/introspect/mysql.ts +99 -0
  26. package/src/core/ddl/introspect/postgres.ts +154 -0
  27. package/src/core/ddl/introspect/sqlite.ts +66 -0
  28. package/src/core/ddl/introspect/types.ts +19 -0
  29. package/src/core/ddl/introspect/utils.ts +27 -0
  30. package/src/core/ddl/schema-diff.ts +179 -0
  31. package/src/core/ddl/schema-generator.ts +229 -0
  32. package/src/core/ddl/schema-introspect.ts +32 -0
  33. package/src/core/ddl/schema-types.ts +39 -0
  34. package/src/core/dialect/abstract.ts +122 -37
  35. package/src/core/dialect/base/sql-dialect.ts +204 -0
  36. package/src/core/dialect/mssql/index.ts +125 -80
  37. package/src/core/dialect/mysql/index.ts +18 -112
  38. package/src/core/dialect/postgres/index.ts +29 -126
  39. package/src/core/dialect/sqlite/index.ts +28 -129
  40. package/src/index.ts +4 -0
  41. package/src/orm/execute.ts +25 -16
  42. package/src/orm/orm-context.ts +60 -55
  43. package/src/orm/query-logger.ts +38 -0
  44. package/src/orm/relations/belongs-to.ts +42 -26
  45. package/src/orm/relations/has-many.ts +41 -25
  46. package/src/orm/relations/many-to-many.ts +43 -27
  47. package/src/orm/unit-of-work.ts +60 -23
  48. package/src/query-builder/hydration-manager.ts +229 -25
  49. package/src/query-builder/query-ast-service.ts +27 -12
  50. package/src/query-builder/select-query-state.ts +24 -12
  51. package/src/query-builder/select.ts +58 -14
  52. package/src/schema/column.ts +206 -27
  53. package/src/schema/table.ts +89 -32
  54. package/src/schema/types.ts +8 -5
@@ -0,0 +1,32 @@
1
+ import { DialectName } from './schema-generator.js';
2
+ import { DatabaseSchema } from './schema-types.js';
3
+ import { DbExecutor } from '../../orm/db-executor.js';
4
+ import type { IntrospectOptions, SchemaIntrospector } from './introspect/types.js';
5
+ import { postgresIntrospector } from './introspect/postgres.js';
6
+ import { mysqlIntrospector } from './introspect/mysql.js';
7
+ import { sqliteIntrospector } from './introspect/sqlite.js';
8
+ import { mssqlIntrospector } from './introspect/mssql.js';
9
+
10
+ const INTROSPECTORS: Record<DialectName, SchemaIntrospector> = {
11
+ postgres: postgresIntrospector,
12
+ mysql: mysqlIntrospector,
13
+ sqlite: sqliteIntrospector,
14
+ mssql: mssqlIntrospector
15
+ };
16
+
17
+ /**
18
+ * Introspects an existing database schema using the dialect-specific strategy.
19
+ */
20
+ export const introspectSchema = async (
21
+ executor: DbExecutor,
22
+ dialect: DialectName,
23
+ options: IntrospectOptions = {}
24
+ ): Promise<DatabaseSchema> => {
25
+ const handler = INTROSPECTORS[dialect];
26
+ if (!handler) {
27
+ throw new Error(`Unsupported dialect for introspection: ${dialect}`);
28
+ }
29
+ return handler.introspect(executor, options);
30
+ };
31
+
32
+ export type { IntrospectOptions, SchemaIntrospector };
@@ -0,0 +1,39 @@
1
+ import { ForeignKeyReference } from '../../schema/column.js';
2
+ import { IndexColumn } from '../../schema/table.js';
3
+
4
+ export interface DatabaseColumn {
5
+ name: string;
6
+ type: string;
7
+ notNull?: boolean;
8
+ default?: unknown;
9
+ autoIncrement?: boolean;
10
+ generated?: 'always' | 'byDefault';
11
+ unique?: boolean | string;
12
+ references?: ForeignKeyReference;
13
+ check?: string;
14
+ }
15
+
16
+ export interface DatabaseIndex {
17
+ name: string;
18
+ columns: IndexColumn[];
19
+ unique?: boolean;
20
+ where?: string;
21
+ }
22
+
23
+ export interface DatabaseCheck {
24
+ name?: string;
25
+ expression: string;
26
+ }
27
+
28
+ export interface DatabaseTable {
29
+ name: string;
30
+ schema?: string;
31
+ columns: DatabaseColumn[];
32
+ primaryKey?: string[];
33
+ indexes?: DatabaseIndex[];
34
+ checks?: DatabaseCheck[];
35
+ }
36
+
37
+ export interface DatabaseSchema {
38
+ tables: DatabaseTable[];
39
+ }
@@ -1,4 +1,11 @@
1
- import { SelectQueryNode, InsertQueryNode, UpdateQueryNode, DeleteQueryNode } from '../ast/query.js';
1
+ import {
2
+ SelectQueryNode,
3
+ InsertQueryNode,
4
+ UpdateQueryNode,
5
+ DeleteQueryNode,
6
+ SetOperationKind,
7
+ CommonTableExpressionNode
8
+ } from '../ast/query.js';
2
9
  import {
3
10
  ExpressionNode,
4
11
  BinaryExpressionNode,
@@ -56,22 +63,23 @@ export interface DeleteCompiler {
56
63
  /**
57
64
  * Abstract base class for SQL dialect implementations
58
65
  */
59
- export abstract class Dialect
60
- implements SelectCompiler, InsertCompiler, UpdateCompiler, DeleteCompiler
61
- {
66
+ export abstract class Dialect
67
+ implements SelectCompiler, InsertCompiler, UpdateCompiler, DeleteCompiler
68
+ {
62
69
  /**
63
70
  * Compiles a SELECT query AST to SQL
64
71
  * @param ast - Query AST to compile
65
72
  * @returns Compiled query with SQL and parameters
66
73
  */
67
- compileSelect(ast: SelectQueryNode): CompiledQuery {
68
- const ctx = this.createCompilerContext();
69
- const rawSql = this.compileSelectAst(ast, ctx).trim();
70
- const sql = rawSql.endsWith(';') ? rawSql : `${rawSql};`;
71
- return {
72
- sql,
73
- params: [...ctx.params]
74
- };
74
+ compileSelect(ast: SelectQueryNode): CompiledQuery {
75
+ const ctx = this.createCompilerContext();
76
+ const normalized = this.normalizeSelectAst(ast);
77
+ const rawSql = this.compileSelectAst(normalized, ctx).trim();
78
+ const sql = rawSql.endsWith(';') ? rawSql : `${rawSql};`;
79
+ return {
80
+ sql,
81
+ params: [...ctx.params]
82
+ };
75
83
  }
76
84
 
77
85
  compileInsert(ast: InsertQueryNode): CompiledQuery {
@@ -94,15 +102,19 @@ export abstract class Dialect
94
102
  };
95
103
  }
96
104
 
97
- compileDelete(ast: DeleteQueryNode): CompiledQuery {
98
- const ctx = this.createCompilerContext();
99
- const rawSql = this.compileDeleteAst(ast, ctx).trim();
105
+ compileDelete(ast: DeleteQueryNode): CompiledQuery {
106
+ const ctx = this.createCompilerContext();
107
+ const rawSql = this.compileDeleteAst(ast, ctx).trim();
100
108
  const sql = rawSql.endsWith(';') ? rawSql : `${rawSql};`;
101
109
  return {
102
110
  sql,
103
111
  params: [...ctx.params]
104
- };
105
- }
112
+ };
113
+ }
114
+
115
+ supportsReturning(): boolean {
116
+ return false;
117
+ }
106
118
 
107
119
  /**
108
120
  * Compiles SELECT query AST to SQL (to be implemented by concrete dialects)
@@ -110,7 +122,7 @@ export abstract class Dialect
110
122
  * @param ctx - Compiler context
111
123
  * @returns SQL string
112
124
  */
113
- protected abstract compileSelectAst(ast: SelectQueryNode, ctx: CompilerContext): string;
125
+ protected abstract compileSelectAst(ast: SelectQueryNode, ctx: CompilerContext): string;
114
126
 
115
127
  protected abstract compileInsertAst(ast: InsertQueryNode, ctx: CompilerContext): string;
116
128
  protected abstract compileUpdateAst(ast: UpdateQueryNode, ctx: CompilerContext): string;
@@ -149,16 +161,23 @@ export abstract class Dialect
149
161
  * Does not add ';' at the end
150
162
  * @param ast - Query AST
151
163
  * @param ctx - Compiler context
152
- * @returns SQL for EXISTS subquery
153
- */
154
- protected compileSelectForExists(ast: SelectQueryNode, ctx: CompilerContext): string {
155
- const full = this.compileSelectAst(ast, ctx).trim().replace(/;$/, '');
156
- const upper = full.toUpperCase();
157
- const fromIndex = upper.indexOf(' FROM ');
158
- if (fromIndex === -1) {
159
- return full;
160
- }
161
-
164
+ * @returns SQL for EXISTS subquery
165
+ */
166
+ protected compileSelectForExists(ast: SelectQueryNode, ctx: CompilerContext): string {
167
+ const normalized = this.normalizeSelectAst(ast);
168
+ const full = this.compileSelectAst(normalized, ctx).trim().replace(/;$/, '');
169
+
170
+ // When the subquery is a set operation, wrap it as a derived table to keep valid syntax.
171
+ if (normalized.setOps && normalized.setOps.length > 0) {
172
+ return `SELECT 1 FROM (${full}) AS _exists`;
173
+ }
174
+
175
+ const upper = full.toUpperCase();
176
+ const fromIndex = upper.indexOf(' FROM ');
177
+ if (fromIndex === -1) {
178
+ return full;
179
+ }
180
+
162
181
  const tail = full.slice(fromIndex);
163
182
  return `SELECT 1${tail}`;
164
183
  }
@@ -185,15 +204,81 @@ export abstract class Dialect
185
204
  * @param index - Parameter index
186
205
  * @returns Formatted placeholder string
187
206
  */
188
- protected formatPlaceholder(index: number): string {
189
- return '?';
190
- }
191
-
192
- private readonly expressionCompilers: Map<string, (node: ExpressionNode, ctx: CompilerContext) => string>;
193
- private readonly operandCompilers: Map<string, (node: OperandNode, ctx: CompilerContext) => string>;
194
-
195
- protected constructor() {
196
- this.expressionCompilers = new Map();
207
+ protected formatPlaceholder(index: number): string {
208
+ return '?';
209
+ }
210
+
211
+ /**
212
+ * Whether the current dialect supports a given set operation.
213
+ * Override in concrete dialects to restrict support.
214
+ */
215
+ protected supportsSetOperation(kind: SetOperationKind): boolean {
216
+ return true;
217
+ }
218
+
219
+ /**
220
+ * Validates set-operation semantics:
221
+ * - Ensures the dialect supports requested operators.
222
+ * - Enforces that only the outermost compound query may have ORDER/LIMIT/OFFSET.
223
+ * @param ast - Query to validate
224
+ * @param isOutermost - Whether this node is the outermost compound query
225
+ */
226
+ protected validateSetOperations(ast: SelectQueryNode, isOutermost = true): void {
227
+ const hasSetOps = !!(ast.setOps && ast.setOps.length);
228
+ if (!isOutermost && (ast.orderBy || ast.limit !== undefined || ast.offset !== undefined)) {
229
+ throw new Error('ORDER BY / LIMIT / OFFSET are only allowed on the outermost compound query.');
230
+ }
231
+
232
+ if (hasSetOps) {
233
+ for (const op of ast.setOps!) {
234
+ if (!this.supportsSetOperation(op.operator)) {
235
+ throw new Error(`Set operation ${op.operator} is not supported by this dialect.`);
236
+ }
237
+ this.validateSetOperations(op.query, false);
238
+ }
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Hoists CTEs from set-operation operands to the outermost query so WITH appears once.
244
+ * @param ast - Query AST
245
+ * @returns Normalized AST without inner CTEs and a list of hoisted CTEs
246
+ */
247
+ private hoistCtes(ast: SelectQueryNode): { normalized: SelectQueryNode; hoistedCtes: CommonTableExpressionNode[] } {
248
+ let hoisted: CommonTableExpressionNode[] = [];
249
+
250
+ const normalizedSetOps = ast.setOps?.map(op => {
251
+ const { normalized: child, hoistedCtes: childHoisted } = this.hoistCtes(op.query);
252
+ const childCtes = child.ctes ?? [];
253
+ if (childCtes.length) {
254
+ hoisted = hoisted.concat(childCtes);
255
+ }
256
+ hoisted = hoisted.concat(childHoisted);
257
+ const queryWithoutCtes = childCtes.length ? { ...child, ctes: undefined } : child;
258
+ return { ...op, query: queryWithoutCtes };
259
+ });
260
+
261
+ const normalized: SelectQueryNode = normalizedSetOps ? { ...ast, setOps: normalizedSetOps } : ast;
262
+ return { normalized, hoistedCtes: hoisted };
263
+ }
264
+
265
+ /**
266
+ * Normalizes a SELECT AST before compilation (validation + CTE hoisting).
267
+ * @param ast - Query AST
268
+ * @returns Normalized query AST
269
+ */
270
+ protected normalizeSelectAst(ast: SelectQueryNode): SelectQueryNode {
271
+ this.validateSetOperations(ast, true);
272
+ const { normalized, hoistedCtes } = this.hoistCtes(ast);
273
+ const combinedCtes = [...(normalized.ctes ?? []), ...hoistedCtes];
274
+ return combinedCtes.length ? { ...normalized, ctes: combinedCtes } : normalized;
275
+ }
276
+
277
+ private readonly expressionCompilers: Map<string, (node: ExpressionNode, ctx: CompilerContext) => string>;
278
+ private readonly operandCompilers: Map<string, (node: OperandNode, ctx: CompilerContext) => string>;
279
+
280
+ protected constructor() {
281
+ this.expressionCompilers = new Map();
197
282
  this.operandCompilers = new Map();
198
283
  this.registerDefaultOperandCompilers();
199
284
  this.registerDefaultExpressionCompilers();
@@ -0,0 +1,204 @@
1
+ import { CompilerContext, Dialect } from '../abstract.js';
2
+ import { SelectQueryNode, InsertQueryNode, UpdateQueryNode, DeleteQueryNode } from '../../ast/query.js';
3
+ import { ColumnNode } from '../../ast/expression.js';
4
+
5
+ /**
6
+ * Shared SQL compiler for dialects with standard LIMIT/OFFSET pagination.
7
+ * Concrete dialects override only the minimal hooks (identifier quoting,
8
+ * JSON path, placeholders, RETURNING support) instead of re-implementing
9
+ * the entire compile pipeline.
10
+ */
11
+ export abstract class SqlDialectBase extends Dialect {
12
+ /**
13
+ * Quotes an identifier (dialect-specific).
14
+ */
15
+ abstract quoteIdentifier(id: string): string;
16
+
17
+ /**
18
+ * Compiles SELECT query AST to SQL using common rules.
19
+ */
20
+ protected compileSelectAst(ast: SelectQueryNode, ctx: CompilerContext): string {
21
+ const hasSetOps = !!(ast.setOps && ast.setOps.length);
22
+ const ctes = this.compileCtes(ast, ctx);
23
+
24
+ // When set operations exist, omit ORDER BY/OFFSET/LIMIT from the operands and apply at the end.
25
+ const baseAst: SelectQueryNode = hasSetOps
26
+ ? { ...ast, setOps: undefined, orderBy: undefined, limit: undefined, offset: undefined }
27
+ : ast;
28
+
29
+ const baseSelect = this.compileSelectCore(baseAst, ctx);
30
+
31
+ if (!hasSetOps) {
32
+ return `${ctes}${baseSelect}`;
33
+ }
34
+
35
+ const compound = ast.setOps!
36
+ .map(op => `${op.operator} ${this.wrapSetOperand(this.compileSelectAst(op.query, ctx))}`)
37
+ .join(' ');
38
+
39
+ const orderBy = this.compileOrderBy(ast);
40
+ const pagination = this.compilePagination(ast, orderBy);
41
+
42
+ const combined = `${this.wrapSetOperand(baseSelect)} ${compound}`;
43
+ return `${ctes}${combined}${orderBy}${pagination}`;
44
+ }
45
+
46
+ protected compileInsertAst(ast: InsertQueryNode, ctx: CompilerContext): string {
47
+ const table = this.compileTableName(ast.into);
48
+ const columnList = ast.columns
49
+ .map(column => `${this.quoteIdentifier(column.table)}.${this.quoteIdentifier(column.name)}`)
50
+ .join(', ');
51
+ const values = ast.values.map(row => `(${row.map(value => this.compileOperand(value, ctx)).join(', ')})`).join(', ');
52
+ const returning = this.compileReturning(ast.returning, ctx);
53
+ return `INSERT INTO ${table} (${columnList}) VALUES ${values}${returning}`;
54
+ }
55
+
56
+ /**
57
+ * Compiles a single SELECT (no set operations, no CTE prefix).
58
+ */
59
+ private compileSelectCore(ast: SelectQueryNode, ctx: CompilerContext): string {
60
+ const columns = this.compileSelectColumns(ast, ctx);
61
+ const from = this.compileFrom(ast.from);
62
+ const joins = this.compileJoins(ast, ctx);
63
+ const whereClause = this.compileWhere(ast.where, ctx);
64
+ const groupBy = this.compileGroupBy(ast);
65
+ const having = this.compileHaving(ast, ctx);
66
+ const orderBy = this.compileOrderBy(ast);
67
+ const pagination = this.compilePagination(ast, orderBy);
68
+
69
+ return `SELECT ${this.compileDistinct(ast)}${columns} FROM ${from}${joins}${whereClause}${groupBy}${having}${orderBy}${pagination}`;
70
+ }
71
+
72
+ protected compileUpdateAst(ast: UpdateQueryNode, ctx: CompilerContext): string {
73
+ const table = this.compileTableName(ast.table);
74
+ const assignments = ast.set.map(assignment => {
75
+ const col = assignment.column;
76
+ const target = `${this.quoteIdentifier(col.table)}.${this.quoteIdentifier(col.name)}`;
77
+ const value = this.compileOperand(assignment.value, ctx);
78
+ return `${target} = ${value}`;
79
+ }).join(', ');
80
+ const whereClause = this.compileWhere(ast.where, ctx);
81
+ const returning = this.compileReturning(ast.returning, ctx);
82
+ return `UPDATE ${table} SET ${assignments}${whereClause}${returning}`;
83
+ }
84
+
85
+ protected compileDeleteAst(ast: DeleteQueryNode, ctx: CompilerContext): string {
86
+ const table = this.compileTableName(ast.from);
87
+ const whereClause = this.compileWhere(ast.where, ctx);
88
+ const returning = this.compileReturning(ast.returning, ctx);
89
+ return `DELETE FROM ${table}${whereClause}${returning}`;
90
+ }
91
+
92
+ /**
93
+ * Default RETURNING compilation: no support.
94
+ */
95
+ protected compileReturning(returning: ColumnNode[] | undefined, _ctx: CompilerContext): string {
96
+ if (!returning || returning.length === 0) return '';
97
+ throw new Error('RETURNING is not supported by this dialect.');
98
+ }
99
+
100
+ protected formatReturningColumns(returning: ColumnNode[]): string {
101
+ return returning
102
+ .map(column => {
103
+ const tablePart = column.table ? `${this.quoteIdentifier(column.table)}.` : '';
104
+ const aliasPart = column.alias ? ` AS ${this.quoteIdentifier(column.alias)}` : '';
105
+ return `${tablePart}${this.quoteIdentifier(column.name)}${aliasPart}`;
106
+ })
107
+ .join(', ');
108
+ }
109
+
110
+ /**
111
+ * DISTINCT clause. Override for DISTINCT ON support.
112
+ */
113
+ protected compileDistinct(ast: SelectQueryNode): string {
114
+ return ast.distinct ? 'DISTINCT ' : '';
115
+ }
116
+
117
+ protected compileSelectColumns(ast: SelectQueryNode, ctx: CompilerContext): string {
118
+ return ast.columns.map(c => {
119
+ const expr = this.compileOperand(c, ctx);
120
+ if (c.alias) {
121
+ if (c.alias.includes('(')) return c.alias;
122
+ return `${expr} AS ${this.quoteIdentifier(c.alias)}`;
123
+ }
124
+ return expr;
125
+ }).join(', ');
126
+ }
127
+
128
+ protected compileFrom(ast: SelectQueryNode['from']): string {
129
+ const base = this.compileTableName(ast);
130
+ return ast.alias ? `${base} AS ${this.quoteIdentifier(ast.alias)}` : base;
131
+ }
132
+
133
+ protected compileTableName(table: { name: string; schema?: string }): string {
134
+ if (table.schema) {
135
+ return `${this.quoteIdentifier(table.schema)}.${this.quoteIdentifier(table.name)}`;
136
+ }
137
+ return this.quoteIdentifier(table.name);
138
+ }
139
+
140
+ protected compileJoins(ast: SelectQueryNode, ctx: CompilerContext): string {
141
+ if (!ast.joins || ast.joins.length === 0) return '';
142
+ const parts = ast.joins.map(j => {
143
+ const table = this.compileFrom(j.table);
144
+ const cond = this.compileExpression(j.condition, ctx);
145
+ return `${j.kind} JOIN ${table} ON ${cond}`;
146
+ });
147
+ return ` ${parts.join(' ')}`;
148
+ }
149
+
150
+ protected compileGroupBy(ast: SelectQueryNode): string {
151
+ if (!ast.groupBy || ast.groupBy.length === 0) return '';
152
+ const cols = ast.groupBy
153
+ .map(c => `${this.quoteIdentifier(c.table)}.${this.quoteIdentifier(c.name)}`)
154
+ .join(', ');
155
+ return ` GROUP BY ${cols}`;
156
+ }
157
+
158
+ protected compileHaving(ast: SelectQueryNode, ctx: CompilerContext): string {
159
+ if (!ast.having) return '';
160
+ return ` HAVING ${this.compileExpression(ast.having, ctx)}`;
161
+ }
162
+
163
+ protected compileOrderBy(ast: SelectQueryNode): string {
164
+ if (!ast.orderBy || ast.orderBy.length === 0) return '';
165
+ const parts = ast.orderBy
166
+ .map(o => `${this.quoteIdentifier(o.column.table)}.${this.quoteIdentifier(o.column.name)} ${o.direction}`)
167
+ .join(', ');
168
+ return ` ORDER BY ${parts}`;
169
+ }
170
+
171
+ /**
172
+ * Default LIMIT/OFFSET pagination clause.
173
+ */
174
+ protected compilePagination(ast: SelectQueryNode, _orderByClause: string): string {
175
+ const parts: string[] = [];
176
+ if (ast.limit !== undefined) parts.push(`LIMIT ${ast.limit}`);
177
+ if (ast.offset !== undefined) parts.push(`OFFSET ${ast.offset}`);
178
+ return parts.length ? ` ${parts.join(' ')}` : '';
179
+ }
180
+
181
+ protected compileCtes(ast: SelectQueryNode, ctx: CompilerContext): string {
182
+ if (!ast.ctes || ast.ctes.length === 0) return '';
183
+ const hasRecursive = ast.ctes.some(cte => cte.recursive);
184
+ const prefix = hasRecursive ? 'WITH RECURSIVE ' : 'WITH ';
185
+ const cteDefs = ast.ctes.map(cte => {
186
+ const name = this.quoteIdentifier(cte.name);
187
+ const cols = cte.columns && cte.columns.length
188
+ ? `(${cte.columns.map(c => this.quoteIdentifier(c)).join(', ')})`
189
+ : '';
190
+ const query = this.stripTrailingSemicolon(this.compileSelectAst(this.normalizeSelectAst(cte.query), ctx));
191
+ return `${name}${cols} AS (${query})`;
192
+ }).join(', ');
193
+ return `${prefix}${cteDefs} `;
194
+ }
195
+
196
+ protected stripTrailingSemicolon(sql: string): string {
197
+ return sql.trim().replace(/;$/, '');
198
+ }
199
+
200
+ protected wrapSetOperand(sql: string): string {
201
+ const trimmed = this.stripTrailingSemicolon(sql);
202
+ return `(${trimmed})`;
203
+ }
204
+ }