metal-orm 1.0.4 → 1.0.5
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/docs/hydration.md +10 -1
- package/package.json +1 -1
- package/src/ast/expression.ts +632 -614
- package/src/ast/query.ts +110 -49
- package/src/builder/delete-query-state.ts +42 -0
- package/src/builder/delete.ts +57 -0
- package/src/builder/hydration-manager.ts +3 -2
- package/src/builder/hydration-planner.ts +89 -33
- package/src/builder/insert-query-state.ts +62 -0
- package/src/builder/insert.ts +59 -0
- package/src/builder/operations/relation-manager.ts +1 -23
- package/src/builder/relation-conditions.ts +45 -1
- package/src/builder/relation-service.ts +81 -18
- package/src/builder/relation-types.ts +15 -0
- package/src/builder/relation-utils.ts +12 -0
- package/src/builder/select.ts +2 -1
- package/src/builder/update-query-state.ts +59 -0
- package/src/builder/update.ts +61 -0
- package/src/dialect/abstract.ts +107 -47
- package/src/dialect/mssql/index.ts +31 -6
- package/src/dialect/mysql/index.ts +31 -6
- package/src/dialect/postgres/index.ts +45 -6
- package/src/dialect/sqlite/index.ts +45 -6
- package/src/index.ts +6 -3
- package/src/playground/features/playground/data/scenarios/hydration.ts +23 -11
- package/src/playground/features/playground/data/schema.ts +10 -6
- package/src/runtime/hydration.ts +17 -5
- package/src/schema/relation.ts +59 -18
- package/tests/belongs-to-many.test.ts +57 -0
- package/tests/dml.test.ts +206 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { CompilerContext, Dialect } from '../abstract';
|
|
2
|
-
import { SelectQueryNode } from '../../ast/query';
|
|
3
|
-
import { JsonPathNode } from '../../ast/expression';
|
|
1
|
+
import { CompilerContext, Dialect } from '../abstract';
|
|
2
|
+
import { SelectQueryNode, InsertQueryNode, UpdateQueryNode, DeleteQueryNode } from '../../ast/query';
|
|
3
|
+
import { JsonPathNode, ColumnNode } from '../../ast/expression';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* SQLite dialect implementation
|
|
@@ -39,7 +39,7 @@ export class SqliteDialect extends Dialect {
|
|
|
39
39
|
* @param ctx - Compiler context
|
|
40
40
|
* @returns SQLite SQL string
|
|
41
41
|
*/
|
|
42
|
-
protected compileSelectAst(ast: SelectQueryNode, ctx: CompilerContext): string {
|
|
42
|
+
protected compileSelectAst(ast: SelectQueryNode, ctx: CompilerContext): string {
|
|
43
43
|
const columns = ast.columns.map(c => {
|
|
44
44
|
let expr = '';
|
|
45
45
|
if (c.type === 'Function') {
|
|
@@ -102,6 +102,45 @@ export class SqliteDialect extends Dialect {
|
|
|
102
102
|
})()
|
|
103
103
|
: '';
|
|
104
104
|
|
|
105
|
-
return `${ctes}SELECT ${distinct}${columns} FROM ${from}${joins ? ' ' + joins : ''}${whereClause}${groupBy}${having}${orderBy}${limit}${offset};`;
|
|
106
|
-
}
|
|
105
|
+
return `${ctes}SELECT ${distinct}${columns} FROM ${from}${joins ? ' ' + joins : ''}${whereClause}${groupBy}${having}${orderBy}${limit}${offset};`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
protected compileInsertAst(ast: InsertQueryNode, ctx: CompilerContext): string {
|
|
109
|
+
const table = this.quoteIdentifier(ast.into.name);
|
|
110
|
+
const columnList = ast.columns.map(column => `${this.quoteIdentifier(column.table)}.${this.quoteIdentifier(column.name)}`).join(', ');
|
|
111
|
+
const values = ast.values.map(row => `(${row.map(value => this.compileOperand(value, ctx)).join(', ')})`).join(', ');
|
|
112
|
+
const returning = this.compileReturning(ast.returning, ctx);
|
|
113
|
+
return `INSERT INTO ${table} (${columnList}) VALUES ${values}${returning};`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
protected compileUpdateAst(ast: UpdateQueryNode, ctx: CompilerContext): string {
|
|
117
|
+
const table = this.quoteIdentifier(ast.table.name);
|
|
118
|
+
const assignments = ast.set.map(assignment => {
|
|
119
|
+
const col = assignment.column;
|
|
120
|
+
const target = `${this.quoteIdentifier(col.table)}.${this.quoteIdentifier(col.name)}`;
|
|
121
|
+
const value = this.compileOperand(assignment.value, ctx);
|
|
122
|
+
return `${target} = ${value}`;
|
|
123
|
+
}).join(', ');
|
|
124
|
+
const whereClause = this.compileWhere(ast.where, ctx);
|
|
125
|
+
const returning = this.compileReturning(ast.returning, ctx);
|
|
126
|
+
return `UPDATE ${table} SET ${assignments}${whereClause}${returning};`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
protected compileDeleteAst(ast: DeleteQueryNode, ctx: CompilerContext): string {
|
|
130
|
+
const table = this.quoteIdentifier(ast.from.name);
|
|
131
|
+
const whereClause = this.compileWhere(ast.where, ctx);
|
|
132
|
+
const returning = this.compileReturning(ast.returning, ctx);
|
|
133
|
+
return `DELETE FROM ${table}${whereClause}${returning};`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
protected compileReturning(returning: ColumnNode[] | undefined, ctx: CompilerContext): string {
|
|
137
|
+
if (!returning || returning.length === 0) return '';
|
|
138
|
+
const columns = returning
|
|
139
|
+
.map(column => {
|
|
140
|
+
const tablePart = column.table ? `${this.quoteIdentifier(column.table)}.` : '';
|
|
141
|
+
return `${tablePart}${this.quoteIdentifier(column.name)}`;
|
|
142
|
+
})
|
|
143
|
+
.join(', ');
|
|
144
|
+
return ` RETURNING ${columns}`;
|
|
145
|
+
}
|
|
107
146
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
|
|
2
2
|
export * from './schema/table';
|
|
3
3
|
export * from './schema/column';
|
|
4
|
-
export * from './schema/relation';
|
|
5
|
-
export * from './builder/select';
|
|
6
|
-
export * from './
|
|
4
|
+
export * from './schema/relation';
|
|
5
|
+
export * from './builder/select';
|
|
6
|
+
export * from './builder/insert';
|
|
7
|
+
export * from './builder/update';
|
|
8
|
+
export * from './builder/delete';
|
|
9
|
+
export * from './ast/expression';
|
|
7
10
|
export * from './dialect/mysql';
|
|
8
11
|
export * from './dialect/mssql';
|
|
9
12
|
export * from './dialect/sqlite';
|
|
@@ -2,14 +2,26 @@ import { eq } from '../../../../../ast/expression';
|
|
|
2
2
|
import { Users } from '../schema';
|
|
3
3
|
import { createScenario } from './types';
|
|
4
4
|
|
|
5
|
-
export const HYDRATION_SCENARIOS = [
|
|
6
|
-
createScenario({
|
|
7
|
-
id: 'user_with_orders',
|
|
8
|
-
category: 'Hydration',
|
|
9
|
-
title: 'Nested User Graph',
|
|
10
|
-
description: 'Eager-load orders for a single user and hydrate a JSON graph from flat SQL rows.',
|
|
11
|
-
build: (qb) => qb
|
|
12
|
-
.include('orders', { columns: ['id', 'total', 'status', 'user_id'] })
|
|
13
|
-
.where(eq(Users.columns.id, 1))
|
|
14
|
-
})
|
|
15
|
-
|
|
5
|
+
export const HYDRATION_SCENARIOS = [
|
|
6
|
+
createScenario({
|
|
7
|
+
id: 'user_with_orders',
|
|
8
|
+
category: 'Hydration',
|
|
9
|
+
title: 'Nested User Graph',
|
|
10
|
+
description: 'Eager-load orders for a single user and hydrate a JSON graph from flat SQL rows.',
|
|
11
|
+
build: (qb) => qb
|
|
12
|
+
.include('orders', { columns: ['id', 'total', 'status', 'user_id'] })
|
|
13
|
+
.where(eq(Users.columns.id, 1))
|
|
14
|
+
}),
|
|
15
|
+
createScenario({
|
|
16
|
+
id: 'user_projects_with_pivot',
|
|
17
|
+
category: 'Hydration',
|
|
18
|
+
title: 'Projects with Pivot Data',
|
|
19
|
+
description: 'Include projects for a user along with pivot metadata stored in `_pivot`.',
|
|
20
|
+
build: (qb) => qb
|
|
21
|
+
.include('projects', {
|
|
22
|
+
columns: ['id', 'name', 'client'],
|
|
23
|
+
pivot: { columns: ['assigned_at', 'role_id'] }
|
|
24
|
+
})
|
|
25
|
+
.where(eq(Users.columns.id, 1))
|
|
26
|
+
})
|
|
27
|
+
];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineTable } from '../../../../schema/table';
|
|
2
2
|
import { col } from '../../../../schema/column';
|
|
3
|
-
import { hasMany, belongsTo } from '../../../../schema/relation';
|
|
3
|
+
import { hasMany, belongsTo, belongsToMany } from '../../../../schema/relation';
|
|
4
4
|
|
|
5
5
|
export const Users = defineTable('users', {
|
|
6
6
|
id: col.primaryKey(col.int()),
|
|
@@ -52,11 +52,15 @@ export const ProjectAssignments = defineTable('project_assignments', {
|
|
|
52
52
|
assigned_at: col.varchar(50)
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
Users.relations = {
|
|
56
|
-
orders: hasMany(Orders, 'user_id'),
|
|
57
|
-
profiles: hasMany(Profiles, 'user_id'),
|
|
58
|
-
userRoles: hasMany(UserRoles, 'user_id')
|
|
59
|
-
|
|
55
|
+
Users.relations = {
|
|
56
|
+
orders: hasMany(Orders, 'user_id'),
|
|
57
|
+
profiles: hasMany(Profiles, 'user_id'),
|
|
58
|
+
userRoles: hasMany(UserRoles, 'user_id'),
|
|
59
|
+
projects: belongsToMany(Projects, ProjectAssignments, {
|
|
60
|
+
pivotForeignKeyToRoot: 'user_id',
|
|
61
|
+
pivotForeignKeyToTarget: 'project_id'
|
|
62
|
+
})
|
|
63
|
+
};
|
|
60
64
|
|
|
61
65
|
Orders.relations = {
|
|
62
66
|
user: belongsTo(Users, 'user_id')
|
package/src/runtime/hydration.ts
CHANGED
|
@@ -26,20 +26,32 @@ export const hydrateRows = (rows: Record<string, any>[], plan?: HydrationPlan):
|
|
|
26
26
|
|
|
27
27
|
const parent = rootMap.get(rootId);
|
|
28
28
|
|
|
29
|
-
plan.relations.forEach(rel => {
|
|
29
|
+
plan.relations.forEach(rel => {
|
|
30
30
|
const childPkKey = makeRelationAlias(rel.aliasPrefix, rel.targetPrimaryKey);
|
|
31
31
|
const childPk = row[childPkKey];
|
|
32
|
-
if (childPk === null || childPk === undefined) return;
|
|
33
|
-
|
|
32
|
+
if (childPk === null || childPk === undefined) return;
|
|
33
|
+
|
|
34
34
|
const bucket = parent[rel.name] as any[];
|
|
35
35
|
if (bucket.some(item => item[rel.targetPrimaryKey] === childPk)) return;
|
|
36
36
|
|
|
37
|
-
const child: Record<string, any> = {};
|
|
37
|
+
const child: Record<string, any> = {};
|
|
38
38
|
rel.columns.forEach(col => {
|
|
39
39
|
const key = makeRelationAlias(rel.aliasPrefix, col);
|
|
40
40
|
child[col] = row[key];
|
|
41
41
|
});
|
|
42
|
-
|
|
42
|
+
|
|
43
|
+
if (rel.pivot) {
|
|
44
|
+
const pivot: Record<string, any> = {};
|
|
45
|
+
rel.pivot.columns.forEach(col => {
|
|
46
|
+
const key = makeRelationAlias(rel.pivot.aliasPrefix, col);
|
|
47
|
+
pivot[col] = row[key];
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (Object.values(pivot).some(v => v !== null && v !== undefined)) {
|
|
51
|
+
(child as any)._pivot = pivot;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
43
55
|
bucket.push(child);
|
|
44
56
|
});
|
|
45
57
|
});
|
package/src/schema/relation.ts
CHANGED
|
@@ -8,6 +8,8 @@ export const RelationKinds = {
|
|
|
8
8
|
HasMany: 'HAS_MANY',
|
|
9
9
|
/** Many-to-one relationship */
|
|
10
10
|
BelongsTo: 'BELONGS_TO',
|
|
11
|
+
/** Many-to-many relationship with pivot metadata */
|
|
12
|
+
BelongsToMany: 'BELONGS_TO_MANY'
|
|
11
13
|
} as const;
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -16,41 +18,50 @@ export const RelationKinds = {
|
|
|
16
18
|
export type RelationType = (typeof RelationKinds)[keyof typeof RelationKinds];
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
|
-
*
|
|
21
|
+
* One-to-many relationship definition
|
|
20
22
|
*/
|
|
21
|
-
interface
|
|
22
|
-
|
|
23
|
+
export interface HasManyRelation {
|
|
24
|
+
type: typeof RelationKinds.HasMany;
|
|
23
25
|
target: TableDef;
|
|
24
|
-
/** Foreign key column name on the child table */
|
|
25
26
|
foreignKey: string;
|
|
26
|
-
/** Local key column name (usually 'id') */
|
|
27
27
|
localKey?: string;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
31
|
+
* Many-to-one relationship definition
|
|
32
32
|
*/
|
|
33
|
-
export interface
|
|
34
|
-
type: typeof RelationKinds.
|
|
33
|
+
export interface BelongsToRelation {
|
|
34
|
+
type: typeof RelationKinds.BelongsTo;
|
|
35
|
+
target: TableDef;
|
|
36
|
+
foreignKey: string;
|
|
37
|
+
localKey?: string;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
/**
|
|
38
|
-
* Many-to-
|
|
41
|
+
* Many-to-many relationship definition with rich pivot metadata
|
|
39
42
|
*/
|
|
40
|
-
export interface
|
|
41
|
-
type: typeof RelationKinds.
|
|
43
|
+
export interface BelongsToManyRelation {
|
|
44
|
+
type: typeof RelationKinds.BelongsToMany;
|
|
45
|
+
target: TableDef;
|
|
46
|
+
pivotTable: TableDef;
|
|
47
|
+
pivotForeignKeyToRoot: string;
|
|
48
|
+
pivotForeignKeyToTarget: string;
|
|
49
|
+
localKey?: string;
|
|
50
|
+
targetKey?: string;
|
|
51
|
+
pivotPrimaryKey?: string;
|
|
52
|
+
defaultPivotColumns?: string[];
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
/**
|
|
45
56
|
* Union type representing any supported relationship definition
|
|
46
57
|
*/
|
|
47
|
-
export type RelationDef = HasManyRelation | BelongsToRelation;
|
|
58
|
+
export type RelationDef = HasManyRelation | BelongsToRelation | BelongsToManyRelation;
|
|
48
59
|
|
|
49
60
|
/**
|
|
50
61
|
* Creates a one-to-many relationship definition
|
|
51
62
|
* @param target - Target table of the relationship
|
|
52
63
|
* @param foreignKey - Foreign key column name on the child table
|
|
53
|
-
* @param localKey - Local key column name (
|
|
64
|
+
* @param localKey - Local key column name (optional)
|
|
54
65
|
* @returns HasManyRelation definition
|
|
55
66
|
*
|
|
56
67
|
* @example
|
|
@@ -58,18 +69,18 @@ export type RelationDef = HasManyRelation | BelongsToRelation;
|
|
|
58
69
|
* hasMany(usersTable, 'user_id')
|
|
59
70
|
* ```
|
|
60
71
|
*/
|
|
61
|
-
export const hasMany = (target: TableDef, foreignKey: string, localKey
|
|
72
|
+
export const hasMany = (target: TableDef, foreignKey: string, localKey?: string): HasManyRelation => ({
|
|
62
73
|
type: RelationKinds.HasMany,
|
|
63
74
|
target,
|
|
64
75
|
foreignKey,
|
|
65
|
-
localKey
|
|
76
|
+
localKey
|
|
66
77
|
});
|
|
67
78
|
|
|
68
79
|
/**
|
|
69
80
|
* Creates a many-to-one relationship definition
|
|
70
81
|
* @param target - Target table of the relationship
|
|
71
82
|
* @param foreignKey - Foreign key column name on the child table
|
|
72
|
-
* @param localKey - Local key column name (
|
|
83
|
+
* @param localKey - Local key column name (optional)
|
|
73
84
|
* @returns BelongsToRelation definition
|
|
74
85
|
*
|
|
75
86
|
* @example
|
|
@@ -77,9 +88,39 @@ export const hasMany = (target: TableDef, foreignKey: string, localKey: string =
|
|
|
77
88
|
* belongsTo(usersTable, 'user_id')
|
|
78
89
|
* ```
|
|
79
90
|
*/
|
|
80
|
-
export const belongsTo = (target: TableDef, foreignKey: string, localKey
|
|
91
|
+
export const belongsTo = (target: TableDef, foreignKey: string, localKey?: string): BelongsToRelation => ({
|
|
81
92
|
type: RelationKinds.BelongsTo,
|
|
82
93
|
target,
|
|
83
94
|
foreignKey,
|
|
84
|
-
localKey
|
|
95
|
+
localKey
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Creates a many-to-many relationship definition with pivot metadata
|
|
100
|
+
* @param target - Target table
|
|
101
|
+
* @param pivotTable - Intermediate pivot table definition
|
|
102
|
+
* @param options - Pivot metadata configuration
|
|
103
|
+
* @returns BelongsToManyRelation definition
|
|
104
|
+
*/
|
|
105
|
+
export const belongsToMany = (
|
|
106
|
+
target: TableDef,
|
|
107
|
+
pivotTable: TableDef,
|
|
108
|
+
options: {
|
|
109
|
+
pivotForeignKeyToRoot: string;
|
|
110
|
+
pivotForeignKeyToTarget: string;
|
|
111
|
+
localKey?: string;
|
|
112
|
+
targetKey?: string;
|
|
113
|
+
pivotPrimaryKey?: string;
|
|
114
|
+
defaultPivotColumns?: string[];
|
|
115
|
+
}
|
|
116
|
+
): BelongsToManyRelation => ({
|
|
117
|
+
type: RelationKinds.BelongsToMany,
|
|
118
|
+
target,
|
|
119
|
+
pivotTable,
|
|
120
|
+
pivotForeignKeyToRoot: options.pivotForeignKeyToRoot,
|
|
121
|
+
pivotForeignKeyToTarget: options.pivotForeignKeyToTarget,
|
|
122
|
+
localKey: options.localKey,
|
|
123
|
+
targetKey: options.targetKey,
|
|
124
|
+
pivotPrimaryKey: options.pivotPrimaryKey,
|
|
125
|
+
defaultPivotColumns: options.defaultPivotColumns
|
|
85
126
|
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { hydrateRows } from '../src/runtime/hydration';
|
|
3
|
+
import { SelectQueryBuilder } from '../src/builder/select';
|
|
4
|
+
import { SqliteDialect } from '../src/dialect/sqlite';
|
|
5
|
+
import { makeRelationAlias } from '../src/utils/relation-alias';
|
|
6
|
+
import { Users } from '../src/playground/features/playground/data/schema';
|
|
7
|
+
|
|
8
|
+
describe('BelongsToMany hydration', () => {
|
|
9
|
+
it('includes pivot metadata for a projects include', () => {
|
|
10
|
+
const builder = new SelectQueryBuilder(Users).include('projects', {
|
|
11
|
+
columns: ['id', 'name', 'client'],
|
|
12
|
+
pivot: { columns: ['assigned_at', 'role_id'] }
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const compiled = builder.compile(new SqliteDialect());
|
|
16
|
+
expect(compiled.sql).toContain('JOIN "project_assignments"');
|
|
17
|
+
expect(compiled.sql).toContain('JOIN "projects"');
|
|
18
|
+
|
|
19
|
+
const plan = builder.getHydrationPlan();
|
|
20
|
+
expect(plan).toBeDefined();
|
|
21
|
+
|
|
22
|
+
const relationPlan = plan!.relations.find(rel => rel.name === 'projects');
|
|
23
|
+
expect(relationPlan).toBeDefined();
|
|
24
|
+
expect(relationPlan!.pivot).toBeDefined();
|
|
25
|
+
expect(relationPlan!.pivot!.columns).toEqual(['assigned_at', 'role_id']);
|
|
26
|
+
expect(relationPlan!.pivot!.aliasPrefix).toBe('projects_pivot');
|
|
27
|
+
|
|
28
|
+
const row: Record<string, any> = {};
|
|
29
|
+
plan!.rootColumns.forEach(col => {
|
|
30
|
+
row[col] = col === plan!.rootPrimaryKey ? 1 : `root-${col}`;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
row[makeRelationAlias(relationPlan!.aliasPrefix, relationPlan!.targetPrimaryKey)] = 42;
|
|
34
|
+
relationPlan!.columns.forEach(col => {
|
|
35
|
+
const alias = makeRelationAlias(relationPlan!.aliasPrefix, col);
|
|
36
|
+
row[alias] = col === relationPlan!.targetPrimaryKey ? 42 : `project-${col}`;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
relationPlan!.pivot!.columns.forEach((col, idx) => {
|
|
40
|
+
const alias = makeRelationAlias(relationPlan!.pivot!.aliasPrefix, col);
|
|
41
|
+
row[alias] = `pivot-${col}-${idx}`;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const hydrated = hydrateRows([row], plan);
|
|
45
|
+
expect(hydrated).toHaveLength(1);
|
|
46
|
+
expect(hydrated[0].projects).toHaveLength(1);
|
|
47
|
+
expect(hydrated[0].projects[0]).toEqual({
|
|
48
|
+
id: 42,
|
|
49
|
+
name: 'project-name',
|
|
50
|
+
client: 'project-client',
|
|
51
|
+
_pivot: {
|
|
52
|
+
assigned_at: 'pivot-assigned_at-0',
|
|
53
|
+
role_id: 'pivot-role_id-1'
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { InsertQueryBuilder, UpdateQueryBuilder, DeleteQueryBuilder } from '../src';
|
|
3
|
+
import { Dialect } from '../src/dialect/abstract';
|
|
4
|
+
import { MySqlDialect } from '../src/dialect/mysql';
|
|
5
|
+
import { PostgresDialect } from '../src/dialect/postgres';
|
|
6
|
+
import { SqliteDialect } from '../src/dialect/sqlite';
|
|
7
|
+
import { SqlServerDialect } from '../src/dialect/mssql';
|
|
8
|
+
import { Users } from '../src/playground/features/playground/data/schema';
|
|
9
|
+
import { eq } from '../src/ast/expression';
|
|
10
|
+
import type { ColumnDef } from '../src/schema/column';
|
|
11
|
+
|
|
12
|
+
type Row = {
|
|
13
|
+
name: string;
|
|
14
|
+
role: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
interface DialectCase {
|
|
18
|
+
name: string;
|
|
19
|
+
dialect: Dialect;
|
|
20
|
+
placeholder: (index: number) => string;
|
|
21
|
+
supportsReturning: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const rowOrder: (keyof Row)[] = ['name', 'role'];
|
|
25
|
+
const insertRows: Row[] = [
|
|
26
|
+
{ name: 'alice', role: 'admin' },
|
|
27
|
+
{ name: 'bob', role: 'user' }
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const returningColumns: ColumnDef[] = [Users.columns.id, Users.columns.name];
|
|
31
|
+
const columnColumns: ColumnDef[] = [Users.columns.name, Users.columns.role];
|
|
32
|
+
|
|
33
|
+
const dialectCases: DialectCase[] = [
|
|
34
|
+
{
|
|
35
|
+
name: 'MySQL',
|
|
36
|
+
dialect: new MySqlDialect(),
|
|
37
|
+
placeholder: () => '?',
|
|
38
|
+
supportsReturning: false
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'Postgres',
|
|
42
|
+
dialect: new PostgresDialect(),
|
|
43
|
+
placeholder: () => '?',
|
|
44
|
+
supportsReturning: true
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'SQLite',
|
|
48
|
+
dialect: new SqliteDialect(),
|
|
49
|
+
placeholder: () => '?',
|
|
50
|
+
supportsReturning: true
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'SQL Server',
|
|
54
|
+
dialect: new SqlServerDialect(),
|
|
55
|
+
placeholder: index => `@p${index}`,
|
|
56
|
+
supportsReturning: false
|
|
57
|
+
}
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const qualifyColumn = (dialect: Dialect, column: ColumnDef): string =>
|
|
61
|
+
`${dialect.quoteIdentifier(column.table || Users.name)}.${dialect.quoteIdentifier(column.name)}`;
|
|
62
|
+
|
|
63
|
+
const buildColumnList = (dialect: Dialect, columns: ColumnDef[]): string =>
|
|
64
|
+
columns.map(column => qualifyColumn(dialect, column)).join(', ');
|
|
65
|
+
|
|
66
|
+
const buildReturningClause = (dialect: Dialect, columns: ColumnDef[]): string =>
|
|
67
|
+
columns.length === 0 ? '' : ` RETURNING ${buildColumnList(dialect, columns)}`;
|
|
68
|
+
|
|
69
|
+
const buildValuesClause = (dialectCase: DialectCase, columnCount: number, rowCount: number): string => {
|
|
70
|
+
let index = 1;
|
|
71
|
+
const segments: string[] = [];
|
|
72
|
+
for (let row = 0; row < rowCount; row += 1) {
|
|
73
|
+
const placeholders: string[] = [];
|
|
74
|
+
for (let col = 0; col < columnCount; col += 1) {
|
|
75
|
+
placeholders.push(dialectCase.placeholder(index));
|
|
76
|
+
index += 1;
|
|
77
|
+
}
|
|
78
|
+
segments.push(`(${placeholders.join(', ')})`);
|
|
79
|
+
}
|
|
80
|
+
return segments.join(', ');
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const buildPlaceholderSequence = (dialectCase: DialectCase, count: number, startIndex = 1): string[] =>
|
|
84
|
+
Array.from({ length: count }, (_, idx) => dialectCase.placeholder(startIndex + idx));
|
|
85
|
+
|
|
86
|
+
const flattenRowValues = (rows: Row[], order: (keyof Row)[]): unknown[] =>
|
|
87
|
+
rows.flatMap(row => order.map(key => row[key]));
|
|
88
|
+
|
|
89
|
+
describe('DML builders', () => {
|
|
90
|
+
dialectCases.forEach(dialectCase => {
|
|
91
|
+
describe(dialectCase.name, () => {
|
|
92
|
+
const dialect = dialectCase.dialect;
|
|
93
|
+
const tableName = Users.name;
|
|
94
|
+
const qualifiedColumns = buildColumnList(dialect, columnColumns);
|
|
95
|
+
const returningSql = buildReturningClause(dialect, returningColumns);
|
|
96
|
+
|
|
97
|
+
it('compiles single-row insert', () => {
|
|
98
|
+
const query = new InsertQueryBuilder(Users).values(insertRows[0]);
|
|
99
|
+
const compiled = query.compile(dialect);
|
|
100
|
+
const valueClause = `(${dialectCase.placeholder(1)}, ${dialectCase.placeholder(2)})`;
|
|
101
|
+
const expectedSql = `INSERT INTO ${dialect.quoteIdentifier(tableName)} (${qualifiedColumns}) VALUES ${valueClause};`;
|
|
102
|
+
expect(compiled.sql).toBe(expectedSql);
|
|
103
|
+
expect(compiled.params).toEqual([insertRows[0].name, insertRows[0].role]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('compiles multi-row insert with consistent parameter order', () => {
|
|
107
|
+
const query = new InsertQueryBuilder(Users).values(insertRows);
|
|
108
|
+
const compiled = query.compile(dialect);
|
|
109
|
+
const valuesClause = buildValuesClause(dialectCase, columnColumns.length, insertRows.length);
|
|
110
|
+
const expectedSql = `INSERT INTO ${dialect.quoteIdentifier(tableName)} (${qualifiedColumns}) VALUES ${valuesClause};`;
|
|
111
|
+
expect(compiled.sql).toBe(expectedSql);
|
|
112
|
+
expect(compiled.params).toEqual(flattenRowValues(insertRows, rowOrder));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (dialectCase.supportsReturning) {
|
|
116
|
+
it('appends RETURNING for insert when requested', () => {
|
|
117
|
+
const query = new InsertQueryBuilder(Users)
|
|
118
|
+
.values(insertRows[0])
|
|
119
|
+
.returning(Users.columns.id, Users.columns.name);
|
|
120
|
+
const compiled = query.compile(dialect);
|
|
121
|
+
const valueClause = `(${dialectCase.placeholder(1)}, ${dialectCase.placeholder(2)})`;
|
|
122
|
+
const expectedSql = `INSERT INTO ${dialect.quoteIdentifier(tableName)} (${qualifiedColumns}) VALUES ${valueClause}${returningSql};`;
|
|
123
|
+
expect(compiled.sql).toBe(expectedSql);
|
|
124
|
+
expect(compiled.params).toEqual([insertRows[0].name, insertRows[0].role]);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
it('compiles update with SET values', () => {
|
|
129
|
+
const updateValues = { name: 'ali', role: 'builder' };
|
|
130
|
+
const query = new UpdateQueryBuilder(Users).set(updateValues);
|
|
131
|
+
const compiled = query.compile(dialect);
|
|
132
|
+
const placeholderSeq = buildPlaceholderSequence(dialectCase, columnColumns.length);
|
|
133
|
+
const assignments = columnColumns
|
|
134
|
+
.map((column, idx) => `${qualifyColumn(dialect, column)} = ${placeholderSeq[idx]}`)
|
|
135
|
+
.join(', ');
|
|
136
|
+
const expectedSql = `UPDATE ${dialect.quoteIdentifier(tableName)} SET ${assignments};`;
|
|
137
|
+
expect(compiled.sql).toBe(expectedSql);
|
|
138
|
+
expect(compiled.params).toEqual([updateValues.name, updateValues.role]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('compiles update with WHERE clause', () => {
|
|
142
|
+
const updateValues = { name: 'gold', role: 'star' };
|
|
143
|
+
const query = new UpdateQueryBuilder(Users)
|
|
144
|
+
.set(updateValues)
|
|
145
|
+
.where(eq(Users.columns.id, 1));
|
|
146
|
+
const compiled = query.compile(dialect);
|
|
147
|
+
const assignmentPlaceholders = buildPlaceholderSequence(dialectCase, columnColumns.length);
|
|
148
|
+
const assignments = columnColumns
|
|
149
|
+
.map((column, idx) => `${qualifyColumn(dialect, column)} = ${assignmentPlaceholders[idx]}`)
|
|
150
|
+
.join(', ');
|
|
151
|
+
const wherePlaceholder = dialectCase.placeholder(columnColumns.length + 1);
|
|
152
|
+
const whereClause = ` WHERE ${qualifyColumn(dialect, Users.columns.id)} = ${wherePlaceholder}`;
|
|
153
|
+
const expectedSql = `UPDATE ${dialect.quoteIdentifier(tableName)} SET ${assignments}${whereClause};`;
|
|
154
|
+
expect(compiled.sql).toBe(expectedSql);
|
|
155
|
+
expect(compiled.params).toEqual([updateValues.name, updateValues.role, 1]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (dialectCase.supportsReturning) {
|
|
159
|
+
it('appends RETURNING for update when requested', () => {
|
|
160
|
+
const query = new UpdateQueryBuilder(Users)
|
|
161
|
+
.set({ name: 'return' })
|
|
162
|
+
.returning(Users.columns.id, Users.columns.name);
|
|
163
|
+
const compiled = query.compile(dialect);
|
|
164
|
+
const placeholder = dialectCase.placeholder(1);
|
|
165
|
+
const assignment = `${qualifyColumn(dialect, Users.columns.name)} = ${placeholder}`;
|
|
166
|
+
const expectedSql = `UPDATE ${dialect.quoteIdentifier(tableName)} SET ${assignment}${returningSql};`;
|
|
167
|
+
expect(compiled.sql).toBe(expectedSql);
|
|
168
|
+
expect(compiled.params).toEqual(['return']);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
it('compiles DELETE without WHERE', () => {
|
|
173
|
+
const query = new DeleteQueryBuilder(Users);
|
|
174
|
+
const compiled = query.compile(dialect);
|
|
175
|
+
const expectedSql = `DELETE FROM ${dialect.quoteIdentifier(tableName)};`;
|
|
176
|
+
expect(compiled.sql).toBe(expectedSql);
|
|
177
|
+
expect(compiled.params).toEqual([]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('compiles DELETE with WHERE clause', () => {
|
|
181
|
+
const query = new DeleteQueryBuilder(Users).where(eq(Users.columns.id, 7));
|
|
182
|
+
const compiled = query.compile(dialect);
|
|
183
|
+
const wherePlaceholder = dialectCase.placeholder(1);
|
|
184
|
+
const whereClause = ` WHERE ${qualifyColumn(dialect, Users.columns.id)} = ${wherePlaceholder}`;
|
|
185
|
+
const expectedSql = `DELETE FROM ${dialect.quoteIdentifier(tableName)}${whereClause};`;
|
|
186
|
+
expect(compiled.sql).toBe(expectedSql);
|
|
187
|
+
expect(compiled.params).toEqual([7]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
if (dialectCase.supportsReturning) {
|
|
191
|
+
it('appends RETURNING for delete when requested', () => {
|
|
192
|
+
const query = new DeleteQueryBuilder(Users)
|
|
193
|
+
.where(eq(Users.columns.id, 11))
|
|
194
|
+
.returning(Users.columns.id, Users.columns.name);
|
|
195
|
+
const compiled = query.compile(dialect);
|
|
196
|
+
const wherePlaceholder = dialectCase.placeholder(1);
|
|
197
|
+
const whereClause = ` WHERE ${qualifyColumn(dialect, Users.columns.id)} = ${wherePlaceholder}`;
|
|
198
|
+
const expectedSql = `DELETE FROM ${dialect.quoteIdentifier(tableName)}${whereClause}${returningSql};`;
|
|
199
|
+
expect(compiled.sql).toBe(expectedSql);
|
|
200
|
+
expect(compiled.params).toEqual([11]);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|