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,118 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { SelectQueryBuilder } from '../src/builder/select';
|
|
3
|
+
import { SqliteDialect } from '../src/dialect/sqlite';
|
|
4
|
+
import { MySqlDialect } from '../src/dialect/mysql';
|
|
5
|
+
import { SqlServerDialect } from '../src/dialect/mssql';
|
|
6
|
+
import { TableDef } from '../src/schema/table';
|
|
7
|
+
import { eq } from '../src/ast/expression';
|
|
8
|
+
|
|
9
|
+
const table = (name: string): TableDef => ({ name, columns: {}, relations: {} });
|
|
10
|
+
const col = (name: string, table?: string) => ({ type: 'Column', name, table: table || 'unknown' } as any);
|
|
11
|
+
const lit = (value: any) => ({ type: 'Literal', value } as any);
|
|
12
|
+
|
|
13
|
+
describe('CTE Support', () => {
|
|
14
|
+
const sqlite = new SqliteDialect();
|
|
15
|
+
const mysql = new MySqlDialect();
|
|
16
|
+
const mssql = new SqlServerDialect();
|
|
17
|
+
|
|
18
|
+
it('should generate a simple CTE', () => {
|
|
19
|
+
const users = table('users');
|
|
20
|
+
const cte = new SelectQueryBuilder(users)
|
|
21
|
+
.selectRaw('id', 'name')
|
|
22
|
+
.where(eq(col('id', 'users'), lit(1)));
|
|
23
|
+
|
|
24
|
+
const query = new SelectQueryBuilder(table('cte_users'))
|
|
25
|
+
.with('cte_users', cte)
|
|
26
|
+
.selectRaw('id', 'name');
|
|
27
|
+
|
|
28
|
+
expect(query.toSql(sqlite)).toBe('WITH "cte_users" AS (SELECT "users"."id", "users"."name" FROM "users" WHERE "users"."id" = ?) SELECT "cte_users"."id", "cte_users"."name" FROM "cte_users";');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should generate a recursive CTE', () => {
|
|
32
|
+
const numbers = table('numbers');
|
|
33
|
+
const cte = new SelectQueryBuilder(numbers)
|
|
34
|
+
.selectRaw('n')
|
|
35
|
+
.where(eq(col('n', 'numbers'), lit(1)));
|
|
36
|
+
|
|
37
|
+
// Recursive part usually involves UNION, but our builder might not support UNION yet?
|
|
38
|
+
// The task didn't mention UNION.
|
|
39
|
+
// But we can test the RECURSIVE keyword generation at least.
|
|
40
|
+
|
|
41
|
+
const query = new SelectQueryBuilder(table('cte_numbers'))
|
|
42
|
+
.withRecursive('cte_numbers', cte)
|
|
43
|
+
.selectRaw('n');
|
|
44
|
+
|
|
45
|
+
expect(query.toSql(sqlite)).toBe('WITH RECURSIVE "cte_numbers" AS (SELECT "numbers"."n" FROM "numbers" WHERE "numbers"."n" = ?) SELECT "cte_numbers"."n" FROM "cte_numbers";');
|
|
46
|
+
expect(query.toSql(mysql)).toBe('WITH RECURSIVE `cte_numbers` AS (SELECT `numbers`.`n` FROM `numbers` WHERE `numbers`.`n` = ?) SELECT `cte_numbers`.`n` FROM `cte_numbers`;');
|
|
47
|
+
// MSSQL should NOT have RECURSIVE
|
|
48
|
+
expect(query.toSql(mssql)).toBe('WITH [cte_numbers] AS (SELECT [numbers].[n] FROM [numbers] WHERE [numbers].[n] = @p1) SELECT [cte_numbers].[n] FROM [cte_numbers];');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should generate CTE with column aliases', () => {
|
|
52
|
+
const users = table('users');
|
|
53
|
+
const cte = new SelectQueryBuilder(users)
|
|
54
|
+
.selectRaw('id', 'name');
|
|
55
|
+
|
|
56
|
+
const query = new SelectQueryBuilder(table('cte_users'))
|
|
57
|
+
.with('cte_users', cte, ['user_id', 'user_name'])
|
|
58
|
+
.selectRaw('user_id');
|
|
59
|
+
|
|
60
|
+
expect(query.toSql(sqlite)).toBe('WITH "cte_users"("user_id", "user_name") AS (SELECT "users"."id", "users"."name" FROM "users") SELECT "cte_users"."user_id" FROM "cte_users";');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should support multiple CTEs', () => {
|
|
64
|
+
const users = table('users');
|
|
65
|
+
const orders = table('orders');
|
|
66
|
+
|
|
67
|
+
const cte1 = new SelectQueryBuilder(users).selectRaw('id');
|
|
68
|
+
const cte2 = new SelectQueryBuilder(orders).selectRaw('total');
|
|
69
|
+
|
|
70
|
+
const query = new SelectQueryBuilder(table('main'))
|
|
71
|
+
.with('u', cte1)
|
|
72
|
+
.with('o', cte2)
|
|
73
|
+
.selectRaw('u.id', 'o.total');
|
|
74
|
+
|
|
75
|
+
expect(query.toSql(sqlite)).toBe('WITH "u" AS (SELECT "users"."id" FROM "users"), "o" AS (SELECT "orders"."total" FROM "orders") SELECT "main"."u.id", "main"."o.total" FROM "main";');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should handle mixed recursive and non-recursive CTEs', () => {
|
|
79
|
+
const numbers = table('numbers');
|
|
80
|
+
const users = table('users');
|
|
81
|
+
|
|
82
|
+
const recursiveCte = new SelectQueryBuilder(numbers)
|
|
83
|
+
.selectRaw('n')
|
|
84
|
+
.where(eq(col('n', 'numbers'), lit(1)));
|
|
85
|
+
|
|
86
|
+
const normalCte = new SelectQueryBuilder(users)
|
|
87
|
+
.selectRaw('id', 'name');
|
|
88
|
+
|
|
89
|
+
const query = new SelectQueryBuilder(table('main'))
|
|
90
|
+
.withRecursive('recursive_numbers', recursiveCte)
|
|
91
|
+
.with('normal_users', normalCte)
|
|
92
|
+
.selectRaw('n', 'id');
|
|
93
|
+
|
|
94
|
+
// Should have "WITH RECURSIVE" once at the beginning, not per CTE
|
|
95
|
+
expect(query.toSql(sqlite)).toBe('WITH RECURSIVE "recursive_numbers" AS (SELECT "numbers"."n" FROM "numbers" WHERE "numbers"."n" = ?), "normal_users" AS (SELECT "users"."id", "users"."name" FROM "users") SELECT "main"."n", "main"."id" FROM "main";');
|
|
96
|
+
expect(query.toSql(mysql)).toBe('WITH RECURSIVE `recursive_numbers` AS (SELECT `numbers`.`n` FROM `numbers` WHERE `numbers`.`n` = ?), `normal_users` AS (SELECT `users`.`id`, `users`.`name` FROM `users`) SELECT `main`.`n`, `main`.`id` FROM `main`;');
|
|
97
|
+
|
|
98
|
+
// MSSQL should NOT have RECURSIVE keyword at all
|
|
99
|
+
expect(query.toSql(mssql)).toBe('WITH [recursive_numbers] AS (SELECT [numbers].[n] FROM [numbers] WHERE [numbers].[n] = @p1), [normal_users] AS (SELECT [users].[id], [users].[name] FROM [users]) SELECT [main].[n], [main].[id] FROM [main];');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should handle multiple non-recursive CTEs without RECURSIVE keyword', () => {
|
|
103
|
+
const users = table('users');
|
|
104
|
+
const orders = table('orders');
|
|
105
|
+
|
|
106
|
+
const cte1 = new SelectQueryBuilder(users).selectRaw('id');
|
|
107
|
+
const cte2 = new SelectQueryBuilder(orders).selectRaw('total');
|
|
108
|
+
|
|
109
|
+
const query = new SelectQueryBuilder(table('main'))
|
|
110
|
+
.with('u', cte1)
|
|
111
|
+
.with('o', cte2)
|
|
112
|
+
.selectRaw('id');
|
|
113
|
+
|
|
114
|
+
// Should NOT have RECURSIVE keyword
|
|
115
|
+
expect(query.toSql(sqlite)).toBe('WITH "u" AS (SELECT "users"."id" FROM "users"), "o" AS (SELECT "orders"."total" FROM "orders") SELECT "main"."id" FROM "main";');
|
|
116
|
+
expect(query.toSql(mysql)).toBe('WITH `u` AS (SELECT `users`.`id` FROM `users`), `o` AS (SELECT `orders`.`total` FROM `orders`) SELECT `main`.`id` FROM `main`;');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
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, gt, and } from '../src/ast/expression';
|
|
7
|
+
|
|
8
|
+
// Define test schema: Users and Orders
|
|
9
|
+
const Users = defineTable('users', {
|
|
10
|
+
id: col.primaryKey(col.int()),
|
|
11
|
+
name: col.varchar(255),
|
|
12
|
+
email: col.varchar(255),
|
|
13
|
+
}, {});
|
|
14
|
+
|
|
15
|
+
const Orders = defineTable('orders', {
|
|
16
|
+
id: col.primaryKey(col.int()),
|
|
17
|
+
user_id: col.int(),
|
|
18
|
+
status: col.varchar(50),
|
|
19
|
+
total: col.int(),
|
|
20
|
+
}, {});
|
|
21
|
+
|
|
22
|
+
// Define relationships
|
|
23
|
+
Users.relations = {
|
|
24
|
+
orders: {
|
|
25
|
+
type: 'HAS_MANY',
|
|
26
|
+
target: Orders,
|
|
27
|
+
foreignKey: 'user_id',
|
|
28
|
+
localKey: 'id'
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
Orders.relations = {
|
|
33
|
+
user: {
|
|
34
|
+
type: 'BELONGS_TO',
|
|
35
|
+
target: Users,
|
|
36
|
+
foreignKey: 'user_id',
|
|
37
|
+
localKey: 'id'
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const dialect = new SqliteDialect();
|
|
42
|
+
|
|
43
|
+
describe('EXISTS Support', () => {
|
|
44
|
+
describe('whereHas - basic functionality', () => {
|
|
45
|
+
it('should generate EXISTS with simple correlation', () => {
|
|
46
|
+
const query = new SelectQueryBuilder(Users)
|
|
47
|
+
.select({ id: Users.columns.id, name: Users.columns.name })
|
|
48
|
+
.whereHas('orders');
|
|
49
|
+
|
|
50
|
+
const compiled = query.compile(dialect);
|
|
51
|
+
const { sql, params } = compiled;
|
|
52
|
+
|
|
53
|
+
expect(sql).toContain('EXISTS');
|
|
54
|
+
expect(sql).toContain('SELECT 1 FROM');
|
|
55
|
+
expect(sql).toContain('"orders"."user_id" = "users"."id"');
|
|
56
|
+
expect(params).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should support whereHas with filter callback', () => {
|
|
60
|
+
const query = new SelectQueryBuilder(Users)
|
|
61
|
+
.select({ id: Users.columns.id, name: Users.columns.name })
|
|
62
|
+
.whereHas('orders', (ordersQb) =>
|
|
63
|
+
ordersQb.where(eq(Orders.columns.status, 'completed'))
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const compiled = query.compile(dialect);
|
|
67
|
+
const { sql, params } = compiled;
|
|
68
|
+
|
|
69
|
+
expect(sql).toContain('EXISTS');
|
|
70
|
+
expect(sql).toContain('"orders"."status" = ?');
|
|
71
|
+
expect(sql).toContain('"orders"."user_id" = "users"."id"');
|
|
72
|
+
expect(params).toEqual(['completed']);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should support whereHas with multiple filters', () => {
|
|
76
|
+
const query = new SelectQueryBuilder(Users)
|
|
77
|
+
.select({ id: Users.columns.id, name: Users.columns.name })
|
|
78
|
+
.whereHas('orders', (ordersQb) =>
|
|
79
|
+
ordersQb.where(and(
|
|
80
|
+
eq(Orders.columns.status, 'completed'),
|
|
81
|
+
gt(Orders.columns.total, 200)
|
|
82
|
+
))
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const compiled = query.compile(dialect);
|
|
86
|
+
const { sql, params } = compiled;
|
|
87
|
+
|
|
88
|
+
expect(sql).toContain('EXISTS');
|
|
89
|
+
expect(sql).toContain('"orders"."status" = ?');
|
|
90
|
+
expect(sql).toContain('"orders"."total" > ?');
|
|
91
|
+
expect(sql).toContain('"orders"."user_id" = "users"."id"');
|
|
92
|
+
expect(params).toEqual(['completed', 200]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('whereHasNot - basic functionality', () => {
|
|
97
|
+
it('should generate NOT EXISTS with simple correlation', () => {
|
|
98
|
+
const query = new SelectQueryBuilder(Users)
|
|
99
|
+
.select({ id: Users.columns.id, name: Users.columns.name })
|
|
100
|
+
.whereHasNot('orders');
|
|
101
|
+
|
|
102
|
+
const compiled = query.compile(dialect);
|
|
103
|
+
const { sql, params } = compiled;
|
|
104
|
+
|
|
105
|
+
expect(sql).toContain('NOT EXISTS');
|
|
106
|
+
expect(sql).toContain('SELECT 1 FROM');
|
|
107
|
+
expect(sql).toContain('"orders"."user_id" = "users"."id"');
|
|
108
|
+
expect(params).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should support whereHasNot with filter callback', () => {
|
|
112
|
+
const query = new SelectQueryBuilder(Users)
|
|
113
|
+
.select({ id: Users.columns.id, name: Users.columns.name })
|
|
114
|
+
.whereHasNot('orders', (ordersQb) =>
|
|
115
|
+
ordersQb.where(eq(Orders.columns.status, 'pending'))
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const compiled = query.compile(dialect);
|
|
119
|
+
const { sql, params } = compiled;
|
|
120
|
+
|
|
121
|
+
expect(sql).toContain('NOT EXISTS');
|
|
122
|
+
expect(sql).toContain('"orders"."status" = ?');
|
|
123
|
+
expect(sql).toContain('"orders"."user_id" = "users"."id"');
|
|
124
|
+
expect(params).toEqual(['pending']);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { like, notLike } from '../src/ast/expression';
|
|
3
|
+
import { Users } from '../src/playground/features/playground/data/schema';
|
|
4
|
+
import { SqliteDialect } from '../src/dialect/sqlite';
|
|
5
|
+
import { SelectQueryBuilder } from '../src/builder/select';
|
|
6
|
+
|
|
7
|
+
describe('like expressions', () => {
|
|
8
|
+
const dialect = new SqliteDialect();
|
|
9
|
+
|
|
10
|
+
it('compiles LIKE with an escape clause', () => {
|
|
11
|
+
const q = new SelectQueryBuilder(Users)
|
|
12
|
+
.selectRaw('*')
|
|
13
|
+
.where(like(Users.columns.name, 'Admin\\_%', '\\'));
|
|
14
|
+
|
|
15
|
+
const compiled = q.compile(dialect);
|
|
16
|
+
expect(compiled.sql).toBe(
|
|
17
|
+
'SELECT "users"."*" FROM "users" WHERE "users"."name" LIKE ? ESCAPE ?;'
|
|
18
|
+
);
|
|
19
|
+
expect(compiled.params).toEqual(['Admin\\_%', '\\']);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('compiles NOT LIKE with an escape clause', () => {
|
|
23
|
+
const q = new SelectQueryBuilder(Users)
|
|
24
|
+
.selectRaw('*')
|
|
25
|
+
.where(notLike(Users.columns.role, 'admin\\_%', '\\'));
|
|
26
|
+
|
|
27
|
+
const compiled = q.compile(dialect);
|
|
28
|
+
expect(compiled.sql).toBe(
|
|
29
|
+
'SELECT "users"."*" FROM "users" WHERE "users"."role" NOT LIKE ? ESCAPE ?;'
|
|
30
|
+
);
|
|
31
|
+
expect(compiled.params).toEqual(['admin\\_%', '\\']);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { SelectQueryBuilder } from '../src/builder/select';
|
|
3
|
+
import { TableDef } from '../src/schema/table';
|
|
4
|
+
import { eq } from '../src/ast/expression';
|
|
5
|
+
import { SqliteDialect } from '../src/dialect/sqlite';
|
|
6
|
+
import { MySqlDialect } from '../src/dialect/mysql';
|
|
7
|
+
import { SqlServerDialect } from '../src/dialect/mssql';
|
|
8
|
+
|
|
9
|
+
const Users: TableDef = {
|
|
10
|
+
name: 'users',
|
|
11
|
+
columns: {
|
|
12
|
+
id: { name: 'id', type: 'integer' },
|
|
13
|
+
name: { name: 'name', type: 'text' }
|
|
14
|
+
},
|
|
15
|
+
relations: {
|
|
16
|
+
orders: {
|
|
17
|
+
type: 'HAS_MANY',
|
|
18
|
+
target: {
|
|
19
|
+
name: 'orders',
|
|
20
|
+
columns: {
|
|
21
|
+
id: { name: 'id', type: 'integer' },
|
|
22
|
+
user_id: { name: 'user_id', type: 'integer' },
|
|
23
|
+
total: { name: 'total', type: 'integer' }
|
|
24
|
+
},
|
|
25
|
+
relations: {}
|
|
26
|
+
},
|
|
27
|
+
foreignKey: 'user_id',
|
|
28
|
+
localKey: 'id'
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const Orders = Users.relations.orders.target;
|
|
34
|
+
|
|
35
|
+
describe('RIGHT JOIN Support', () => {
|
|
36
|
+
it('should generate correct SQL for manual rightJoin', () => {
|
|
37
|
+
const qb = new SelectQueryBuilder(Users)
|
|
38
|
+
.select({
|
|
39
|
+
user: { ...Users.columns.name, table: 'users' },
|
|
40
|
+
total: { ...Orders.columns.total, table: 'orders' }
|
|
41
|
+
})
|
|
42
|
+
.rightJoin(Orders, eq({ ...Users.columns.id, table: 'users' }, { ...Orders.columns.user_id, table: 'orders' }));
|
|
43
|
+
|
|
44
|
+
const sqlite = new SqliteDialect();
|
|
45
|
+
expect(qb.toSql(sqlite)).toBe('SELECT "users"."name" AS "user", "orders"."total" AS "total" FROM "users" RIGHT JOIN "orders" ON "users"."id" = "orders"."user_id";');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should generate correct SQL for manual leftJoin', () => {
|
|
49
|
+
const qb = new SelectQueryBuilder(Users)
|
|
50
|
+
.select({
|
|
51
|
+
user: { ...Users.columns.name, table: 'users' },
|
|
52
|
+
total: { ...Orders.columns.total, table: 'orders' }
|
|
53
|
+
})
|
|
54
|
+
.leftJoin(Orders, eq({ ...Users.columns.id, table: 'users' }, { ...Orders.columns.user_id, table: 'orders' }));
|
|
55
|
+
|
|
56
|
+
const sqlite = new SqliteDialect();
|
|
57
|
+
expect(qb.toSql(sqlite)).toBe('SELECT "users"."name" AS "user", "orders"."total" AS "total" FROM "users" LEFT JOIN "orders" ON "users"."id" = "orders"."user_id";');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should generate correct SQL for joinRelation with RIGHT kind', () => {
|
|
61
|
+
const qb = new SelectQueryBuilder(Users)
|
|
62
|
+
.select({
|
|
63
|
+
user: { ...Users.columns.name, table: 'users' },
|
|
64
|
+
total: { ...Orders.columns.total, table: 'orders' }
|
|
65
|
+
})
|
|
66
|
+
.joinRelation('orders', 'RIGHT');
|
|
67
|
+
|
|
68
|
+
const sqlite = new SqliteDialect();
|
|
69
|
+
expect(qb.toSql(sqlite)).toBe('SELECT "users"."name" AS "user", "orders"."total" AS "total" FROM "users" RIGHT JOIN "orders" ON "orders"."user_id" = "users"."id";');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should work with MySQL dialect', () => {
|
|
73
|
+
const qb = new SelectQueryBuilder(Users)
|
|
74
|
+
.select({ user: { ...Users.columns.name, table: 'users' } })
|
|
75
|
+
.rightJoin(Orders, eq({ ...Users.columns.id, table: 'users' }, { ...Orders.columns.user_id, table: 'orders' }));
|
|
76
|
+
|
|
77
|
+
const mysql = new MySqlDialect();
|
|
78
|
+
expect(qb.toSql(mysql)).toBe('SELECT `users`.`name` AS `user` FROM `users` RIGHT JOIN `orders` ON `users`.`id` = `orders`.`user_id`;');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should work with MSSQL dialect', () => {
|
|
82
|
+
const qb = new SelectQueryBuilder(Users)
|
|
83
|
+
.select({ user: { ...Users.columns.name, table: 'users' } })
|
|
84
|
+
.rightJoin(Orders, eq({ ...Users.columns.id, table: 'users' }, { ...Orders.columns.user_id, table: 'orders' }));
|
|
85
|
+
|
|
86
|
+
const mssql = new SqlServerDialect();
|
|
87
|
+
expect(qb.toSql(mssql)).toBe('SELECT [users].[name] AS [user] FROM [users] RIGHT JOIN [orders] ON [users].[id] = [orders].[user_id];');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { SelectQueryBuilder } from '../src/builder/select';
|
|
3
|
+
import { SqliteDialect } from '../src/dialect/sqlite';
|
|
4
|
+
import { MySqlDialect } from '../src/dialect/mysql';
|
|
5
|
+
import { SqlServerDialect } from '../src/dialect/mssql';
|
|
6
|
+
import { TableDef, defineTable } from '../src/schema/table';
|
|
7
|
+
import { col } from '../src/schema/column';
|
|
8
|
+
import { eq, gt, lt, avg, count, sum } from '../src/ast/expression';
|
|
9
|
+
|
|
10
|
+
// Define test schema
|
|
11
|
+
const Users = defineTable('users', {
|
|
12
|
+
id: col.primaryKey(col.int()),
|
|
13
|
+
name: col.varchar(255),
|
|
14
|
+
age: col.int(),
|
|
15
|
+
}, {});
|
|
16
|
+
|
|
17
|
+
const Orders = defineTable('orders', {
|
|
18
|
+
id: col.primaryKey(col.int()),
|
|
19
|
+
user_id: col.int(),
|
|
20
|
+
status: col.varchar(50),
|
|
21
|
+
total: col.int(),
|
|
22
|
+
}, {});
|
|
23
|
+
|
|
24
|
+
const Profiles = defineTable('profiles', {
|
|
25
|
+
id: col.primaryKey(col.int()),
|
|
26
|
+
user_id: col.int(),
|
|
27
|
+
display_name: col.varchar(255),
|
|
28
|
+
}, {});
|
|
29
|
+
|
|
30
|
+
const Contributions = defineTable('contributions', {
|
|
31
|
+
id: col.primaryKey(col.int()),
|
|
32
|
+
project_id: col.int(),
|
|
33
|
+
hours: col.int(),
|
|
34
|
+
}, {});
|
|
35
|
+
|
|
36
|
+
const sqlite = new SqliteDialect();
|
|
37
|
+
const mysql = new MySqlDialect();
|
|
38
|
+
const sqlserver = new SqlServerDialect();
|
|
39
|
+
|
|
40
|
+
describe('Scalar Subqueries', () => {
|
|
41
|
+
describe('selectSubquery in SELECT projection', () => {
|
|
42
|
+
it('should add scalar subquery to SELECT (SQLite)', () => {
|
|
43
|
+
const profileSubquery = new SelectQueryBuilder(Profiles)
|
|
44
|
+
.select({ display_name: Profiles.columns.display_name })
|
|
45
|
+
.where(eq(Profiles.columns.user_id, Users.columns.id));
|
|
46
|
+
|
|
47
|
+
const query = new SelectQueryBuilder(Users)
|
|
48
|
+
.select({ id: Users.columns.id })
|
|
49
|
+
.selectSubquery('profile_name', profileSubquery);
|
|
50
|
+
|
|
51
|
+
const sql = query.toSql(sqlite);
|
|
52
|
+
|
|
53
|
+
expect(sql).toContain('(SELECT');
|
|
54
|
+
expect(sql).toContain('"profiles"."display_name"');
|
|
55
|
+
expect(sql).toContain('AS "profile_name"');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should support multiple scalar subqueries', () => {
|
|
59
|
+
const orderCountSub = new SelectQueryBuilder(Orders)
|
|
60
|
+
.select({ cnt: count(Orders.columns.id) })
|
|
61
|
+
.where(eq(Orders.columns.user_id, Users.columns.id));
|
|
62
|
+
|
|
63
|
+
const totalSpentSub = new SelectQueryBuilder(Orders)
|
|
64
|
+
.select({ total: sum(Orders.columns.total) })
|
|
65
|
+
.where(eq(Orders.columns.user_id, Users.columns.id));
|
|
66
|
+
|
|
67
|
+
const query = new SelectQueryBuilder(Users)
|
|
68
|
+
.select({ id: Users.columns.id })
|
|
69
|
+
.selectSubquery('order_count', orderCountSub)
|
|
70
|
+
.selectSubquery('total_spent', totalSpentSub);
|
|
71
|
+
|
|
72
|
+
const sql = query.toSql(sqlite);
|
|
73
|
+
expect(sql).toContain('AS "order_count"');
|
|
74
|
+
expect(sql).toContain('AS "total_spent"');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('Scalar subquery in WHERE clause', () => {
|
|
79
|
+
it('should support scalar subquery with gt operator', () => {
|
|
80
|
+
const avgAgeSub = new SelectQueryBuilder(Users)
|
|
81
|
+
.select({ avg_age: avg(Users.columns.age) });
|
|
82
|
+
|
|
83
|
+
const query = new SelectQueryBuilder(Users)
|
|
84
|
+
.select({ id: Users.columns.id, age: Users.columns.age })
|
|
85
|
+
.where(gt(Users.columns.age, {
|
|
86
|
+
type: 'ScalarSubquery',
|
|
87
|
+
query: avgAgeSub.getAST()
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
const sql = query.toSql(sqlite);
|
|
91
|
+
expect(sql).toContain('"users"."age" >');
|
|
92
|
+
expect(sql).toContain('(SELECT');
|
|
93
|
+
expect(sql).toContain('AVG("users"."age")');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('HAVING Clause', () => {
|
|
99
|
+
describe('Basic HAVING with aggregates', () => {
|
|
100
|
+
it('should add HAVING clause with COUNT (SQLite)', () => {
|
|
101
|
+
const query = new SelectQueryBuilder(Contributions)
|
|
102
|
+
.select({ project_id: Contributions.columns.project_id, cnt: count(Contributions.columns.id) })
|
|
103
|
+
.groupBy(Contributions.columns.project_id)
|
|
104
|
+
.having(gt(count(Contributions.columns.id), 10));
|
|
105
|
+
|
|
106
|
+
const compiled = query.compile(sqlite);
|
|
107
|
+
const { sql, params } = compiled;
|
|
108
|
+
|
|
109
|
+
expect(sql).toContain('GROUP BY');
|
|
110
|
+
expect(sql).toContain('HAVING');
|
|
111
|
+
expect(sql).toContain('COUNT("contributions"."id") > ?');
|
|
112
|
+
expect(params).toEqual([10]);
|
|
113
|
+
|
|
114
|
+
const groupByIndex = sql.indexOf('GROUP BY');
|
|
115
|
+
const havingIndex = sql.indexOf('HAVING');
|
|
116
|
+
expect(havingIndex).toBeGreaterThan(groupByIndex);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should add HAVING clause with SUM (MySQL)', () => {
|
|
120
|
+
const query = new SelectQueryBuilder(Contributions)
|
|
121
|
+
.select({ project_id: Contributions.columns.project_id, hours: sum(Contributions.columns.hours) })
|
|
122
|
+
.groupBy(Contributions.columns.project_id)
|
|
123
|
+
.having(gt(sum(Contributions.columns.hours), 100));
|
|
124
|
+
|
|
125
|
+
const compiled = query.compile(mysql);
|
|
126
|
+
const { sql, params } = compiled;
|
|
127
|
+
|
|
128
|
+
expect(sql).toContain('GROUP BY');
|
|
129
|
+
expect(sql).toContain('HAVING');
|
|
130
|
+
expect(sql).toContain('SUM(`contributions`.`hours`) > ?');
|
|
131
|
+
expect(params).toEqual([100]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should add HAVING clause with AVG (SQL Server)', () => {
|
|
135
|
+
const query = new SelectQueryBuilder(Orders)
|
|
136
|
+
.select({ user_id: Orders.columns.user_id, avg_total: avg(Orders.columns.total) })
|
|
137
|
+
.groupBy(Orders.columns.user_id)
|
|
138
|
+
.having(gt(avg(Orders.columns.total), 500));
|
|
139
|
+
|
|
140
|
+
const compiled = query.compile(sqlserver);
|
|
141
|
+
const { sql, params } = compiled;
|
|
142
|
+
|
|
143
|
+
expect(sql).toContain('GROUP BY');
|
|
144
|
+
expect(sql).toContain('HAVING');
|
|
145
|
+
expect(sql).toContain('AVG([orders].[total]) > @p1');
|
|
146
|
+
expect(params).toEqual([500]);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('Multiple HAVING conditions', () => {
|
|
151
|
+
it('should combine multiple HAVING calls with AND', () => {
|
|
152
|
+
const query = new SelectQueryBuilder(Contributions)
|
|
153
|
+
.select({ project_id: Contributions.columns.project_id, cnt: count(Contributions.columns.id) })
|
|
154
|
+
.groupBy(Contributions.columns.project_id)
|
|
155
|
+
.having(gt(count(Contributions.columns.id), 5))
|
|
156
|
+
.having(lt(sum(Contributions.columns.hours), 200));
|
|
157
|
+
|
|
158
|
+
const compiled = query.compile(sqlite);
|
|
159
|
+
const { sql, params } = compiled;
|
|
160
|
+
|
|
161
|
+
expect(sql).toContain('HAVING');
|
|
162
|
+
expect(sql).toContain('AND');
|
|
163
|
+
expect(sql).toContain('COUNT("contributions"."id") > ?');
|
|
164
|
+
expect(sql).toContain('SUM("contributions"."hours") < ?');
|
|
165
|
+
expect(params).toEqual([5, 200]);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('SQL clause ordering', () => {
|
|
170
|
+
it('should maintain correct clause order: WHERE > GROUP BY > HAVING > ORDER BY', () => {
|
|
171
|
+
const query = new SelectQueryBuilder(Orders)
|
|
172
|
+
.select({ status: Orders.columns.status, cnt: count(Orders.columns.id) })
|
|
173
|
+
.where(gt(Orders.columns.total, 0))
|
|
174
|
+
.groupBy(Orders.columns.status)
|
|
175
|
+
.having(gt(count(Orders.columns.id), 10))
|
|
176
|
+
.orderBy(Orders.columns.status, 'ASC');
|
|
177
|
+
|
|
178
|
+
const compiled = query.compile(sqlite);
|
|
179
|
+
const { sql, params } = compiled;
|
|
180
|
+
|
|
181
|
+
const whereIdx = sql.indexOf('WHERE');
|
|
182
|
+
const groupByIdx = sql.indexOf('GROUP BY');
|
|
183
|
+
const havingIdx = sql.indexOf('HAVING');
|
|
184
|
+
const orderByIdx = sql.indexOf('ORDER BY');
|
|
185
|
+
|
|
186
|
+
expect(whereIdx).toBeGreaterThan(-1);
|
|
187
|
+
expect(groupByIdx).toBeGreaterThan(whereIdx);
|
|
188
|
+
expect(havingIdx).toBeGreaterThan(groupByIdx);
|
|
189
|
+
expect(orderByIdx).toBeGreaterThan(havingIdx);
|
|
190
|
+
expect(params).toEqual([0, 10]);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
});
|