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,120 @@
|
|
|
1
|
+
import { performance } from 'node:perf_hooks';
|
|
2
|
+
import { SelectQueryBuilder } from '../../../../builder/select';
|
|
3
|
+
import { Users } from '../data/schema';
|
|
4
|
+
import { SqliteDialect } from '../../../../dialect/sqlite';
|
|
5
|
+
import { hydrateRows } from '../../../../runtime/hydration';
|
|
6
|
+
import type { IDatabaseClient } from '../common/IDatabaseClient';
|
|
7
|
+
import type { QueryExecutionResult } from '../api/types';
|
|
8
|
+
import type { Scenario } from '../data/scenarios';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Extracts the TypeScript code from a build function
|
|
12
|
+
*/
|
|
13
|
+
function extractTypeScriptCode(buildFn: (builder: SelectQueryBuilder<any>) => SelectQueryBuilder<any>): string {
|
|
14
|
+
const fnString = buildFn.toString();
|
|
15
|
+
|
|
16
|
+
// Remove the function wrapper and return statement
|
|
17
|
+
const bodyMatch = fnString.match(/=>\s*\{?\s*(.*?)\s*\}?$/s);
|
|
18
|
+
if (bodyMatch) {
|
|
19
|
+
let code = bodyMatch[1].trim();
|
|
20
|
+
|
|
21
|
+
// Remove trailing semicolon if present
|
|
22
|
+
code = code.replace(/;$/, '');
|
|
23
|
+
|
|
24
|
+
// Clean up indentation (remove common leading spaces)
|
|
25
|
+
const lines = code.split('\n');
|
|
26
|
+
if (lines.length > 1) {
|
|
27
|
+
// Find the minimum indentation of non-empty lines
|
|
28
|
+
const nonEmptyLines = lines.filter(line => line.trim().length > 0);
|
|
29
|
+
const minIndent = Math.min(...nonEmptyLines.map(line => {
|
|
30
|
+
const match = line.match(/^(\s*)/);
|
|
31
|
+
return match ? match[1].length : 0;
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// Remove the common indentation
|
|
35
|
+
code = lines.map(line => line.slice(minIndent)).join('\n').trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return code;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return fnString;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class QueryExecutionService {
|
|
45
|
+
constructor(private dbClient: IDatabaseClient) { }
|
|
46
|
+
|
|
47
|
+
async executeScenario(scenario: Scenario): Promise<QueryExecutionResult> {
|
|
48
|
+
const startTime = performance.now();
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
if (!this.dbClient.isReady) {
|
|
52
|
+
return {
|
|
53
|
+
sql: '',
|
|
54
|
+
params: [],
|
|
55
|
+
typescriptCode: scenario.typescriptCode || extractTypeScriptCode(scenario.build),
|
|
56
|
+
results: [],
|
|
57
|
+
error: 'Database not ready',
|
|
58
|
+
executionTime: 0
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const queryBuilder = new SelectQueryBuilder(Users);
|
|
63
|
+
const builtQuery = scenario.build(queryBuilder);
|
|
64
|
+
|
|
65
|
+
const dialect = new SqliteDialect();
|
|
66
|
+
const compiled = builtQuery.compile(dialect);
|
|
67
|
+
|
|
68
|
+
const results = await this.dbClient.executeSql(compiled.sql, compiled.params);
|
|
69
|
+
const executionTime = performance.now() - startTime;
|
|
70
|
+
|
|
71
|
+
// Check if hydration is needed
|
|
72
|
+
const hydrationPlan = builtQuery.getHydrationPlan();
|
|
73
|
+
let hydratedResults: Record<string, any>[] | undefined;
|
|
74
|
+
|
|
75
|
+
if (hydrationPlan && results.length > 0) {
|
|
76
|
+
// Convert QueryResult[] to Record<string, any>[] for hydration
|
|
77
|
+
const rows: Record<string, any>[] = [];
|
|
78
|
+
const { columns, values } = results[0];
|
|
79
|
+
|
|
80
|
+
for (const row of values) {
|
|
81
|
+
const obj: Record<string, any> = {};
|
|
82
|
+
columns.forEach((col, idx) => {
|
|
83
|
+
obj[col] = row[idx];
|
|
84
|
+
});
|
|
85
|
+
rows.push(obj);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
hydratedResults = hydrateRows(rows, hydrationPlan);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
sql: compiled.sql,
|
|
93
|
+
params: compiled.params,
|
|
94
|
+
typescriptCode: scenario.typescriptCode || extractTypeScriptCode(scenario.build),
|
|
95
|
+
results,
|
|
96
|
+
hydratedResults,
|
|
97
|
+
error: this.dbClient.error,
|
|
98
|
+
executionTime
|
|
99
|
+
};
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const executionTime = performance.now() - startTime;
|
|
102
|
+
return {
|
|
103
|
+
sql: '',
|
|
104
|
+
params: [],
|
|
105
|
+
typescriptCode: scenario.typescriptCode || extractTypeScriptCode(scenario.build),
|
|
106
|
+
results: [],
|
|
107
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
108
|
+
executionTime
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
isDatabaseReady(): boolean {
|
|
114
|
+
return this.dbClient.isReady;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
getLastError(): string | null {
|
|
118
|
+
return this.dbClient.error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// In a real Node environment: import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
|
|
3
|
+
// Browser Shim
|
|
4
|
+
export class AsyncLocalStorage<T> {
|
|
5
|
+
private store: T | undefined;
|
|
6
|
+
|
|
7
|
+
run<R>(store: T, callback: () => R): R {
|
|
8
|
+
this.store = store;
|
|
9
|
+
try {
|
|
10
|
+
return callback();
|
|
11
|
+
} finally {
|
|
12
|
+
this.store = undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
getStore(): T | undefined {
|
|
17
|
+
return this.store;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { HydrationPlan } from '../ast/query';
|
|
2
|
+
|
|
3
|
+
const isRelationKey = (key: string) => key.includes('__');
|
|
4
|
+
|
|
5
|
+
export const hydrateRows = (rows: Record<string, any>[], plan?: HydrationPlan): Record<string, any>[] => {
|
|
6
|
+
if (!plan || !rows.length) return rows;
|
|
7
|
+
|
|
8
|
+
const rootMap = new Map<any, any>();
|
|
9
|
+
|
|
10
|
+
rows.forEach(row => {
|
|
11
|
+
const rootId = row[plan.rootPrimaryKey];
|
|
12
|
+
if (rootId === undefined) return;
|
|
13
|
+
|
|
14
|
+
if (!rootMap.has(rootId)) {
|
|
15
|
+
const base: Record<string, any> = {};
|
|
16
|
+
const baseKeys = plan.rootColumns.length ? plan.rootColumns : Object.keys(row).filter(k => !isRelationKey(k));
|
|
17
|
+
baseKeys.forEach(key => { base[key] = row[key]; });
|
|
18
|
+
plan.relations.forEach(rel => { base[rel.name] = []; });
|
|
19
|
+
rootMap.set(rootId, base);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const parent = rootMap.get(rootId);
|
|
23
|
+
|
|
24
|
+
plan.relations.forEach(rel => {
|
|
25
|
+
const childPkKey = `${rel.aliasPrefix}__${rel.targetPrimaryKey}`;
|
|
26
|
+
const childPk = row[childPkKey];
|
|
27
|
+
if (childPk === null || childPk === undefined) return;
|
|
28
|
+
|
|
29
|
+
const bucket = parent[rel.name] as any[];
|
|
30
|
+
if (bucket.some(item => item[rel.targetPrimaryKey] === childPk)) return;
|
|
31
|
+
|
|
32
|
+
const child: Record<string, any> = {};
|
|
33
|
+
rel.columns.forEach(col => {
|
|
34
|
+
const key = `${rel.aliasPrefix}__${col}`;
|
|
35
|
+
child[col] = row[key];
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
bucket.push(child);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return Array.from(rootMap.values());
|
|
43
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type ColumnType = 'INT' | 'VARCHAR' | 'JSON' | 'ENUM' | 'BOOLEAN';
|
|
2
|
+
|
|
3
|
+
export interface ColumnDef {
|
|
4
|
+
name: string; // filled at runtime by defineTable
|
|
5
|
+
type: ColumnType;
|
|
6
|
+
primary?: boolean;
|
|
7
|
+
notNull?: boolean;
|
|
8
|
+
args?: any[]; // for varchar length etc
|
|
9
|
+
table?: string; // Filled at runtime by defineTable
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Column Factory
|
|
13
|
+
export const col = {
|
|
14
|
+
int: (): ColumnDef => ({ name: '', type: 'INT' }),
|
|
15
|
+
varchar: (length: number): ColumnDef => ({ name: '', type: 'VARCHAR', args: [length] }),
|
|
16
|
+
json: (): ColumnDef => ({ name: '', type: 'JSON' }),
|
|
17
|
+
boolean: (): ColumnDef => ({ name: '', type: 'BOOLEAN' }),
|
|
18
|
+
primaryKey: (def: ColumnDef): ColumnDef => ({ ...def, primary: true })
|
|
19
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { TableDef } from './table';
|
|
2
|
+
|
|
3
|
+
export const RelationKinds = {
|
|
4
|
+
HasMany: 'HAS_MANY',
|
|
5
|
+
BelongsTo: 'BELONGS_TO',
|
|
6
|
+
} as const;
|
|
7
|
+
|
|
8
|
+
export type RelationType = (typeof RelationKinds)[keyof typeof RelationKinds];
|
|
9
|
+
|
|
10
|
+
interface BaseRelation {
|
|
11
|
+
target: TableDef;
|
|
12
|
+
foreignKey: string; // The column on the child table
|
|
13
|
+
localKey?: string; // Usually 'id'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface HasManyRelation extends BaseRelation {
|
|
17
|
+
type: typeof RelationKinds.HasMany;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface BelongsToRelation extends BaseRelation {
|
|
21
|
+
type: typeof RelationKinds.BelongsTo;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type RelationDef = HasManyRelation | BelongsToRelation;
|
|
25
|
+
|
|
26
|
+
export const hasMany = (target: TableDef, foreignKey: string, localKey: string = 'id'): HasManyRelation => ({
|
|
27
|
+
type: RelationKinds.HasMany,
|
|
28
|
+
target,
|
|
29
|
+
foreignKey,
|
|
30
|
+
localKey,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const belongsTo = (target: TableDef, foreignKey: string, localKey: string = 'id'): BelongsToRelation => ({
|
|
34
|
+
type: RelationKinds.BelongsTo,
|
|
35
|
+
target,
|
|
36
|
+
foreignKey,
|
|
37
|
+
localKey,
|
|
38
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ColumnDef } from './column';
|
|
2
|
+
import { RelationDef } from './relation';
|
|
3
|
+
|
|
4
|
+
export interface TableDef<T extends Record<string, ColumnDef> = Record<string, ColumnDef>> {
|
|
5
|
+
name: string;
|
|
6
|
+
columns: T;
|
|
7
|
+
relations: Record<string, RelationDef>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const defineTable = <T extends Record<string, ColumnDef>>(
|
|
11
|
+
name: string,
|
|
12
|
+
columns: T,
|
|
13
|
+
relations: Record<string, RelationDef> = {}
|
|
14
|
+
): TableDef<T> => {
|
|
15
|
+
// Runtime mutability to assign names to column definitions for convenience
|
|
16
|
+
const colsWithNames = Object.entries(columns).reduce((acc, [key, def]) => {
|
|
17
|
+
(acc as any)[key] = { ...def, name: key, table: name };
|
|
18
|
+
return acc;
|
|
19
|
+
}, {} as T);
|
|
20
|
+
|
|
21
|
+
return { name, columns: colsWithNames, relations };
|
|
22
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { between, notBetween, eq } from '../src/ast/expression';
|
|
3
|
+
import { Users, Orders } from '../src/playground/features/playground/data/schema';
|
|
4
|
+
import { SqliteDialect } from '../src/dialect/sqlite';
|
|
5
|
+
import { SelectQueryBuilder } from '../src/builder/select';
|
|
6
|
+
|
|
7
|
+
describe('between', () => {
|
|
8
|
+
const dialect = new SqliteDialect();
|
|
9
|
+
|
|
10
|
+
it('should handle a basic BETWEEN condition', () => {
|
|
11
|
+
const q = new SelectQueryBuilder(Users)
|
|
12
|
+
.selectRaw('*')
|
|
13
|
+
.where(between(Users.columns.id, 1, 10));
|
|
14
|
+
const compiled = q.compile(dialect);
|
|
15
|
+
expect(compiled.sql).toBe(
|
|
16
|
+
'SELECT "users"."*" FROM "users" WHERE "users"."id" BETWEEN ? AND ?;'
|
|
17
|
+
);
|
|
18
|
+
expect(compiled.params).toEqual([1, 10]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should handle a NOT BETWEEN condition', () => {
|
|
22
|
+
const q = new SelectQueryBuilder(Users)
|
|
23
|
+
.selectRaw('*')
|
|
24
|
+
.where(notBetween(Users.columns.id, 1, 10));
|
|
25
|
+
const compiled = q.compile(dialect);
|
|
26
|
+
expect(compiled.sql).toBe(
|
|
27
|
+
'SELECT "users"."*" FROM "users" WHERE "users"."id" NOT BETWEEN ? AND ?;'
|
|
28
|
+
);
|
|
29
|
+
expect(compiled.params).toEqual([1, 10]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should handle multiple conditions', () => {
|
|
33
|
+
const q = new SelectQueryBuilder(Orders)
|
|
34
|
+
.selectRaw('*')
|
|
35
|
+
.where(between(Orders.columns.total, 100, 200))
|
|
36
|
+
.where(eq(Orders.columns.user_id, 1));
|
|
37
|
+
const compiled = q.compile(dialect);
|
|
38
|
+
expect(compiled.sql).toBe(
|
|
39
|
+
'SELECT "orders"."*" FROM "orders" WHERE "orders"."total" BETWEEN ? AND ? AND "orders"."user_id" = ?;'
|
|
40
|
+
);
|
|
41
|
+
expect(compiled.params).toEqual([100, 200, 1]);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Users } from '../src/playground/features/playground/data/schema';
|
|
3
|
+
import { caseWhen, gt, eq } from '../src/ast/expression';
|
|
4
|
+
import { SqliteDialect } from '../src/dialect/sqlite';
|
|
5
|
+
import { SelectQueryBuilder } from '../src/builder/select';
|
|
6
|
+
|
|
7
|
+
describe('CASE Expressions', () => {
|
|
8
|
+
const dialect = new SqliteDialect();
|
|
9
|
+
|
|
10
|
+
it('should compile simple CASE WHEN ... THEN ... ELSE ... END', () => {
|
|
11
|
+
const query = new SelectQueryBuilder(Users)
|
|
12
|
+
.select({
|
|
13
|
+
status: caseWhen([
|
|
14
|
+
{ when: gt(Users.columns.id, 10), then: 'High' },
|
|
15
|
+
{ when: gt(Users.columns.id, 5), then: 'Medium' }
|
|
16
|
+
], 'Low')
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const compiled = query.compile(dialect);
|
|
20
|
+
const { sql, params } = compiled;
|
|
21
|
+
|
|
22
|
+
expect(sql).toContain('CASE WHEN "users"."id" > ? THEN ? WHEN "users"."id" > ? THEN ? ELSE ? END');
|
|
23
|
+
expect(params).toEqual([10, 'High', 5, 'Medium', 'Low']);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should compile CASE without ELSE', () => {
|
|
27
|
+
const query = new SelectQueryBuilder(Users)
|
|
28
|
+
.select({
|
|
29
|
+
status: caseWhen([
|
|
30
|
+
{ when: eq(Users.columns.name, 'Alice'), then: 'Admin' }
|
|
31
|
+
])
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const compiled = query.compile(dialect);
|
|
35
|
+
const { sql, params } = compiled;
|
|
36
|
+
|
|
37
|
+
expect(sql).toContain('CASE WHEN "users"."name" = ? THEN ? END');
|
|
38
|
+
expect(params).toEqual(['Alice', 'Admin']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should work in WHERE clause', () => {
|
|
42
|
+
const query = new SelectQueryBuilder(Users)
|
|
43
|
+
.where(
|
|
44
|
+
eq(
|
|
45
|
+
caseWhen([
|
|
46
|
+
{ when: gt(Users.columns.id, 10), then: 'High' }
|
|
47
|
+
], 'Low'),
|
|
48
|
+
'High'
|
|
49
|
+
)
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const compiled = query.compile(dialect);
|
|
53
|
+
const { sql, params } = compiled;
|
|
54
|
+
|
|
55
|
+
expect(sql).toContain('WHERE CASE WHEN "users"."id" > ? THEN ? ELSE ? END = ?');
|
|
56
|
+
expect(params).toEqual([10, 'High', 'Low', 'High']);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { SelectQueryBuilder } from '../src/builder/select';
|
|
3
|
+
import { SqliteDialect } from '../src/dialect/sqlite';
|
|
4
|
+
import { TableDef, defineTable } from '../src/schema/table';
|
|
5
|
+
import { col } from '../src/schema/column';
|
|
6
|
+
import { eq, exists, and } from '../src/ast/expression';
|
|
7
|
+
|
|
8
|
+
// Define test schema: Clientes, Pedidos and Fidelidade
|
|
9
|
+
const Clientes = defineTable('clientes', {
|
|
10
|
+
id: col.primaryKey(col.int()),
|
|
11
|
+
nome: col.varchar(255),
|
|
12
|
+
email: col.varchar(255),
|
|
13
|
+
}, {});
|
|
14
|
+
|
|
15
|
+
const Pedidos = defineTable('pedidos', {
|
|
16
|
+
id: col.primaryKey(col.int()),
|
|
17
|
+
cliente_id: col.int(),
|
|
18
|
+
data_pedido: col.varchar(50), // Usando varchar em vez de date
|
|
19
|
+
status: col.varchar(50),
|
|
20
|
+
total: col.int(),
|
|
21
|
+
}, {});
|
|
22
|
+
|
|
23
|
+
const Fidelidade = defineTable('fidelidade', {
|
|
24
|
+
id: col.primaryKey(col.int()),
|
|
25
|
+
cliente_id: col.int(),
|
|
26
|
+
status: col.varchar(50),
|
|
27
|
+
data_inicio: col.varchar(50), // Usando varchar em vez de date
|
|
28
|
+
}, {});
|
|
29
|
+
|
|
30
|
+
// Define relationships
|
|
31
|
+
Clientes.relations = {
|
|
32
|
+
pedidos: {
|
|
33
|
+
type: 'HAS_MANY',
|
|
34
|
+
target: Pedidos,
|
|
35
|
+
foreignKey: 'cliente_id',
|
|
36
|
+
localKey: 'id'
|
|
37
|
+
},
|
|
38
|
+
fidelidade: {
|
|
39
|
+
type: 'HAS_MANY',
|
|
40
|
+
target: Fidelidade,
|
|
41
|
+
foreignKey: 'cliente_id',
|
|
42
|
+
localKey: 'id'
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
Pedidos.relations = {
|
|
47
|
+
cliente: {
|
|
48
|
+
type: 'BELONGS_TO',
|
|
49
|
+
target: Clientes,
|
|
50
|
+
foreignKey: 'cliente_id',
|
|
51
|
+
localKey: 'id'
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
Fidelidade.relations = {
|
|
56
|
+
cliente: {
|
|
57
|
+
type: 'BELONGS_TO',
|
|
58
|
+
target: Clientes,
|
|
59
|
+
foreignKey: 'cliente_id',
|
|
60
|
+
localKey: 'id'
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const dialect = new SqliteDialect();
|
|
65
|
+
|
|
66
|
+
describe('Complex EXISTS Query Support', () => {
|
|
67
|
+
it('should support EXISTS with date range AND another EXISTS', () => {
|
|
68
|
+
// Construindo a subquery para pedidos em 2024
|
|
69
|
+
const pedidosEm2024 = new SelectQueryBuilder(Pedidos)
|
|
70
|
+
.select({ dummy: col.int() }) // SELECT 1 (dummy)
|
|
71
|
+
.where(and(
|
|
72
|
+
eq(Pedidos.columns.cliente_id, { type: 'Column', table: 'clientes', name: 'id' }),
|
|
73
|
+
// BETWEEN não é suportado diretamente, então usamos >= e <=
|
|
74
|
+
and(
|
|
75
|
+
eq(Pedidos.columns.data_pedido, '2024-01-01'),
|
|
76
|
+
eq(Pedidos.columns.data_pedido, '2024-12-31')
|
|
77
|
+
)
|
|
78
|
+
));
|
|
79
|
+
|
|
80
|
+
// Construindo a subquery para fidelidade ativa
|
|
81
|
+
const fidelidadeAtiva = new SelectQueryBuilder(Fidelidade)
|
|
82
|
+
.select({ dummy: col.int() }) // SELECT 1 (dummy)
|
|
83
|
+
.where(and(
|
|
84
|
+
eq(Fidelidade.columns.cliente_id, { type: 'Column', table: 'clientes', name: 'id' }),
|
|
85
|
+
eq(Fidelidade.columns.status, 'Ativo')
|
|
86
|
+
));
|
|
87
|
+
|
|
88
|
+
// Consulta principal
|
|
89
|
+
const query = new SelectQueryBuilder(Clientes)
|
|
90
|
+
.select({ nome: Clientes.columns.nome })
|
|
91
|
+
.where(and(
|
|
92
|
+
exists(pedidosEm2024.getAST()),
|
|
93
|
+
exists(fidelidadeAtiva.getAST())
|
|
94
|
+
));
|
|
95
|
+
|
|
96
|
+
const compiled = query.compile(dialect);
|
|
97
|
+
const { sql, params } = compiled;
|
|
98
|
+
|
|
99
|
+
console.log('Generated SQL:', sql);
|
|
100
|
+
console.log('Parameters:', params);
|
|
101
|
+
|
|
102
|
+
expect(sql).toContain('EXISTS');
|
|
103
|
+
expect(sql).toContain('SELECT 1 FROM "pedidos"');
|
|
104
|
+
expect(sql).toContain('"pedidos"."cliente_id" = "clientes"."id"');
|
|
105
|
+
expect(sql).toContain('"pedidos"."data_pedido"');
|
|
106
|
+
expect(sql).toContain('EXISTS');
|
|
107
|
+
expect(sql).toContain('SELECT 1 FROM "fidelidade"');
|
|
108
|
+
expect(sql).toContain('"fidelidade"."cliente_id" = "clientes"."id"');
|
|
109
|
+
expect(sql).toContain('"fidelidade"."status" = ?');
|
|
110
|
+
expect(params).toContain('Ativo');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should support whereHas for both conditions', () => {
|
|
114
|
+
// Usando whereHas para simplificar a construção das subqueries EXISTS
|
|
115
|
+
const query = new SelectQueryBuilder(Clientes)
|
|
116
|
+
.select({ nome: Clientes.columns.nome })
|
|
117
|
+
.whereHas('pedidos', (pedidosQb) =>
|
|
118
|
+
pedidosQb.where(and(
|
|
119
|
+
eq(Pedidos.columns.data_pedido, '2024-01-01'),
|
|
120
|
+
eq(Pedidos.columns.data_pedido, '2024-12-31')
|
|
121
|
+
))
|
|
122
|
+
)
|
|
123
|
+
.whereHas('fidelidade', (fidelidadeQb) =>
|
|
124
|
+
fidelidadeQb.where(eq(Fidelidade.columns.status, 'Ativo'))
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const compiled = query.compile(dialect);
|
|
128
|
+
const { sql, params } = compiled;
|
|
129
|
+
|
|
130
|
+
console.log('Generated SQL with whereHas:', sql);
|
|
131
|
+
console.log('Parameters:', params);
|
|
132
|
+
|
|
133
|
+
expect(sql).toContain('EXISTS');
|
|
134
|
+
expect(sql).toContain('SELECT 1 FROM "pedidos"');
|
|
135
|
+
expect(sql).toContain('"pedidos"."cliente_id" = "clientes"."id"');
|
|
136
|
+
expect(sql).toContain('"pedidos"."data_pedido"');
|
|
137
|
+
expect(sql).toContain('EXISTS');
|
|
138
|
+
expect(sql).toContain('SELECT 1 FROM "fidelidade"');
|
|
139
|
+
expect(sql).toContain('"fidelidade"."cliente_id" = "clientes"."id"');
|
|
140
|
+
expect(sql).toContain('"fidelidade"."status" = ?');
|
|
141
|
+
expect(params).toContain('Ativo');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should generate correct SQL structure for complex EXISTS query', () => {
|
|
145
|
+
// Teste para verificar se o SQL gerado tem a estrutura correta
|
|
146
|
+
const query = new SelectQueryBuilder(Clientes)
|
|
147
|
+
.select({ nome: Clientes.columns.nome })
|
|
148
|
+
.where(and(
|
|
149
|
+
exists({
|
|
150
|
+
type: 'SelectQuery',
|
|
151
|
+
from: { type: 'Table', name: 'pedidos' },
|
|
152
|
+
columns: [{ type: 'Column', table: 'pedidos', name: 'id' }],
|
|
153
|
+
joins: [],
|
|
154
|
+
where: {
|
|
155
|
+
type: 'LogicalExpression',
|
|
156
|
+
operator: 'AND',
|
|
157
|
+
operands: [
|
|
158
|
+
{
|
|
159
|
+
type: 'BinaryExpression',
|
|
160
|
+
left: { type: 'Column', table: 'pedidos', name: 'cliente_id' },
|
|
161
|
+
operator: '=',
|
|
162
|
+
right: { type: 'Column', table: 'clientes', name: 'id' }
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
type: 'LogicalExpression',
|
|
166
|
+
operator: 'AND',
|
|
167
|
+
operands: [
|
|
168
|
+
{
|
|
169
|
+
type: 'BinaryExpression',
|
|
170
|
+
left: { type: 'Column', table: 'pedidos', name: 'data_pedido' },
|
|
171
|
+
operator: '>=',
|
|
172
|
+
right: { type: 'Literal', value: '2024-01-01' }
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
type: 'BinaryExpression',
|
|
176
|
+
left: { type: 'Column', table: 'pedidos', name: 'data_pedido' },
|
|
177
|
+
operator: '<=',
|
|
178
|
+
right: { type: 'Literal', value: '2024-12-31' }
|
|
179
|
+
}
|
|
180
|
+
]
|
|
181
|
+
}
|
|
182
|
+
]
|
|
183
|
+
}
|
|
184
|
+
}),
|
|
185
|
+
exists({
|
|
186
|
+
type: 'SelectQuery',
|
|
187
|
+
from: { type: 'Table', name: 'fidelidade' },
|
|
188
|
+
columns: [{ type: 'Column', table: 'fidelidade', name: 'id' }],
|
|
189
|
+
joins: [],
|
|
190
|
+
where: {
|
|
191
|
+
type: 'LogicalExpression',
|
|
192
|
+
operator: 'AND',
|
|
193
|
+
operands: [
|
|
194
|
+
{
|
|
195
|
+
type: 'BinaryExpression',
|
|
196
|
+
left: { type: 'Column', table: 'fidelidade', name: 'cliente_id' },
|
|
197
|
+
operator: '=',
|
|
198
|
+
right: { type: 'Column', table: 'clientes', name: 'id' }
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
type: 'BinaryExpression',
|
|
202
|
+
left: { type: 'Column', table: 'fidelidade', name: 'status' },
|
|
203
|
+
operator: '=',
|
|
204
|
+
right: { type: 'Literal', value: 'Ativo' }
|
|
205
|
+
}
|
|
206
|
+
]
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
));
|
|
210
|
+
|
|
211
|
+
const compiled = query.compile(dialect);
|
|
212
|
+
const { sql, params } = compiled;
|
|
213
|
+
|
|
214
|
+
console.log('Generated SQL structure:', sql);
|
|
215
|
+
console.log('Parameters:', params);
|
|
216
|
+
|
|
217
|
+
// Verifica a estrutura geral da consulta
|
|
218
|
+
expect(sql).toContain('SELECT "clientes"."nome" AS "nome" FROM "clientes" WHERE');
|
|
219
|
+
expect(sql).toContain('EXISTS');
|
|
220
|
+
expect(sql).toContain('SELECT 1 FROM "pedidos"');
|
|
221
|
+
expect(sql).toContain('"pedidos"."cliente_id" = "clientes"."id"');
|
|
222
|
+
expect(sql).toContain('"pedidos"."data_pedido" >= ?');
|
|
223
|
+
expect(sql).toContain('"pedidos"."data_pedido" <= ?');
|
|
224
|
+
expect(sql).toContain('EXISTS');
|
|
225
|
+
expect(sql).toContain('SELECT 1 FROM "fidelidade"');
|
|
226
|
+
expect(sql).toContain('"fidelidade"."cliente_id" = "clientes"."id"');
|
|
227
|
+
expect(sql).toContain('"fidelidade"."status" = ?');
|
|
228
|
+
expect(params).toEqual(['2024-01-01', '2024-12-31', 'Ativo']);
|
|
229
|
+
});
|
|
230
|
+
});
|