metal-orm 1.0.0
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.
- package/README.md +30 -0
- package/ROADMAP.md +125 -0
- package/metadata.json +5 -0
- package/package.json +45 -0
- package/playground/api/playground-api.ts +94 -0
- package/playground/index.html +15 -0
- package/playground/src/App.css +1 -0
- package/playground/src/App.tsx +114 -0
- package/playground/src/components/CodeDisplay.tsx +43 -0
- package/playground/src/components/QueryExecutor.tsx +189 -0
- package/playground/src/components/ResultsTable.tsx +67 -0
- package/playground/src/components/ResultsTabs.tsx +105 -0
- package/playground/src/components/ScenarioList.tsx +56 -0
- package/playground/src/components/logo.svg +45 -0
- package/playground/src/data/scenarios.ts +2 -0
- package/playground/src/main.tsx +9 -0
- package/playground/src/services/PlaygroundApiService.ts +60 -0
- package/postcss.config.cjs +5 -0
- package/sql_sql-ansi-cheatsheet-2025.md +264 -0
- package/src/ast/expression.ts +362 -0
- package/src/ast/join.ts +11 -0
- package/src/ast/query.ts +63 -0
- package/src/builder/hydration-manager.ts +55 -0
- package/src/builder/hydration-planner.ts +77 -0
- package/src/builder/operations/column-selector.ts +42 -0
- package/src/builder/operations/cte-manager.ts +18 -0
- package/src/builder/operations/filter-manager.ts +36 -0
- package/src/builder/operations/join-manager.ts +26 -0
- package/src/builder/operations/pagination-manager.ts +17 -0
- package/src/builder/operations/relation-manager.ts +49 -0
- package/src/builder/query-ast-service.ts +155 -0
- package/src/builder/relation-conditions.ts +39 -0
- package/src/builder/relation-projection-helper.ts +59 -0
- package/src/builder/relation-service.ts +166 -0
- package/src/builder/select-query-builder-deps.ts +33 -0
- package/src/builder/select-query-state.ts +107 -0
- package/src/builder/select.ts +237 -0
- package/src/codegen/typescript.ts +295 -0
- package/src/constants/sql.ts +57 -0
- package/src/dialect/abstract.ts +221 -0
- package/src/dialect/mssql/index.ts +89 -0
- package/src/dialect/mysql/index.ts +81 -0
- package/src/dialect/sqlite/index.ts +85 -0
- package/src/index.ts +12 -0
- package/src/playground/features/playground/api/types.ts +16 -0
- package/src/playground/features/playground/clients/MockClient.ts +17 -0
- package/src/playground/features/playground/clients/SqliteClient.ts +57 -0
- package/src/playground/features/playground/common/IDatabaseClient.ts +10 -0
- package/src/playground/features/playground/data/scenarios/aggregation.ts +36 -0
- package/src/playground/features/playground/data/scenarios/basics.ts +25 -0
- package/src/playground/features/playground/data/scenarios/edge_cases.ts +57 -0
- package/src/playground/features/playground/data/scenarios/filtering.ts +94 -0
- package/src/playground/features/playground/data/scenarios/hydration.ts +15 -0
- package/src/playground/features/playground/data/scenarios/index.ts +29 -0
- package/src/playground/features/playground/data/scenarios/ordering.ts +25 -0
- package/src/playground/features/playground/data/scenarios/pagination.ts +16 -0
- package/src/playground/features/playground/data/scenarios/relationships.ts +75 -0
- package/src/playground/features/playground/data/scenarios/types.ts +67 -0
- package/src/playground/features/playground/data/schema.ts +87 -0
- package/src/playground/features/playground/data/seed.ts +104 -0
- package/src/playground/features/playground/services/QueryExecutionService.ts +120 -0
- package/src/runtime/als.ts +19 -0
- package/src/runtime/hydration.ts +43 -0
- package/src/schema/column.ts +19 -0
- package/src/schema/relation.ts +38 -0
- package/src/schema/table.ts +22 -0
- package/tests/between.test.ts +43 -0
- package/tests/case-expression.test.ts +58 -0
- package/tests/complex-exists.test.ts +230 -0
- package/tests/cte.test.ts +118 -0
- package/tests/exists.test.ts +127 -0
- package/tests/like.test.ts +33 -0
- package/tests/right-join.test.ts +89 -0
- package/tests/subquery-having.test.ts +193 -0
- package/tests/window-function.test.ts +137 -0
- package/tsconfig.json +30 -0
- package/tsup.config.ts +10 -0
- package/vite.config.ts +22 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { SelectQueryNode } from '../ast/query';
|
|
2
|
+
import {
|
|
3
|
+
ExpressionNode,
|
|
4
|
+
BinaryExpressionNode,
|
|
5
|
+
LogicalExpressionNode,
|
|
6
|
+
NullExpressionNode,
|
|
7
|
+
InExpressionNode,
|
|
8
|
+
ExistsExpressionNode,
|
|
9
|
+
LiteralNode,
|
|
10
|
+
ColumnNode,
|
|
11
|
+
OperandNode,
|
|
12
|
+
FunctionNode,
|
|
13
|
+
JsonPathNode,
|
|
14
|
+
ScalarSubqueryNode,
|
|
15
|
+
CaseExpressionNode,
|
|
16
|
+
WindowFunctionNode,
|
|
17
|
+
BetweenExpressionNode
|
|
18
|
+
} from '../ast/expression';
|
|
19
|
+
|
|
20
|
+
export interface CompilerContext {
|
|
21
|
+
params: unknown[];
|
|
22
|
+
addParameter(value: unknown): string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CompiledQuery {
|
|
26
|
+
sql: string;
|
|
27
|
+
params: unknown[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export abstract class Dialect {
|
|
31
|
+
compileSelect(ast: SelectQueryNode): CompiledQuery {
|
|
32
|
+
const ctx = this.createCompilerContext();
|
|
33
|
+
const rawSql = this.compileSelectAst(ast, ctx).trim();
|
|
34
|
+
const sql = rawSql.endsWith(';') ? rawSql : `${rawSql};`;
|
|
35
|
+
return {
|
|
36
|
+
sql,
|
|
37
|
+
params: [...ctx.params]
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
protected abstract compileSelectAst(ast: SelectQueryNode, ctx: CompilerContext): string;
|
|
42
|
+
|
|
43
|
+
abstract quoteIdentifier(id: string): string;
|
|
44
|
+
|
|
45
|
+
protected compileWhere(where: ExpressionNode | undefined, ctx: CompilerContext): string {
|
|
46
|
+
if (!where) return '';
|
|
47
|
+
return ` WHERE ${this.compileExpression(where, ctx)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Gera a subquery para EXISTS.
|
|
52
|
+
* Regra: sempre força SELECT 1, ignorando a lista de colunas.
|
|
53
|
+
* Mantém FROM, JOINs, WHERE, GROUP BY, ORDER BY, LIMIT/OFFSET.
|
|
54
|
+
* Não adiciona ';' no final.
|
|
55
|
+
*/
|
|
56
|
+
protected compileSelectForExists(ast: SelectQueryNode, ctx: CompilerContext): string {
|
|
57
|
+
const full = this.compileSelectAst(ast, ctx).trim().replace(/;$/, '');
|
|
58
|
+
const upper = full.toUpperCase();
|
|
59
|
+
const fromIndex = upper.indexOf(' FROM ');
|
|
60
|
+
if (fromIndex === -1) {
|
|
61
|
+
return full;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const tail = full.slice(fromIndex);
|
|
65
|
+
return `SELECT 1${tail}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
protected createCompilerContext(): CompilerContext {
|
|
69
|
+
const params: unknown[] = [];
|
|
70
|
+
let counter = 0;
|
|
71
|
+
return {
|
|
72
|
+
params,
|
|
73
|
+
addParameter: (value: unknown) => {
|
|
74
|
+
counter += 1;
|
|
75
|
+
params.push(value);
|
|
76
|
+
return this.formatPlaceholder(counter);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
protected formatPlaceholder(index: number): string {
|
|
82
|
+
return '?';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private readonly expressionCompilers: Map<string, (node: ExpressionNode, ctx: CompilerContext) => string>;
|
|
86
|
+
private readonly operandCompilers: Map<string, (node: OperandNode, ctx: CompilerContext) => string>;
|
|
87
|
+
|
|
88
|
+
protected constructor() {
|
|
89
|
+
this.expressionCompilers = new Map();
|
|
90
|
+
this.operandCompilers = new Map();
|
|
91
|
+
this.registerDefaultOperandCompilers();
|
|
92
|
+
this.registerDefaultExpressionCompilers();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
protected registerExpressionCompiler<T extends ExpressionNode>(type: T['type'], compiler: (node: T, ctx: CompilerContext) => string): void {
|
|
96
|
+
this.expressionCompilers.set(type, compiler as (node: ExpressionNode, ctx: CompilerContext) => string);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
protected registerOperandCompiler<T extends OperandNode>(type: T['type'], compiler: (node: T, ctx: CompilerContext) => string): void {
|
|
100
|
+
this.operandCompilers.set(type, compiler as (node: OperandNode, ctx: CompilerContext) => string);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
protected compileExpression(node: ExpressionNode, ctx: CompilerContext): string {
|
|
104
|
+
const compiler = this.expressionCompilers.get(node.type);
|
|
105
|
+
return compiler ? compiler(node, ctx) : '';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
protected compileOperand(node: OperandNode, ctx: CompilerContext): string {
|
|
109
|
+
const compiler = this.operandCompilers.get(node.type);
|
|
110
|
+
return compiler ? compiler(node, ctx) : '';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private registerDefaultExpressionCompilers(): void {
|
|
114
|
+
this.registerExpressionCompiler('BinaryExpression', (binary: BinaryExpressionNode, ctx) => {
|
|
115
|
+
const left = this.compileOperand(binary.left, ctx);
|
|
116
|
+
const right = this.compileOperand(binary.right, ctx);
|
|
117
|
+
const base = `${left} ${binary.operator} ${right}`;
|
|
118
|
+
if (binary.escape) {
|
|
119
|
+
const escapeOperand = this.compileOperand(binary.escape, ctx);
|
|
120
|
+
return `${base} ESCAPE ${escapeOperand}`;
|
|
121
|
+
}
|
|
122
|
+
return base;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
this.registerExpressionCompiler('LogicalExpression', (logical: LogicalExpressionNode, ctx) => {
|
|
126
|
+
if (logical.operands.length === 0) return '';
|
|
127
|
+
const parts = logical.operands.map(op => {
|
|
128
|
+
const compiled = this.compileExpression(op, ctx);
|
|
129
|
+
return op.type === 'LogicalExpression' ? `(${compiled})` : compiled;
|
|
130
|
+
});
|
|
131
|
+
return parts.join(` ${logical.operator} `);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
this.registerExpressionCompiler('NullExpression', (nullExpr: NullExpressionNode, ctx) => {
|
|
135
|
+
const left = this.compileOperand(nullExpr.left, ctx);
|
|
136
|
+
return `${left} ${nullExpr.operator}`;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
this.registerExpressionCompiler('InExpression', (inExpr: InExpressionNode, ctx) => {
|
|
140
|
+
const left = this.compileOperand(inExpr.left, ctx);
|
|
141
|
+
const values = inExpr.right.map(v => this.compileOperand(v, ctx)).join(', ');
|
|
142
|
+
return `${left} ${inExpr.operator} (${values})`;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
this.registerExpressionCompiler('ExistsExpression', (existsExpr: ExistsExpressionNode, ctx) => {
|
|
146
|
+
const subquerySql = this.compileSelectForExists(existsExpr.subquery, ctx);
|
|
147
|
+
return `${existsExpr.operator} (${subquerySql})`;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
this.registerExpressionCompiler('BetweenExpression', (betweenExpr: BetweenExpressionNode, ctx) => {
|
|
151
|
+
const left = this.compileOperand(betweenExpr.left, ctx);
|
|
152
|
+
const lower = this.compileOperand(betweenExpr.lower, ctx);
|
|
153
|
+
const upper = this.compileOperand(betweenExpr.upper, ctx);
|
|
154
|
+
return `${left} ${betweenExpr.operator} ${lower} AND ${upper}`;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private registerDefaultOperandCompilers(): void {
|
|
159
|
+
this.registerOperandCompiler('Literal', (literal: LiteralNode, ctx) => ctx.addParameter(literal.value));
|
|
160
|
+
|
|
161
|
+
this.registerOperandCompiler('Column', (column: ColumnNode, _ctx) => {
|
|
162
|
+
return `${this.quoteIdentifier(column.table)}.${this.quoteIdentifier(column.name)}`;
|
|
163
|
+
});
|
|
164
|
+
this.registerOperandCompiler('Function', (fnNode: FunctionNode, ctx) => {
|
|
165
|
+
const args = fnNode.args.map(arg => this.compileOperand(arg, ctx)).join(', ');
|
|
166
|
+
return `${fnNode.name}(${args})`;
|
|
167
|
+
});
|
|
168
|
+
this.registerOperandCompiler('JsonPath', (path: JsonPathNode, _ctx) => this.compileJsonPath(path));
|
|
169
|
+
|
|
170
|
+
this.registerOperandCompiler('ScalarSubquery', (node: ScalarSubqueryNode, ctx) => {
|
|
171
|
+
const sql = this.compileSelectAst(node.query, ctx).trim().replace(/;$/, '');
|
|
172
|
+
return `(${sql})`;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
this.registerOperandCompiler('CaseExpression', (node: CaseExpressionNode, ctx) => {
|
|
176
|
+
const parts = ['CASE'];
|
|
177
|
+
for (const { when, then } of node.conditions) {
|
|
178
|
+
parts.push(`WHEN ${this.compileExpression(when, ctx)} THEN ${this.compileOperand(then, ctx)}`);
|
|
179
|
+
}
|
|
180
|
+
if (node.else) {
|
|
181
|
+
parts.push(`ELSE ${this.compileOperand(node.else, ctx)}`);
|
|
182
|
+
}
|
|
183
|
+
parts.push('END');
|
|
184
|
+
return parts.join(' ');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
this.registerOperandCompiler('WindowFunction', (node: WindowFunctionNode, ctx) => {
|
|
188
|
+
let result = `${node.name}(`;
|
|
189
|
+
if (node.args.length > 0) {
|
|
190
|
+
result += node.args.map(arg => this.compileOperand(arg, ctx)).join(', ');
|
|
191
|
+
}
|
|
192
|
+
result += ') OVER (';
|
|
193
|
+
|
|
194
|
+
const parts: string[] = [];
|
|
195
|
+
|
|
196
|
+
if (node.partitionBy && node.partitionBy.length > 0) {
|
|
197
|
+
const partitionClause = 'PARTITION BY ' + node.partitionBy.map(col =>
|
|
198
|
+
`${this.quoteIdentifier(col.table)}.${this.quoteIdentifier(col.name)}`
|
|
199
|
+
).join(', ');
|
|
200
|
+
parts.push(partitionClause);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (node.orderBy && node.orderBy.length > 0) {
|
|
204
|
+
const orderClause = 'ORDER BY ' + node.orderBy.map(o =>
|
|
205
|
+
`${this.quoteIdentifier(o.column.table)}.${this.quoteIdentifier(o.column.name)} ${o.direction}`
|
|
206
|
+
).join(', ');
|
|
207
|
+
parts.push(orderClause);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
result += parts.join(' ');
|
|
211
|
+
result += ')';
|
|
212
|
+
|
|
213
|
+
return result;
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Default fallback, should be overridden by dialects if supported
|
|
218
|
+
protected compileJsonPath(node: JsonPathNode): string {
|
|
219
|
+
throw new Error("JSON Path not supported by this dialect");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { CompilerContext, Dialect } from '../abstract';
|
|
2
|
+
import { SelectQueryNode } from '../../ast/query';
|
|
3
|
+
import { JsonPathNode } from '../../ast/expression';
|
|
4
|
+
|
|
5
|
+
export class SqlServerDialect extends Dialect {
|
|
6
|
+
public constructor() {
|
|
7
|
+
super();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
quoteIdentifier(id: string): string {
|
|
11
|
+
return `[${id}]`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
protected compileJsonPath(node: JsonPathNode): string {
|
|
15
|
+
const col = `${this.quoteIdentifier(node.column.table)}.${this.quoteIdentifier(node.column.name)}`;
|
|
16
|
+
// SQL Server uses JSON_VALUE(col, '$.path')
|
|
17
|
+
return `JSON_VALUE(${col}, '${node.path}')`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
protected formatPlaceholder(index: number): string {
|
|
21
|
+
return `@p${index}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
protected compileSelectAst(ast: SelectQueryNode, ctx: CompilerContext): string {
|
|
25
|
+
const columns = ast.columns.map(c => {
|
|
26
|
+
let expr = '';
|
|
27
|
+
if (c.type === 'Function') {
|
|
28
|
+
expr = this.compileOperand(c, ctx);
|
|
29
|
+
} else if (c.type === 'Column') {
|
|
30
|
+
expr = `${this.quoteIdentifier(c.table)}.${this.quoteIdentifier(c.name)}`;
|
|
31
|
+
} else if (c.type === 'ScalarSubquery') {
|
|
32
|
+
expr = this.compileOperand(c, ctx);
|
|
33
|
+
} else if (c.type === 'WindowFunction') {
|
|
34
|
+
expr = this.compileOperand(c, ctx);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (c.alias) {
|
|
38
|
+
if (c.alias.includes('(')) return c.alias;
|
|
39
|
+
return `${expr} AS ${this.quoteIdentifier(c.alias)}`;
|
|
40
|
+
}
|
|
41
|
+
return expr;
|
|
42
|
+
}).join(', ');
|
|
43
|
+
|
|
44
|
+
const distinct = ast.distinct ? 'DISTINCT ' : '';
|
|
45
|
+
const from = `${this.quoteIdentifier(ast.from.name)}`;
|
|
46
|
+
|
|
47
|
+
const joins = ast.joins.map(j => {
|
|
48
|
+
const table = this.quoteIdentifier(j.table.name);
|
|
49
|
+
const cond = this.compileExpression(j.condition, ctx);
|
|
50
|
+
return `${j.kind} JOIN ${table} ON ${cond}`;
|
|
51
|
+
}).join(' ');
|
|
52
|
+
const whereClause = this.compileWhere(ast.where, ctx);
|
|
53
|
+
|
|
54
|
+
const groupBy = ast.groupBy && ast.groupBy.length > 0
|
|
55
|
+
? ' GROUP BY ' + ast.groupBy.map(c => `${this.quoteIdentifier(c.table)}.${this.quoteIdentifier(c.name)}`).join(', ')
|
|
56
|
+
: '';
|
|
57
|
+
|
|
58
|
+
const having = ast.having
|
|
59
|
+
? ` HAVING ${this.compileExpression(ast.having, ctx)}`
|
|
60
|
+
: '';
|
|
61
|
+
|
|
62
|
+
const orderBy = ast.orderBy && ast.orderBy.length > 0
|
|
63
|
+
? ' ORDER BY ' + ast.orderBy.map(o => `${this.quoteIdentifier(o.column.table)}.${this.quoteIdentifier(o.column.name)} ${o.direction}`).join(', ')
|
|
64
|
+
: '';
|
|
65
|
+
|
|
66
|
+
let pagination = '';
|
|
67
|
+
if (ast.limit || ast.offset) {
|
|
68
|
+
const off = ast.offset || 0;
|
|
69
|
+
const orderClause = orderBy || ' ORDER BY (SELECT NULL)';
|
|
70
|
+
pagination = `${orderClause} OFFSET ${off} ROWS`;
|
|
71
|
+
if (ast.limit) {
|
|
72
|
+
pagination += ` FETCH NEXT ${ast.limit} ROWS ONLY`;
|
|
73
|
+
}
|
|
74
|
+
return `SELECT ${distinct}${columns} FROM ${from}${joins ? ' ' + joins : ''}${whereClause}${groupBy}${having}${pagination};`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const ctes = ast.ctes && ast.ctes.length > 0
|
|
78
|
+
? 'WITH ' + ast.ctes.map(cte => {
|
|
79
|
+
// MSSQL does not use RECURSIVE keyword
|
|
80
|
+
const name = this.quoteIdentifier(cte.name);
|
|
81
|
+
const cols = cte.columns ? `(${cte.columns.map(c => this.quoteIdentifier(c)).join(', ')})` : '';
|
|
82
|
+
const query = this.compileSelectAst(cte.query, ctx).trim().replace(/;$/, '');
|
|
83
|
+
return `${name}${cols} AS (${query})`;
|
|
84
|
+
}).join(', ') + ' '
|
|
85
|
+
: '';
|
|
86
|
+
|
|
87
|
+
return `${ctes}SELECT ${distinct}${columns} FROM ${from}${joins ? ' ' + joins : ''}${whereClause}${groupBy}${having}${orderBy};`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { CompilerContext, Dialect } from '../abstract';
|
|
2
|
+
import { SelectQueryNode } from '../../ast/query';
|
|
3
|
+
import { JsonPathNode } from '../../ast/expression';
|
|
4
|
+
|
|
5
|
+
export class MySqlDialect extends Dialect {
|
|
6
|
+
public constructor() {
|
|
7
|
+
super();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
quoteIdentifier(id: string): string {
|
|
11
|
+
return `\`${id}\``;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
protected compileJsonPath(node: JsonPathNode): string {
|
|
15
|
+
const col = `${this.quoteIdentifier(node.column.table)}.${this.quoteIdentifier(node.column.name)}`;
|
|
16
|
+
// MySQL 5.7+ uses col->'$.path'
|
|
17
|
+
return `${col}->'${node.path}'`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
protected compileSelectAst(ast: SelectQueryNode, ctx: CompilerContext): string {
|
|
21
|
+
const columns = ast.columns.map(c => {
|
|
22
|
+
let expr = '';
|
|
23
|
+
if (c.type === 'Function') {
|
|
24
|
+
expr = this.compileOperand(c, ctx);
|
|
25
|
+
} else if (c.type === 'Column') {
|
|
26
|
+
expr = `${this.quoteIdentifier(c.table)}.${this.quoteIdentifier(c.name)}`;
|
|
27
|
+
} else if (c.type === 'ScalarSubquery') {
|
|
28
|
+
expr = this.compileOperand(c, ctx);
|
|
29
|
+
} else if (c.type === 'WindowFunction') {
|
|
30
|
+
expr = this.compileOperand(c, ctx);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (c.alias) {
|
|
34
|
+
if (c.alias.includes('(')) return c.alias;
|
|
35
|
+
return `${expr} AS ${this.quoteIdentifier(c.alias)}`;
|
|
36
|
+
}
|
|
37
|
+
return expr;
|
|
38
|
+
}).join(', ');
|
|
39
|
+
|
|
40
|
+
const distinct = ast.distinct ? 'DISTINCT ' : '';
|
|
41
|
+
const from = `${this.quoteIdentifier(ast.from.name)}`;
|
|
42
|
+
|
|
43
|
+
const joins = ast.joins.map(j => {
|
|
44
|
+
const table = this.quoteIdentifier(j.table.name);
|
|
45
|
+
const cond = this.compileExpression(j.condition, ctx);
|
|
46
|
+
return `${j.kind} JOIN ${table} ON ${cond}`;
|
|
47
|
+
}).join(' ');
|
|
48
|
+
const whereClause = this.compileWhere(ast.where, ctx);
|
|
49
|
+
|
|
50
|
+
const groupBy = ast.groupBy && ast.groupBy.length > 0
|
|
51
|
+
? ' GROUP BY ' + ast.groupBy.map(c => `${this.quoteIdentifier(c.table)}.${this.quoteIdentifier(c.name)}`).join(', ')
|
|
52
|
+
: '';
|
|
53
|
+
|
|
54
|
+
const having = ast.having
|
|
55
|
+
? ` HAVING ${this.compileExpression(ast.having, ctx)}`
|
|
56
|
+
: '';
|
|
57
|
+
|
|
58
|
+
const orderBy = ast.orderBy && ast.orderBy.length > 0
|
|
59
|
+
? ' ORDER BY ' + ast.orderBy.map(o => `${this.quoteIdentifier(o.column.table)}.${this.quoteIdentifier(o.column.name)} ${o.direction}`).join(', ')
|
|
60
|
+
: '';
|
|
61
|
+
|
|
62
|
+
const limit = ast.limit ? ` LIMIT ${ast.limit}` : '';
|
|
63
|
+
const offset = ast.offset ? ` OFFSET ${ast.offset}` : '';
|
|
64
|
+
|
|
65
|
+
const ctes = ast.ctes && ast.ctes.length > 0
|
|
66
|
+
? (() => {
|
|
67
|
+
const hasRecursive = ast.ctes.some(cte => cte.recursive);
|
|
68
|
+
const prefix = hasRecursive ? 'WITH RECURSIVE ' : 'WITH ';
|
|
69
|
+
const cteDefs = ast.ctes.map(cte => {
|
|
70
|
+
const name = this.quoteIdentifier(cte.name);
|
|
71
|
+
const cols = cte.columns ? `(${cte.columns.map(c => this.quoteIdentifier(c)).join(', ')})` : '';
|
|
72
|
+
const query = this.compileSelectAst(cte.query, ctx).trim().replace(/;$/, '');
|
|
73
|
+
return `${name}${cols} AS (${query})`;
|
|
74
|
+
}).join(', ');
|
|
75
|
+
return prefix + cteDefs + ' ';
|
|
76
|
+
})()
|
|
77
|
+
: '';
|
|
78
|
+
|
|
79
|
+
return `${ctes}SELECT ${distinct}${columns} FROM ${from}${joins ? ' ' + joins : ''}${whereClause}${groupBy}${having}${orderBy}${limit}${offset};`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { CompilerContext, Dialect } from '../abstract';
|
|
2
|
+
import { SelectQueryNode } from '../../ast/query';
|
|
3
|
+
import { JsonPathNode } from '../../ast/expression';
|
|
4
|
+
|
|
5
|
+
export class SqliteDialect extends Dialect {
|
|
6
|
+
public constructor() {
|
|
7
|
+
super();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
quoteIdentifier(id: string): string {
|
|
11
|
+
return `"${id}"`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
protected compileJsonPath(node: JsonPathNode): string {
|
|
15
|
+
const col = `${this.quoteIdentifier(node.column.table)}.${this.quoteIdentifier(node.column.name)}`;
|
|
16
|
+
// SQLite uses json_extract(col, '$.path')
|
|
17
|
+
return `json_extract(${col}, '${node.path}')`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
protected compileSelectAst(ast: SelectQueryNode, ctx: CompilerContext): string {
|
|
21
|
+
const columns = ast.columns.map(c => {
|
|
22
|
+
let expr = '';
|
|
23
|
+
if (c.type === 'Function') {
|
|
24
|
+
expr = this.compileOperand(c, ctx);
|
|
25
|
+
} else if (c.type === 'Column') {
|
|
26
|
+
expr = `${this.quoteIdentifier(c.table)}.${this.quoteIdentifier(c.name)}`;
|
|
27
|
+
} else if (c.type === 'ScalarSubquery') {
|
|
28
|
+
expr = this.compileOperand(c, ctx);
|
|
29
|
+
} else if (c.type === 'CaseExpression') {
|
|
30
|
+
expr = this.compileOperand(c, ctx);
|
|
31
|
+
} else if (c.type === 'WindowFunction') {
|
|
32
|
+
expr = this.compileOperand(c, ctx);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Handle alias
|
|
36
|
+
if (c.alias) {
|
|
37
|
+
// Backward compat for the raw string parsing alias hack in playground
|
|
38
|
+
if (c.alias.includes('(')) return c.alias;
|
|
39
|
+
return `${expr} AS ${this.quoteIdentifier(c.alias)}`;
|
|
40
|
+
}
|
|
41
|
+
return expr;
|
|
42
|
+
}).join(', ');
|
|
43
|
+
|
|
44
|
+
const distinct = ast.distinct ? 'DISTINCT ' : '';
|
|
45
|
+
const from = `${this.quoteIdentifier(ast.from.name)}`;
|
|
46
|
+
|
|
47
|
+
const joins = ast.joins.map(j => {
|
|
48
|
+
const table = this.quoteIdentifier(j.table.name);
|
|
49
|
+
const cond = this.compileExpression(j.condition, ctx);
|
|
50
|
+
return `${j.kind} JOIN ${table} ON ${cond}`;
|
|
51
|
+
}).join(' ');
|
|
52
|
+
const whereClause = this.compileWhere(ast.where, ctx);
|
|
53
|
+
|
|
54
|
+
const groupBy = ast.groupBy && ast.groupBy.length > 0
|
|
55
|
+
? ' GROUP BY ' + ast.groupBy.map(c => `${this.quoteIdentifier(c.table)}.${this.quoteIdentifier(c.name)}`).join(', ')
|
|
56
|
+
: '';
|
|
57
|
+
|
|
58
|
+
const having = ast.having
|
|
59
|
+
? ` HAVING ${this.compileExpression(ast.having, ctx)}`
|
|
60
|
+
: '';
|
|
61
|
+
|
|
62
|
+
const orderBy = ast.orderBy && ast.orderBy.length > 0
|
|
63
|
+
? ' ORDER BY ' + ast.orderBy.map(o => `${this.quoteIdentifier(o.column.table)}.${this.quoteIdentifier(o.column.name)} ${o.direction}`).join(', ')
|
|
64
|
+
: '';
|
|
65
|
+
|
|
66
|
+
const limit = ast.limit ? ` LIMIT ${ast.limit}` : '';
|
|
67
|
+
const offset = ast.offset ? ` OFFSET ${ast.offset}` : '';
|
|
68
|
+
|
|
69
|
+
const ctes = ast.ctes && ast.ctes.length > 0
|
|
70
|
+
? (() => {
|
|
71
|
+
const hasRecursive = ast.ctes.some(cte => cte.recursive);
|
|
72
|
+
const prefix = hasRecursive ? 'WITH RECURSIVE ' : 'WITH ';
|
|
73
|
+
const cteDefs = ast.ctes.map(cte => {
|
|
74
|
+
const name = this.quoteIdentifier(cte.name);
|
|
75
|
+
const cols = cte.columns ? `(${cte.columns.map(c => this.quoteIdentifier(c)).join(', ')})` : '';
|
|
76
|
+
const query = this.compileSelectAst(cte.query, ctx).trim().replace(/;$/, '');
|
|
77
|
+
return `${name}${cols} AS (${query})`;
|
|
78
|
+
}).join(', ');
|
|
79
|
+
return prefix + cteDefs + ' ';
|
|
80
|
+
})()
|
|
81
|
+
: '';
|
|
82
|
+
|
|
83
|
+
return `${ctes}SELECT ${distinct}${columns} FROM ${from}${joins ? ' ' + joins : ''}${whereClause}${groupBy}${having}${orderBy}${limit}${offset};`;
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
|
|
2
|
+
export * from './schema/table';
|
|
3
|
+
export * from './schema/column';
|
|
4
|
+
export * from './schema/relation';
|
|
5
|
+
export * from './builder/select';
|
|
6
|
+
export * from './ast/expression';
|
|
7
|
+
export * from './dialect/mysql';
|
|
8
|
+
export * from './dialect/mssql';
|
|
9
|
+
export * from './dialect/sqlite';
|
|
10
|
+
export * from './runtime/als';
|
|
11
|
+
export * from './runtime/hydration';
|
|
12
|
+
export * from './codegen/typescript';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { QueryResult } from '../common/IDatabaseClient';
|
|
2
|
+
|
|
3
|
+
export interface QueryExecutionResult {
|
|
4
|
+
sql: string;
|
|
5
|
+
params: unknown[];
|
|
6
|
+
typescriptCode: string;
|
|
7
|
+
results: QueryResult[];
|
|
8
|
+
hydratedResults?: Record<string, any>[];
|
|
9
|
+
error: string | null;
|
|
10
|
+
executionTime: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ApiStatusResponse {
|
|
14
|
+
ready: boolean;
|
|
15
|
+
error: string | null;
|
|
16
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { IDatabaseClient, QueryResult } from "../common/IDatabaseClient";
|
|
2
|
+
import { DialectName } from "../../../../constants/sql";
|
|
3
|
+
|
|
4
|
+
export type SupportedDialect = DialectName;
|
|
5
|
+
|
|
6
|
+
export class MockClient implements IDatabaseClient {
|
|
7
|
+
isReady: boolean = true;
|
|
8
|
+
error: string | null = null;
|
|
9
|
+
|
|
10
|
+
constructor(dialect: SupportedDialect) {
|
|
11
|
+
this.error = `${dialect} is not supported yet.`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public async executeSql(sql: string, _params: unknown[] = []): Promise<QueryResult[]> {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { IDatabaseClient, QueryResult } from "../common/IDatabaseClient";
|
|
2
|
+
import { SEED_SQL } from "../data/seed";
|
|
3
|
+
import sqlite3 from 'sqlite3';
|
|
4
|
+
|
|
5
|
+
export class SqliteClient implements IDatabaseClient {
|
|
6
|
+
isReady: boolean = false;
|
|
7
|
+
error: string | null = null;
|
|
8
|
+
private db: sqlite3.Database | null = null;
|
|
9
|
+
|
|
10
|
+
constructor() {
|
|
11
|
+
this.initDB();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
private async initDB() {
|
|
15
|
+
try {
|
|
16
|
+
this.db = new sqlite3.Database(':memory:');
|
|
17
|
+
await new Promise<void>((resolve, reject) => {
|
|
18
|
+
this.db!.exec(SEED_SQL, (err) => {
|
|
19
|
+
if (err) reject(err);
|
|
20
|
+
else resolve();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
this.isReady = true;
|
|
24
|
+
} catch (e) {
|
|
25
|
+
console.error("Failed to load DB", e);
|
|
26
|
+
this.error = "Failed to initialize SQLite database.";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public async executeSql(sql: string, params: unknown[] = []): Promise<QueryResult[]> {
|
|
31
|
+
if (!this.db) {
|
|
32
|
+
this.error = "Database not ready.";
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
this.db!.all(sql, params, (err, rows) => {
|
|
38
|
+
if (err) {
|
|
39
|
+
this.error = err.message;
|
|
40
|
+
resolve([]);
|
|
41
|
+
} else {
|
|
42
|
+
this.error = null;
|
|
43
|
+
if (rows.length === 0) {
|
|
44
|
+
resolve([]);
|
|
45
|
+
} else {
|
|
46
|
+
const columns = Object.keys(rows[0]);
|
|
47
|
+
const values = rows.map(row => columns.map(col => row[col]));
|
|
48
|
+
resolve([{
|
|
49
|
+
columns,
|
|
50
|
+
values,
|
|
51
|
+
}]);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { eq, count, sum } from '../../../../../ast/expression';
|
|
2
|
+
import { createLiteral } from '../../../../../builder/select';
|
|
3
|
+
import { Users, Orders } from '../schema';
|
|
4
|
+
import { Scenario } from './types';
|
|
5
|
+
|
|
6
|
+
export const AGGREGATION_SCENARIOS = [
|
|
7
|
+
{
|
|
8
|
+
id: 'analytics',
|
|
9
|
+
category: 'Aggregation',
|
|
10
|
+
title: 'Sales Analytics',
|
|
11
|
+
description: 'Aggregating sales data using GROUP BY and sorting with ORDER BY DESC.',
|
|
12
|
+
build: (qb) => qb
|
|
13
|
+
.select({
|
|
14
|
+
user: Users.columns.name,
|
|
15
|
+
orderCount: count(Orders.columns.id)
|
|
16
|
+
})
|
|
17
|
+
.joinRelation('orders')
|
|
18
|
+
.groupBy(Users.columns.name)
|
|
19
|
+
.orderBy(Users.columns.name, 'DESC')
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: 'revenue_by_role',
|
|
23
|
+
category: 'Aggregation',
|
|
24
|
+
title: 'Revenue by Role',
|
|
25
|
+
description: 'Summing completed order totals and grouping the result by user role.',
|
|
26
|
+
build: (qb) => qb
|
|
27
|
+
.select({
|
|
28
|
+
role: Users.columns.role,
|
|
29
|
+
revenue: sum(Orders.columns.total)
|
|
30
|
+
})
|
|
31
|
+
.joinRelation('orders')
|
|
32
|
+
.where(eq(Orders.columns.status, createLiteral('completed')))
|
|
33
|
+
.groupBy(Users.columns.role)
|
|
34
|
+
.orderBy(Users.columns.role, 'ASC')
|
|
35
|
+
}
|
|
36
|
+
];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Users } from '../schema';
|
|
2
|
+
import { createScenario } from './types';
|
|
3
|
+
|
|
4
|
+
export const BASIC_SCENARIOS = [
|
|
5
|
+
createScenario({
|
|
6
|
+
id: 'basic',
|
|
7
|
+
category: 'Basics',
|
|
8
|
+
title: 'Hello World',
|
|
9
|
+
description: 'A basic projection query fetching specific columns from the Users table.',
|
|
10
|
+
build: (qb) => qb.select({ id: Users.columns.id, name: Users.columns.name }).limit(5)
|
|
11
|
+
}),
|
|
12
|
+
createScenario({
|
|
13
|
+
id: 'aliased_projection',
|
|
14
|
+
category: 'Basics',
|
|
15
|
+
title: 'Aliased Projection',
|
|
16
|
+
description: 'Expose friendly aliases when projecting columns for DTOs.',
|
|
17
|
+
build: (qb) => qb
|
|
18
|
+
.select({
|
|
19
|
+
userId: Users.columns.id,
|
|
20
|
+
userName: Users.columns.name,
|
|
21
|
+
userRole: Users.columns.role
|
|
22
|
+
})
|
|
23
|
+
.limit(4)
|
|
24
|
+
})
|
|
25
|
+
];
|