metal-orm 1.1.3 → 1.1.4
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 +715 -703
- package/dist/index.cjs +655 -75
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +170 -8
- package/dist/index.d.ts +170 -8
- package/dist/index.js +649 -75
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/scripts/naming-strategy.mjs +16 -1
- package/src/core/ast/procedure.ts +21 -0
- package/src/core/ast/query.ts +47 -19
- package/src/core/ddl/introspect/utils.ts +56 -56
- package/src/core/dialect/abstract.ts +560 -547
- package/src/core/dialect/base/sql-dialect.ts +43 -29
- package/src/core/dialect/mssql/index.ts +369 -232
- package/src/core/dialect/mysql/index.ts +99 -7
- package/src/core/dialect/postgres/index.ts +121 -60
- package/src/core/dialect/sqlite/index.ts +97 -64
- package/src/core/execution/db-executor.ts +108 -90
- package/src/core/execution/executors/mssql-executor.ts +28 -24
- package/src/core/execution/executors/mysql-executor.ts +62 -27
- package/src/core/execution/executors/sqlite-executor.ts +10 -9
- package/src/index.ts +9 -6
- package/src/orm/execute-procedure.ts +77 -0
- package/src/orm/execute.ts +74 -73
- package/src/orm/interceptor-pipeline.ts +21 -17
- package/src/orm/pooled-executor-factory.ts +41 -20
- package/src/orm/unit-of-work.ts +6 -4
- package/src/query/index.ts +8 -5
- package/src/query-builder/delete.ts +3 -2
- package/src/query-builder/insert-query-state.ts +47 -19
- package/src/query-builder/insert.ts +142 -28
- package/src/query-builder/procedure-call.ts +122 -0
- package/src/query-builder/select/select-operations.ts +5 -2
- package/src/query-builder/select.ts +1146 -1105
- package/src/query-builder/update.ts +3 -2
- package/src/tree/tree-manager.ts +754 -754
package/src/tree/tree-manager.ts
CHANGED
|
@@ -1,754 +1,754 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tree Manager
|
|
3
|
-
*
|
|
4
|
-
* Level 2 API: ORM runtime integration for tree operations.
|
|
5
|
-
* Provides session-aware tree manipulation with Unit of Work integration.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { OrmSession } from '../orm/orm-session.js';
|
|
9
|
-
import type { DbExecutor } from '../core/execution/db-executor.js';
|
|
10
|
-
import type { Dialect } from '../core/dialect/abstract.js';
|
|
11
|
-
import type { TableDef } from '../schema/table.js';
|
|
12
|
-
import { selectFrom, insertInto, update, deleteFrom } from '../query/index.js';
|
|
13
|
-
import type { QueryResult } from '../core/execution/db-executor.js';
|
|
14
|
-
import { and, eq, gte, lte, ValueOperandInput } from '../core/ast/expression.js';
|
|
15
|
-
|
|
16
|
-
import type {
|
|
17
|
-
TreeConfig,
|
|
18
|
-
NestedSetBounds,
|
|
19
|
-
RecoverResult,
|
|
20
|
-
TreeScope,
|
|
21
|
-
ThreadedNode,
|
|
22
|
-
} from './tree-types.js';
|
|
23
|
-
import {
|
|
24
|
-
resolveTreeConfig,
|
|
25
|
-
validateTreeTable,
|
|
26
|
-
} from './tree-types.js';
|
|
27
|
-
import {
|
|
28
|
-
NestedSetStrategy,
|
|
29
|
-
NodeWithPk,
|
|
30
|
-
buildScopeConditions,
|
|
31
|
-
} from './nested-set-strategy.js';
|
|
32
|
-
import { treeQuery, TreeQuery, threadResults } from './tree-query.js';
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Options for creating a TreeManager.
|
|
36
|
-
*/
|
|
37
|
-
export interface TreeManagerOptions<T extends TableDef> {
|
|
38
|
-
/** Database executor for running queries */
|
|
39
|
-
executor: DbExecutor;
|
|
40
|
-
/** SQL dialect */
|
|
41
|
-
dialect: Dialect;
|
|
42
|
-
/** Table definition */
|
|
43
|
-
table: T;
|
|
44
|
-
/** Tree configuration */
|
|
45
|
-
config?: Partial<TreeConfig>;
|
|
46
|
-
/** Optional scope values for multi-tree tables */
|
|
47
|
-
scope?: TreeScope;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Result of fetching a node with tree metadata.
|
|
52
|
-
*/
|
|
53
|
-
export interface TreeNodeResult<T = Record<string, unknown>> {
|
|
54
|
-
/** The node data */
|
|
55
|
-
data: T;
|
|
56
|
-
/** Nested set bounds */
|
|
57
|
-
lft: number;
|
|
58
|
-
rght: number;
|
|
59
|
-
/** Parent ID (null for roots) */
|
|
60
|
-
parentId: unknown;
|
|
61
|
-
/** Depth level (if available) */
|
|
62
|
-
depth?: number;
|
|
63
|
-
/** Whether this is a leaf node */
|
|
64
|
-
isLeaf: boolean;
|
|
65
|
-
/** Whether this is a root node */
|
|
66
|
-
isRoot: boolean;
|
|
67
|
-
/** Count of descendants */
|
|
68
|
-
childCount: number;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Tree Manager for Level 2 ORM runtime integration.
|
|
73
|
-
* Provides session-aware tree operations with proper change tracking.
|
|
74
|
-
*
|
|
75
|
-
* @typeParam T - The table definition type
|
|
76
|
-
*
|
|
77
|
-
* @example
|
|
78
|
-
* ```ts
|
|
79
|
-
* const manager = new TreeManager({
|
|
80
|
-
* executor: session.executor,
|
|
81
|
-
* dialect: session.dialect,
|
|
82
|
-
* table: categories,
|
|
83
|
-
* config: { parentKey: 'parentId', leftKey: 'lft', rightKey: 'rght' },
|
|
84
|
-
* });
|
|
85
|
-
*
|
|
86
|
-
* const node = await manager.getNode(5);
|
|
87
|
-
* await manager.moveUp(node);
|
|
88
|
-
* ```
|
|
89
|
-
*/
|
|
90
|
-
export class TreeManager<T extends TableDef> {
|
|
91
|
-
readonly table: T;
|
|
92
|
-
readonly config: TreeConfig;
|
|
93
|
-
readonly query: TreeQuery<T>;
|
|
94
|
-
|
|
95
|
-
private readonly executor: DbExecutor;
|
|
96
|
-
private readonly dialect: Dialect;
|
|
97
|
-
private readonly scopeValues: TreeScope;
|
|
98
|
-
private readonly pkName: string;
|
|
99
|
-
|
|
100
|
-
constructor(options: TreeManagerOptions<T>) {
|
|
101
|
-
const { executor, dialect, table, config = {}, scope = {} } = options;
|
|
102
|
-
|
|
103
|
-
this.executor = executor;
|
|
104
|
-
this.dialect = dialect;
|
|
105
|
-
this.table = table;
|
|
106
|
-
this.config = resolveTreeConfig(config);
|
|
107
|
-
this.scopeValues = scope;
|
|
108
|
-
this.pkName = this.getPrimaryKeyName();
|
|
109
|
-
|
|
110
|
-
const validation = validateTreeTable(table, this.config);
|
|
111
|
-
if (!validation.valid) {
|
|
112
|
-
throw new Error(
|
|
113
|
-
`Invalid tree table '${table.name}': missing columns ${validation.missingColumns.join(', ')}`
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
this.query = treeQuery(table, this.config).withScope(scope);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Gets a node by ID with tree metadata.
|
|
122
|
-
*/
|
|
123
|
-
async getNode(id: unknown): Promise<TreeNodeResult | null> {
|
|
124
|
-
const query = this.query.findById(id);
|
|
125
|
-
const { sql, params } = query.compile(this.dialect);
|
|
126
|
-
const results = await this.executor.executeSql(sql, params);
|
|
127
|
-
const rows = queryResultsToRows(results);
|
|
128
|
-
|
|
129
|
-
if (rows.length === 0) return null;
|
|
130
|
-
|
|
131
|
-
return this.createNodeResult(rows[0]);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Gets the root nodes.
|
|
136
|
-
*/
|
|
137
|
-
async getRoots(): Promise<TreeNodeResult[]> {
|
|
138
|
-
const query = this.query.findRoots();
|
|
139
|
-
const { sql, params } = query.compile(this.dialect);
|
|
140
|
-
const results = await this.executor.executeSql(sql, params);
|
|
141
|
-
const rows = queryResultsToRows(results);
|
|
142
|
-
|
|
143
|
-
return rows.map(row => this.createNodeResult(row));
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Gets direct children of a node.
|
|
148
|
-
*/
|
|
149
|
-
async getChildren(parentId: unknown): Promise<TreeNodeResult[]> {
|
|
150
|
-
const query = this.query.findDirectChildren(parentId);
|
|
151
|
-
const { sql, params } = query.compile(this.dialect);
|
|
152
|
-
const results = await this.executor.executeSql(sql, params);
|
|
153
|
-
const rows = queryResultsToRows(results);
|
|
154
|
-
|
|
155
|
-
return rows.map(row => this.createNodeResult(row));
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Gets all descendants of a node.
|
|
160
|
-
*/
|
|
161
|
-
async getDescendants(node: TreeNodeResult | NestedSetBounds): Promise<TreeNodeResult[]> {
|
|
162
|
-
const bounds = this.getBounds(node);
|
|
163
|
-
const query = this.query.findDescendants(bounds);
|
|
164
|
-
const { sql, params } = query.compile(this.dialect);
|
|
165
|
-
const results = await this.executor.executeSql(sql, params);
|
|
166
|
-
const rows = queryResultsToRows(results);
|
|
167
|
-
|
|
168
|
-
return rows.map(row => this.createNodeResult(row));
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Gets the path from root to a node (ancestors).
|
|
173
|
-
*/
|
|
174
|
-
async getPath(node: TreeNodeResult | NestedSetBounds, includeSelf: boolean = true): Promise<TreeNodeResult[]> {
|
|
175
|
-
const bounds = this.getBounds(node);
|
|
176
|
-
const query = this.query.findAncestors(bounds, { includeSelf });
|
|
177
|
-
const { sql, params } = query.compile(this.dialect);
|
|
178
|
-
const results = await this.executor.executeSql(sql, params);
|
|
179
|
-
const rows = queryResultsToRows(results);
|
|
180
|
-
|
|
181
|
-
return rows.map(row => this.createNodeResult(row));
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Gets siblings of a node.
|
|
186
|
-
*/
|
|
187
|
-
async getSiblings(node: TreeNodeResult, includeSelf: boolean = true): Promise<TreeNodeResult[]> {
|
|
188
|
-
const query = this.query.findSiblings(
|
|
189
|
-
node.parentId,
|
|
190
|
-
includeSelf ? undefined : (node.data as Record<string, unknown>)[this.pkName]
|
|
191
|
-
);
|
|
192
|
-
const { sql, params } = query.compile(this.dialect);
|
|
193
|
-
const results = await this.executor.executeSql(sql, params);
|
|
194
|
-
const rows = queryResultsToRows(results);
|
|
195
|
-
|
|
196
|
-
return rows.map(row => this.createNodeResult(row));
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Gets the parent of a node.
|
|
201
|
-
*/
|
|
202
|
-
async getParent(node: TreeNodeResult): Promise<TreeNodeResult | null> {
|
|
203
|
-
if (node.isRoot) return null;
|
|
204
|
-
return this.getNode(node.parentId);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Gets all descendants as a threaded (nested) structure.
|
|
209
|
-
*/
|
|
210
|
-
async getDescendantsThreaded(node: TreeNodeResult | NestedSetBounds): Promise<ThreadedNode<Record<string, unknown>>[]> {
|
|
211
|
-
const bounds = this.getBounds(node);
|
|
212
|
-
const query = this.query.findDescendants(bounds);
|
|
213
|
-
const { sql, params } = query.compile(this.dialect);
|
|
214
|
-
const queryResults = await this.executor.executeSql(sql, params);
|
|
215
|
-
const rows = queryResultsToRows(queryResults);
|
|
216
|
-
|
|
217
|
-
return threadResults(
|
|
218
|
-
rows,
|
|
219
|
-
this.config.leftKey,
|
|
220
|
-
this.config.rightKey
|
|
221
|
-
);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Counts descendants of a node.
|
|
226
|
-
*/
|
|
227
|
-
childCount(node: TreeNodeResult | NestedSetBounds): number {
|
|
228
|
-
const bounds = this.getBounds(node);
|
|
229
|
-
return NestedSetStrategy.childCount(bounds.lft, bounds.rght);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Gets the depth (level) of a node.
|
|
234
|
-
*/
|
|
235
|
-
async getLevel(node: TreeNodeResult | NestedSetBounds): Promise<number> {
|
|
236
|
-
const bounds = this.getBounds(node);
|
|
237
|
-
|
|
238
|
-
if ('depth' in node && typeof node.depth === 'number') {
|
|
239
|
-
return node.depth;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const ancestors = await this.getPath(bounds, false);
|
|
243
|
-
return ancestors.length;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Moves a node up among its siblings.
|
|
248
|
-
* @returns true if moved, false if already at top
|
|
249
|
-
*/
|
|
250
|
-
async moveUp(node: TreeNodeResult): Promise<boolean> {
|
|
251
|
-
const siblings = await this.getSiblings(node, true);
|
|
252
|
-
const nodeIndex = siblings.findIndex(
|
|
253
|
-
s => (s.data as Record<string, unknown>)[this.pkName] === (node.data as Record<string, unknown>)[this.pkName]
|
|
254
|
-
);
|
|
255
|
-
|
|
256
|
-
if (nodeIndex <= 0) return false;
|
|
257
|
-
|
|
258
|
-
const prevSibling = siblings[nodeIndex - 1];
|
|
259
|
-
return this.swapNodes(node, prevSibling);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Moves a node down among its siblings.
|
|
264
|
-
* @returns true if moved, false if already at bottom
|
|
265
|
-
*/
|
|
266
|
-
async moveDown(node: TreeNodeResult): Promise<boolean> {
|
|
267
|
-
const siblings = await this.getSiblings(node, true);
|
|
268
|
-
const nodeIndex = siblings.findIndex(
|
|
269
|
-
s => (s.data as Record<string, unknown>)[this.pkName] === (node.data as Record<string, unknown>)[this.pkName]
|
|
270
|
-
);
|
|
271
|
-
|
|
272
|
-
if (nodeIndex < 0 || nodeIndex >= siblings.length - 1) return false;
|
|
273
|
-
|
|
274
|
-
const nextSibling = siblings[nodeIndex + 1];
|
|
275
|
-
return this.swapNodes(node, nextSibling);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* Moves a node to be the last child of a new parent.
|
|
280
|
-
*/
|
|
281
|
-
async moveTo(node: TreeNodeResult, newParentId: unknown | null): Promise<void> {
|
|
282
|
-
NestedSetStrategy.subtreeWidth(node.lft, node.rght);
|
|
283
|
-
|
|
284
|
-
let newPos: { lft: number; rght: number; depth: number };
|
|
285
|
-
|
|
286
|
-
if (newParentId === null) {
|
|
287
|
-
const maxRght = await this.getMaxRght();
|
|
288
|
-
newPos = NestedSetStrategy.calculateInsertAsRoot(maxRght);
|
|
289
|
-
} else {
|
|
290
|
-
const newParent = await this.getNode(newParentId);
|
|
291
|
-
if (!newParent) {
|
|
292
|
-
throw new Error(`Parent node ${newParentId} not found`);
|
|
293
|
-
}
|
|
294
|
-
newPos = NestedSetStrategy.calculateInsertAsLastChild(
|
|
295
|
-
newParent.rght,
|
|
296
|
-
newParent.depth ?? await this.getLevel(newParent)
|
|
297
|
-
);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
await this.moveSubtree(node, newPos.lft, newParentId, newPos.depth);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* Inserts a new node as a child of a parent.
|
|
305
|
-
* @returns The ID of the new node (if auto-generated)
|
|
306
|
-
*/
|
|
307
|
-
async insertAsChild(
|
|
308
|
-
parentId: unknown | null,
|
|
309
|
-
data: Record<string, unknown>
|
|
310
|
-
): Promise<unknown> {
|
|
311
|
-
let insertPos: { lft: number; rght: number; depth: number };
|
|
312
|
-
|
|
313
|
-
if (parentId === null) {
|
|
314
|
-
const maxRght = await this.getMaxRght();
|
|
315
|
-
insertPos = NestedSetStrategy.calculateInsertAsRoot(maxRght);
|
|
316
|
-
} else {
|
|
317
|
-
const parent = await this.getNode(parentId);
|
|
318
|
-
if (!parent) {
|
|
319
|
-
throw new Error(`Parent node ${parentId} not found`);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
await this.shiftForInsert(parent.rght);
|
|
323
|
-
|
|
324
|
-
insertPos = NestedSetStrategy.calculateInsertAsLastChild(
|
|
325
|
-
parent.rght,
|
|
326
|
-
parent.depth ?? await this.getLevel(parent)
|
|
327
|
-
);
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
const insertData = {
|
|
331
|
-
...data,
|
|
332
|
-
[this.config.parentKey]: parentId,
|
|
333
|
-
[this.config.leftKey]: insertPos.lft,
|
|
334
|
-
[this.config.rightKey]: insertPos.rght,
|
|
335
|
-
};
|
|
336
|
-
|
|
337
|
-
if (this.config.depthKey) {
|
|
338
|
-
insertData[this.config.depthKey] = insertPos.depth;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const scopeData = buildScopeConditions(this.config.scope, this.scopeValues);
|
|
342
|
-
Object.assign(insertData, scopeData);
|
|
343
|
-
|
|
344
|
-
const insertQuery = insertInto(this.table).values(insertData as Record<string, ValueOperandInput>);
|
|
345
|
-
const { sql, params } = insertQuery.compile(this.dialect);
|
|
346
|
-
await this.executor.executeSql(sql, params);
|
|
347
|
-
|
|
348
|
-
// If ID was provided in data, return it
|
|
349
|
-
if (insertData[this.pkName] !== undefined) {
|
|
350
|
-
return insertData[this.pkName];
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// For auto-increment, query for the inserted node by its unique lft value
|
|
354
|
-
const findQuery = selectFrom(this.table)
|
|
355
|
-
.where(eq(this.table.columns[this.config.leftKey], insertPos.lft));
|
|
356
|
-
const { sql: findSql, params: findParams } = findQuery.compile(this.dialect);
|
|
357
|
-
const results = await this.executor.executeSql(findSql, findParams);
|
|
358
|
-
const rows = queryResultsToRows(results);
|
|
359
|
-
|
|
360
|
-
if (rows.length > 0) {
|
|
361
|
-
return rows[0][this.pkName];
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
return undefined;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* Removes a node and re-parents its children to the node's parent.
|
|
369
|
-
*/
|
|
370
|
-
async removeFromTree(node: TreeNodeResult): Promise<void> {
|
|
371
|
-
const nodeId = (node.data as Record<string, unknown>)[this.pkName];
|
|
372
|
-
|
|
373
|
-
await this.executeUpdate(
|
|
374
|
-
eq(this.table.columns[this.config.parentKey], nodeId),
|
|
375
|
-
{ [this.config.parentKey]: node.parentId }
|
|
376
|
-
);
|
|
377
|
-
|
|
378
|
-
const gap = NestedSetStrategy.calculateDeleteGap(node.lft, node.rght);
|
|
379
|
-
await this.shiftForDelete(node.rght, 2);
|
|
380
|
-
|
|
381
|
-
NestedSetStrategy.calculateShiftForDelete(node.lft + 1, gap.width - 2);
|
|
382
|
-
if (gap.width > 2) {
|
|
383
|
-
await this.executeRawUpdate(
|
|
384
|
-
`UPDATE ${this.quoteTable()} SET ` +
|
|
385
|
-
`${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} - 1, ` +
|
|
386
|
-
`${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} - 1 ` +
|
|
387
|
-
`WHERE ${this.quoteCol(this.config.leftKey)} > ? AND ${this.quoteCol(this.config.rightKey)} < ?`,
|
|
388
|
-
[node.lft, node.rght]
|
|
389
|
-
);
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/**
|
|
394
|
-
* Deletes a node and all its descendants.
|
|
395
|
-
*/
|
|
396
|
-
async deleteSubtree(node: TreeNodeResult): Promise<number> {
|
|
397
|
-
const bounds = { lft: node.lft, rght: node.rght };
|
|
398
|
-
const width = NestedSetStrategy.subtreeWidth(bounds.lft, bounds.rght);
|
|
399
|
-
|
|
400
|
-
const deleteQuery = deleteFrom(this.table)
|
|
401
|
-
.where(
|
|
402
|
-
and(
|
|
403
|
-
gte(this.table.columns[this.config.leftKey], bounds.lft),
|
|
404
|
-
lte(this.table.columns[this.config.rightKey], bounds.rght)
|
|
405
|
-
)
|
|
406
|
-
);
|
|
407
|
-
|
|
408
|
-
const scopeConditions = buildScopeConditions(this.config.scope, this.scopeValues);
|
|
409
|
-
let finalQuery = deleteQuery;
|
|
410
|
-
for (const [key, value] of Object.entries(scopeConditions)) {
|
|
411
|
-
finalQuery = finalQuery.where(eq(this.table.columns[key], value));
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
const { sql, params } = finalQuery.compile(this.dialect);
|
|
415
|
-
await this.executor.executeSql(sql, params);
|
|
416
|
-
|
|
417
|
-
await this.shiftForDelete(bounds.rght, width);
|
|
418
|
-
|
|
419
|
-
return width / 2;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/**
|
|
423
|
-
* Rebuilds the tree structure from parent_id relationships.
|
|
424
|
-
* Useful for fixing corrupted trees or initial population.
|
|
425
|
-
*/
|
|
426
|
-
async recover(): Promise<RecoverResult> {
|
|
427
|
-
try {
|
|
428
|
-
const nodes = await this.getAllNodesForRecovery();
|
|
429
|
-
const updates = NestedSetStrategy.recover(nodes, (a, b) => {
|
|
430
|
-
if (this.config.recoverOrder) {
|
|
431
|
-
const [key, dir] = Object.entries(this.config.recoverOrder)[0];
|
|
432
|
-
const aVal = a[key as keyof typeof a];
|
|
433
|
-
const bVal = b[key as keyof typeof b];
|
|
434
|
-
const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
|
|
435
|
-
return dir === 'DESC' ? -cmp : cmp;
|
|
436
|
-
}
|
|
437
|
-
return (a.pk as number) - (b.pk as number);
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
for (const update of updates) {
|
|
441
|
-
const updateData: Record<string, unknown> = {
|
|
442
|
-
[this.config.leftKey]: update.lft,
|
|
443
|
-
[this.config.rightKey]: update.rght,
|
|
444
|
-
};
|
|
445
|
-
if (this.config.depthKey) {
|
|
446
|
-
updateData[this.config.depthKey] = update.depth;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
await this.executeUpdate(
|
|
450
|
-
eq(this.table.columns[this.pkName], update.pk),
|
|
451
|
-
updateData
|
|
452
|
-
);
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
return { processed: updates.length, success: true };
|
|
456
|
-
} catch (error) {
|
|
457
|
-
return {
|
|
458
|
-
processed: 0,
|
|
459
|
-
success: false,
|
|
460
|
-
errors: [(error as Error).message],
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* Validates the tree structure.
|
|
467
|
-
* @returns Array of validation errors (empty if valid)
|
|
468
|
-
*/
|
|
469
|
-
async validate(): Promise<string[]> {
|
|
470
|
-
const query = this.query.findTreeList();
|
|
471
|
-
const { sql, params } = query.compile(this.dialect);
|
|
472
|
-
const queryResults = await this.executor.executeSql(sql, params);
|
|
473
|
-
const rows = queryResultsToRows(queryResults);
|
|
474
|
-
|
|
475
|
-
return NestedSetStrategy.validateTree(
|
|
476
|
-
rows,
|
|
477
|
-
row => row[this.config.leftKey] as number,
|
|
478
|
-
row => row[this.config.rightKey] as number,
|
|
479
|
-
row => row[this.pkName]
|
|
480
|
-
);
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
/**
|
|
484
|
-
* Creates a new TreeManager with different scope values.
|
|
485
|
-
*/
|
|
486
|
-
withScope(scope: TreeScope): TreeManager<T> {
|
|
487
|
-
return new TreeManager({
|
|
488
|
-
executor: this.executor,
|
|
489
|
-
dialect: this.dialect,
|
|
490
|
-
table: this.table,
|
|
491
|
-
config: this.config,
|
|
492
|
-
scope: { ...this.scopeValues, ...scope },
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// ===== Private Helpers =====
|
|
497
|
-
|
|
498
|
-
private createNodeResult(row: Record<string, unknown>): TreeNodeResult {
|
|
499
|
-
const lft = row[this.config.leftKey] as number;
|
|
500
|
-
const rght = row[this.config.rightKey] as number;
|
|
501
|
-
const parentId = row[this.config.parentKey];
|
|
502
|
-
const depth = this.config.depthKey ? row[this.config.depthKey] as number | undefined : undefined;
|
|
503
|
-
|
|
504
|
-
return {
|
|
505
|
-
data: row,
|
|
506
|
-
lft,
|
|
507
|
-
rght,
|
|
508
|
-
parentId,
|
|
509
|
-
depth,
|
|
510
|
-
isLeaf: NestedSetStrategy.isLeaf(lft, rght),
|
|
511
|
-
isRoot: NestedSetStrategy.isRoot(parentId),
|
|
512
|
-
childCount: NestedSetStrategy.childCount(lft, rght),
|
|
513
|
-
};
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
private getBounds(node: TreeNodeResult | NestedSetBounds): NestedSetBounds {
|
|
517
|
-
if ('data' in node) {
|
|
518
|
-
return { lft: node.lft, rght: node.rght };
|
|
519
|
-
}
|
|
520
|
-
return node;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
private getPrimaryKeyName(): string {
|
|
524
|
-
for (const [name, col] of Object.entries(this.table.columns)) {
|
|
525
|
-
if (col.primary) {
|
|
526
|
-
return name;
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
if (this.table.primaryKey && this.table.primaryKey.length > 0) {
|
|
530
|
-
return this.table.primaryKey[0];
|
|
531
|
-
}
|
|
532
|
-
return 'id';
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
private async getMaxRght(): Promise<number> {
|
|
536
|
-
const query = selectFrom(this.table)
|
|
537
|
-
.selectRaw(`MAX(${this.config.rightKey}) as max_rght`);
|
|
538
|
-
const { sql, params } = query.compile(this.dialect);
|
|
539
|
-
const queryResults = await this.executor.executeSql(sql, params);
|
|
540
|
-
const rows = queryResultsToRows(queryResults);
|
|
541
|
-
|
|
542
|
-
const maxRght = rows[0]?.max_rght;
|
|
543
|
-
return typeof maxRght === 'number' ? maxRght : 0;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
private async shiftForInsert(insertPoint: number): Promise<void> {
|
|
547
|
-
await this.executeRawUpdate(
|
|
548
|
-
`UPDATE ${this.quoteTable()} SET ${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} + 2 ` +
|
|
549
|
-
`WHERE ${this.quoteCol(this.config.rightKey)} >= ?`,
|
|
550
|
-
[insertPoint]
|
|
551
|
-
);
|
|
552
|
-
|
|
553
|
-
await this.executeRawUpdate(
|
|
554
|
-
`UPDATE ${this.quoteTable()} SET ${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} + 2 ` +
|
|
555
|
-
`WHERE ${this.quoteCol(this.config.leftKey)} > ?`,
|
|
556
|
-
[insertPoint]
|
|
557
|
-
);
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
private async shiftForDelete(deletedRght: number, width: number): Promise<void> {
|
|
561
|
-
await this.executeRawUpdate(
|
|
562
|
-
`UPDATE ${this.quoteTable()} SET ${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} - ? ` +
|
|
563
|
-
`WHERE ${this.quoteCol(this.config.leftKey)} > ?`,
|
|
564
|
-
[width, deletedRght]
|
|
565
|
-
);
|
|
566
|
-
|
|
567
|
-
await this.executeRawUpdate(
|
|
568
|
-
`UPDATE ${this.quoteTable()} SET ${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} - ? ` +
|
|
569
|
-
`WHERE ${this.quoteCol(this.config.rightKey)} > ?`,
|
|
570
|
-
[width, deletedRght]
|
|
571
|
-
);
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
private async swapNodes(nodeA: TreeNodeResult, nodeB: TreeNodeResult): Promise<boolean> {
|
|
575
|
-
const shift = NestedSetStrategy.calculateMoveUp(
|
|
576
|
-
{ lft: nodeA.lft, rght: nodeA.rght },
|
|
577
|
-
{ lft: nodeB.lft, rght: nodeB.rght }
|
|
578
|
-
);
|
|
579
|
-
|
|
580
|
-
if (!shift) return false;
|
|
581
|
-
|
|
582
|
-
const tempOffset = 10000000;
|
|
583
|
-
|
|
584
|
-
await this.executeRawUpdate(
|
|
585
|
-
`UPDATE ${this.quoteTable()} SET ` +
|
|
586
|
-
`${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} + ?, ` +
|
|
587
|
-
`${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} + ? ` +
|
|
588
|
-
`WHERE ${this.quoteCol(this.config.leftKey)} >= ? AND ${this.quoteCol(this.config.rightKey)} <= ?`,
|
|
589
|
-
[tempOffset, tempOffset, nodeA.lft, nodeA.rght]
|
|
590
|
-
);
|
|
591
|
-
|
|
592
|
-
await this.executeRawUpdate(
|
|
593
|
-
`UPDATE ${this.quoteTable()} SET ` +
|
|
594
|
-
`${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} + ?, ` +
|
|
595
|
-
`${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} + ? ` +
|
|
596
|
-
`WHERE ${this.quoteCol(this.config.leftKey)} >= ? AND ${this.quoteCol(this.config.rightKey)} <= ?`,
|
|
597
|
-
[shift.siblingShift, shift.siblingShift, nodeB.lft, nodeB.rght]
|
|
598
|
-
);
|
|
599
|
-
|
|
600
|
-
await this.executeRawUpdate(
|
|
601
|
-
`UPDATE ${this.quoteTable()} SET ` +
|
|
602
|
-
`${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} - ? + ?, ` +
|
|
603
|
-
`${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} - ? + ? ` +
|
|
604
|
-
`WHERE ${this.quoteCol(this.config.leftKey)} >= ?`,
|
|
605
|
-
[tempOffset, shift.nodeShift, tempOffset, shift.nodeShift, nodeA.lft + tempOffset]
|
|
606
|
-
);
|
|
607
|
-
|
|
608
|
-
return true;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
private async moveSubtree(
|
|
612
|
-
node: TreeNodeResult,
|
|
613
|
-
newLft: number,
|
|
614
|
-
newParentId: unknown | null,
|
|
615
|
-
newDepth: number
|
|
616
|
-
): Promise<void> {
|
|
617
|
-
const width = NestedSetStrategy.subtreeWidth(node.lft, node.rght);
|
|
618
|
-
const delta = newLft - node.lft;
|
|
619
|
-
const depthDelta = this.config.depthKey
|
|
620
|
-
? newDepth - (node.depth ?? 0)
|
|
621
|
-
: 0;
|
|
622
|
-
const nodeId = (node.data as Record<string, unknown>)[this.pkName];
|
|
623
|
-
|
|
624
|
-
const tempOffset = 10000000;
|
|
625
|
-
|
|
626
|
-
await this.executeRawUpdate(
|
|
627
|
-
`UPDATE ${this.quoteTable()} SET ` +
|
|
628
|
-
`${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} + ? ` +
|
|
629
|
-
`WHERE ${this.quoteCol(this.config.leftKey)} >= ? AND ${this.quoteCol(this.config.rightKey)} <= ?`,
|
|
630
|
-
[tempOffset, node.lft, node.rght]
|
|
631
|
-
);
|
|
632
|
-
|
|
633
|
-
await this.executeRawUpdate(
|
|
634
|
-
`UPDATE ${this.quoteTable()} SET ` +
|
|
635
|
-
`${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} + ? ` +
|
|
636
|
-
`WHERE ${this.quoteCol(this.config.leftKey)} >= ? AND ${this.quoteCol(this.config.rightKey)} <= ?`,
|
|
637
|
-
[tempOffset, node.lft + tempOffset, node.rght]
|
|
638
|
-
);
|
|
639
|
-
|
|
640
|
-
await this.shiftForDelete(node.rght, width);
|
|
641
|
-
await this.shiftForInsert(newLft);
|
|
642
|
-
|
|
643
|
-
let updateSql = `UPDATE ${this.quoteTable()} SET ` +
|
|
644
|
-
`${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} - ? + ?, ` +
|
|
645
|
-
`${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} - ? + ?`;
|
|
646
|
-
|
|
647
|
-
const updateParams: unknown[] = [tempOffset, delta, tempOffset, delta];
|
|
648
|
-
|
|
649
|
-
if (this.config.depthKey && depthDelta !== 0) {
|
|
650
|
-
updateSql += `, ${this.quoteCol(this.config.depthKey)} = ${this.quoteCol(this.config.depthKey)} + ?`;
|
|
651
|
-
updateParams.push(depthDelta);
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
updateSql += ` WHERE ${this.quoteCol(this.config.leftKey)} >= ?`;
|
|
655
|
-
updateParams.push(node.lft + tempOffset);
|
|
656
|
-
|
|
657
|
-
await this.executeRawUpdate(updateSql, updateParams);
|
|
658
|
-
|
|
659
|
-
await this.executeUpdate(
|
|
660
|
-
eq(this.table.columns[this.pkName], nodeId),
|
|
661
|
-
{ [this.config.parentKey]: newParentId }
|
|
662
|
-
);
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
private async getAllNodesForRecovery(): Promise<NodeWithPk<unknown>[]> {
|
|
666
|
-
const query = selectFrom(this.table)
|
|
667
|
-
.select(this.pkName, this.config.parentKey, this.config.leftKey, this.config.rightKey);
|
|
668
|
-
|
|
669
|
-
const scopeConditions = buildScopeConditions(this.config.scope, this.scopeValues);
|
|
670
|
-
let finalQuery = query;
|
|
671
|
-
for (const [key, value] of Object.entries(scopeConditions)) {
|
|
672
|
-
finalQuery = finalQuery.where(eq(this.table.columns[key], value));
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
const { sql, params } = finalQuery.compile(this.dialect);
|
|
676
|
-
const queryResults = await this.executor.executeSql(sql, params);
|
|
677
|
-
const rows = queryResultsToRows(queryResults);
|
|
678
|
-
|
|
679
|
-
return rows.map(row => ({
|
|
680
|
-
pk: row[this.pkName],
|
|
681
|
-
lft: row[this.config.leftKey] as number,
|
|
682
|
-
rght: row[this.config.rightKey] as number,
|
|
683
|
-
parentId: row[this.config.parentKey],
|
|
684
|
-
}));
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
private async executeUpdate(
|
|
688
|
-
condition: ReturnType<typeof eq>,
|
|
689
|
-
data: Record<string, unknown>
|
|
690
|
-
): Promise<void> {
|
|
691
|
-
const query = update(this.table).set(data).where(condition);
|
|
692
|
-
const { sql, params } = query.compile(this.dialect);
|
|
693
|
-
await this.executor.executeSql(sql, params);
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
private async executeRawUpdate(sql: string, params: unknown[]): Promise<void> {
|
|
697
|
-
await this.executor.executeSql(sql, params);
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
private quoteTable(): string {
|
|
701
|
-
const quote = this.getQuoteChar();
|
|
702
|
-
return `${quote}${this.table.name}${quote}`;
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
private quoteCol(name: string): string {
|
|
706
|
-
const quote = this.getQuoteChar();
|
|
707
|
-
return `${quote}${name}${quote}`;
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
private getQuoteChar(): string {
|
|
711
|
-
const dialectName = this.dialect.constructor.name.toLowerCase();
|
|
712
|
-
return dialectName.includes('mysql') ? '`' : '"';
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
/**
|
|
717
|
-
* Creates a TreeManager from an OrmSession.
|
|
718
|
-
* Convenience factory for Level 2 integration.
|
|
719
|
-
*/
|
|
720
|
-
export function createTreeManager<T extends TableDef>(
|
|
721
|
-
session: OrmSession,
|
|
722
|
-
table: T,
|
|
723
|
-
config?: Partial<TreeConfig>,
|
|
724
|
-
scope?: TreeScope
|
|
725
|
-
): TreeManager<T> {
|
|
726
|
-
return new TreeManager({
|
|
727
|
-
executor: session.executor,
|
|
728
|
-
dialect: session.dialect,
|
|
729
|
-
table,
|
|
730
|
-
config,
|
|
731
|
-
scope,
|
|
732
|
-
});
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
/**
|
|
736
|
-
* Converts QueryResult[] to row objects.
|
|
737
|
-
* Handles the canonical { columns, values } format.
|
|
738
|
-
*/
|
|
739
|
-
function queryResultsToRows(results: QueryResult[]): Record<string, unknown>[] {
|
|
740
|
-
const rows: Record<string, unknown>[] = [];
|
|
741
|
-
|
|
742
|
-
for (const result of results) {
|
|
743
|
-
const { columns, values } = result;
|
|
744
|
-
for (const valueRow of values) {
|
|
745
|
-
const row: Record<string, unknown> = {};
|
|
746
|
-
for (let i = 0; i < columns.length; i++) {
|
|
747
|
-
row[columns[i]] = valueRow[i];
|
|
748
|
-
}
|
|
749
|
-
rows.push(row);
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
return rows;
|
|
754
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Tree Manager
|
|
3
|
+
*
|
|
4
|
+
* Level 2 API: ORM runtime integration for tree operations.
|
|
5
|
+
* Provides session-aware tree manipulation with Unit of Work integration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { OrmSession } from '../orm/orm-session.js';
|
|
9
|
+
import type { DbExecutor } from '../core/execution/db-executor.js';
|
|
10
|
+
import type { Dialect } from '../core/dialect/abstract.js';
|
|
11
|
+
import type { TableDef } from '../schema/table.js';
|
|
12
|
+
import { selectFrom, insertInto, update, deleteFrom } from '../query/index.js';
|
|
13
|
+
import type { QueryResult } from '../core/execution/db-executor.js';
|
|
14
|
+
import { and, eq, gte, lte, ValueOperandInput } from '../core/ast/expression.js';
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
TreeConfig,
|
|
18
|
+
NestedSetBounds,
|
|
19
|
+
RecoverResult,
|
|
20
|
+
TreeScope,
|
|
21
|
+
ThreadedNode,
|
|
22
|
+
} from './tree-types.js';
|
|
23
|
+
import {
|
|
24
|
+
resolveTreeConfig,
|
|
25
|
+
validateTreeTable,
|
|
26
|
+
} from './tree-types.js';
|
|
27
|
+
import {
|
|
28
|
+
NestedSetStrategy,
|
|
29
|
+
NodeWithPk,
|
|
30
|
+
buildScopeConditions,
|
|
31
|
+
} from './nested-set-strategy.js';
|
|
32
|
+
import { treeQuery, TreeQuery, threadResults } from './tree-query.js';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Options for creating a TreeManager.
|
|
36
|
+
*/
|
|
37
|
+
export interface TreeManagerOptions<T extends TableDef> {
|
|
38
|
+
/** Database executor for running queries */
|
|
39
|
+
executor: DbExecutor;
|
|
40
|
+
/** SQL dialect */
|
|
41
|
+
dialect: Dialect;
|
|
42
|
+
/** Table definition */
|
|
43
|
+
table: T;
|
|
44
|
+
/** Tree configuration */
|
|
45
|
+
config?: Partial<TreeConfig>;
|
|
46
|
+
/** Optional scope values for multi-tree tables */
|
|
47
|
+
scope?: TreeScope;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Result of fetching a node with tree metadata.
|
|
52
|
+
*/
|
|
53
|
+
export interface TreeNodeResult<T = Record<string, unknown>> {
|
|
54
|
+
/** The node data */
|
|
55
|
+
data: T;
|
|
56
|
+
/** Nested set bounds */
|
|
57
|
+
lft: number;
|
|
58
|
+
rght: number;
|
|
59
|
+
/** Parent ID (null for roots) */
|
|
60
|
+
parentId: unknown;
|
|
61
|
+
/** Depth level (if available) */
|
|
62
|
+
depth?: number;
|
|
63
|
+
/** Whether this is a leaf node */
|
|
64
|
+
isLeaf: boolean;
|
|
65
|
+
/** Whether this is a root node */
|
|
66
|
+
isRoot: boolean;
|
|
67
|
+
/** Count of descendants */
|
|
68
|
+
childCount: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Tree Manager for Level 2 ORM runtime integration.
|
|
73
|
+
* Provides session-aware tree operations with proper change tracking.
|
|
74
|
+
*
|
|
75
|
+
* @typeParam T - The table definition type
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```ts
|
|
79
|
+
* const manager = new TreeManager({
|
|
80
|
+
* executor: session.executor,
|
|
81
|
+
* dialect: session.dialect,
|
|
82
|
+
* table: categories,
|
|
83
|
+
* config: { parentKey: 'parentId', leftKey: 'lft', rightKey: 'rght' },
|
|
84
|
+
* });
|
|
85
|
+
*
|
|
86
|
+
* const node = await manager.getNode(5);
|
|
87
|
+
* await manager.moveUp(node);
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
export class TreeManager<T extends TableDef> {
|
|
91
|
+
readonly table: T;
|
|
92
|
+
readonly config: TreeConfig;
|
|
93
|
+
readonly query: TreeQuery<T>;
|
|
94
|
+
|
|
95
|
+
private readonly executor: DbExecutor;
|
|
96
|
+
private readonly dialect: Dialect;
|
|
97
|
+
private readonly scopeValues: TreeScope;
|
|
98
|
+
private readonly pkName: string;
|
|
99
|
+
|
|
100
|
+
constructor(options: TreeManagerOptions<T>) {
|
|
101
|
+
const { executor, dialect, table, config = {}, scope = {} } = options;
|
|
102
|
+
|
|
103
|
+
this.executor = executor;
|
|
104
|
+
this.dialect = dialect;
|
|
105
|
+
this.table = table;
|
|
106
|
+
this.config = resolveTreeConfig(config);
|
|
107
|
+
this.scopeValues = scope;
|
|
108
|
+
this.pkName = this.getPrimaryKeyName();
|
|
109
|
+
|
|
110
|
+
const validation = validateTreeTable(table, this.config);
|
|
111
|
+
if (!validation.valid) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Invalid tree table '${table.name}': missing columns ${validation.missingColumns.join(', ')}`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this.query = treeQuery(table, this.config).withScope(scope);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Gets a node by ID with tree metadata.
|
|
122
|
+
*/
|
|
123
|
+
async getNode(id: unknown): Promise<TreeNodeResult | null> {
|
|
124
|
+
const query = this.query.findById(id);
|
|
125
|
+
const { sql, params } = query.compile(this.dialect);
|
|
126
|
+
const results = await this.executor.executeSql(sql, params);
|
|
127
|
+
const rows = queryResultsToRows(results);
|
|
128
|
+
|
|
129
|
+
if (rows.length === 0) return null;
|
|
130
|
+
|
|
131
|
+
return this.createNodeResult(rows[0]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Gets the root nodes.
|
|
136
|
+
*/
|
|
137
|
+
async getRoots(): Promise<TreeNodeResult[]> {
|
|
138
|
+
const query = this.query.findRoots();
|
|
139
|
+
const { sql, params } = query.compile(this.dialect);
|
|
140
|
+
const results = await this.executor.executeSql(sql, params);
|
|
141
|
+
const rows = queryResultsToRows(results);
|
|
142
|
+
|
|
143
|
+
return rows.map(row => this.createNodeResult(row));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Gets direct children of a node.
|
|
148
|
+
*/
|
|
149
|
+
async getChildren(parentId: unknown): Promise<TreeNodeResult[]> {
|
|
150
|
+
const query = this.query.findDirectChildren(parentId);
|
|
151
|
+
const { sql, params } = query.compile(this.dialect);
|
|
152
|
+
const results = await this.executor.executeSql(sql, params);
|
|
153
|
+
const rows = queryResultsToRows(results);
|
|
154
|
+
|
|
155
|
+
return rows.map(row => this.createNodeResult(row));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Gets all descendants of a node.
|
|
160
|
+
*/
|
|
161
|
+
async getDescendants(node: TreeNodeResult | NestedSetBounds): Promise<TreeNodeResult[]> {
|
|
162
|
+
const bounds = this.getBounds(node);
|
|
163
|
+
const query = this.query.findDescendants(bounds);
|
|
164
|
+
const { sql, params } = query.compile(this.dialect);
|
|
165
|
+
const results = await this.executor.executeSql(sql, params);
|
|
166
|
+
const rows = queryResultsToRows(results);
|
|
167
|
+
|
|
168
|
+
return rows.map(row => this.createNodeResult(row));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Gets the path from root to a node (ancestors).
|
|
173
|
+
*/
|
|
174
|
+
async getPath(node: TreeNodeResult | NestedSetBounds, includeSelf: boolean = true): Promise<TreeNodeResult[]> {
|
|
175
|
+
const bounds = this.getBounds(node);
|
|
176
|
+
const query = this.query.findAncestors(bounds, { includeSelf });
|
|
177
|
+
const { sql, params } = query.compile(this.dialect);
|
|
178
|
+
const results = await this.executor.executeSql(sql, params);
|
|
179
|
+
const rows = queryResultsToRows(results);
|
|
180
|
+
|
|
181
|
+
return rows.map(row => this.createNodeResult(row));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Gets siblings of a node.
|
|
186
|
+
*/
|
|
187
|
+
async getSiblings(node: TreeNodeResult, includeSelf: boolean = true): Promise<TreeNodeResult[]> {
|
|
188
|
+
const query = this.query.findSiblings(
|
|
189
|
+
node.parentId,
|
|
190
|
+
includeSelf ? undefined : (node.data as Record<string, unknown>)[this.pkName]
|
|
191
|
+
);
|
|
192
|
+
const { sql, params } = query.compile(this.dialect);
|
|
193
|
+
const results = await this.executor.executeSql(sql, params);
|
|
194
|
+
const rows = queryResultsToRows(results);
|
|
195
|
+
|
|
196
|
+
return rows.map(row => this.createNodeResult(row));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Gets the parent of a node.
|
|
201
|
+
*/
|
|
202
|
+
async getParent(node: TreeNodeResult): Promise<TreeNodeResult | null> {
|
|
203
|
+
if (node.isRoot) return null;
|
|
204
|
+
return this.getNode(node.parentId);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Gets all descendants as a threaded (nested) structure.
|
|
209
|
+
*/
|
|
210
|
+
async getDescendantsThreaded(node: TreeNodeResult | NestedSetBounds): Promise<ThreadedNode<Record<string, unknown>>[]> {
|
|
211
|
+
const bounds = this.getBounds(node);
|
|
212
|
+
const query = this.query.findDescendants(bounds);
|
|
213
|
+
const { sql, params } = query.compile(this.dialect);
|
|
214
|
+
const queryResults = await this.executor.executeSql(sql, params);
|
|
215
|
+
const rows = queryResultsToRows(queryResults);
|
|
216
|
+
|
|
217
|
+
return threadResults(
|
|
218
|
+
rows,
|
|
219
|
+
this.config.leftKey,
|
|
220
|
+
this.config.rightKey
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Counts descendants of a node.
|
|
226
|
+
*/
|
|
227
|
+
childCount(node: TreeNodeResult | NestedSetBounds): number {
|
|
228
|
+
const bounds = this.getBounds(node);
|
|
229
|
+
return NestedSetStrategy.childCount(bounds.lft, bounds.rght);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Gets the depth (level) of a node.
|
|
234
|
+
*/
|
|
235
|
+
async getLevel(node: TreeNodeResult | NestedSetBounds): Promise<number> {
|
|
236
|
+
const bounds = this.getBounds(node);
|
|
237
|
+
|
|
238
|
+
if ('depth' in node && typeof node.depth === 'number') {
|
|
239
|
+
return node.depth;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const ancestors = await this.getPath(bounds, false);
|
|
243
|
+
return ancestors.length;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Moves a node up among its siblings.
|
|
248
|
+
* @returns true if moved, false if already at top
|
|
249
|
+
*/
|
|
250
|
+
async moveUp(node: TreeNodeResult): Promise<boolean> {
|
|
251
|
+
const siblings = await this.getSiblings(node, true);
|
|
252
|
+
const nodeIndex = siblings.findIndex(
|
|
253
|
+
s => (s.data as Record<string, unknown>)[this.pkName] === (node.data as Record<string, unknown>)[this.pkName]
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
if (nodeIndex <= 0) return false;
|
|
257
|
+
|
|
258
|
+
const prevSibling = siblings[nodeIndex - 1];
|
|
259
|
+
return this.swapNodes(node, prevSibling);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Moves a node down among its siblings.
|
|
264
|
+
* @returns true if moved, false if already at bottom
|
|
265
|
+
*/
|
|
266
|
+
async moveDown(node: TreeNodeResult): Promise<boolean> {
|
|
267
|
+
const siblings = await this.getSiblings(node, true);
|
|
268
|
+
const nodeIndex = siblings.findIndex(
|
|
269
|
+
s => (s.data as Record<string, unknown>)[this.pkName] === (node.data as Record<string, unknown>)[this.pkName]
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
if (nodeIndex < 0 || nodeIndex >= siblings.length - 1) return false;
|
|
273
|
+
|
|
274
|
+
const nextSibling = siblings[nodeIndex + 1];
|
|
275
|
+
return this.swapNodes(node, nextSibling);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Moves a node to be the last child of a new parent.
|
|
280
|
+
*/
|
|
281
|
+
async moveTo(node: TreeNodeResult, newParentId: unknown | null): Promise<void> {
|
|
282
|
+
NestedSetStrategy.subtreeWidth(node.lft, node.rght);
|
|
283
|
+
|
|
284
|
+
let newPos: { lft: number; rght: number; depth: number };
|
|
285
|
+
|
|
286
|
+
if (newParentId === null) {
|
|
287
|
+
const maxRght = await this.getMaxRght();
|
|
288
|
+
newPos = NestedSetStrategy.calculateInsertAsRoot(maxRght);
|
|
289
|
+
} else {
|
|
290
|
+
const newParent = await this.getNode(newParentId);
|
|
291
|
+
if (!newParent) {
|
|
292
|
+
throw new Error(`Parent node ${newParentId} not found`);
|
|
293
|
+
}
|
|
294
|
+
newPos = NestedSetStrategy.calculateInsertAsLastChild(
|
|
295
|
+
newParent.rght,
|
|
296
|
+
newParent.depth ?? await this.getLevel(newParent)
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
await this.moveSubtree(node, newPos.lft, newParentId, newPos.depth);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Inserts a new node as a child of a parent.
|
|
305
|
+
* @returns The ID of the new node (if auto-generated)
|
|
306
|
+
*/
|
|
307
|
+
async insertAsChild(
|
|
308
|
+
parentId: unknown | null,
|
|
309
|
+
data: Record<string, unknown>
|
|
310
|
+
): Promise<unknown> {
|
|
311
|
+
let insertPos: { lft: number; rght: number; depth: number };
|
|
312
|
+
|
|
313
|
+
if (parentId === null) {
|
|
314
|
+
const maxRght = await this.getMaxRght();
|
|
315
|
+
insertPos = NestedSetStrategy.calculateInsertAsRoot(maxRght);
|
|
316
|
+
} else {
|
|
317
|
+
const parent = await this.getNode(parentId);
|
|
318
|
+
if (!parent) {
|
|
319
|
+
throw new Error(`Parent node ${parentId} not found`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
await this.shiftForInsert(parent.rght);
|
|
323
|
+
|
|
324
|
+
insertPos = NestedSetStrategy.calculateInsertAsLastChild(
|
|
325
|
+
parent.rght,
|
|
326
|
+
parent.depth ?? await this.getLevel(parent)
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const insertData = {
|
|
331
|
+
...data,
|
|
332
|
+
[this.config.parentKey]: parentId,
|
|
333
|
+
[this.config.leftKey]: insertPos.lft,
|
|
334
|
+
[this.config.rightKey]: insertPos.rght,
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
if (this.config.depthKey) {
|
|
338
|
+
insertData[this.config.depthKey] = insertPos.depth;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const scopeData = buildScopeConditions(this.config.scope, this.scopeValues);
|
|
342
|
+
Object.assign(insertData, scopeData);
|
|
343
|
+
|
|
344
|
+
const insertQuery = insertInto(this.table).values(insertData as Record<string, ValueOperandInput>);
|
|
345
|
+
const { sql, params } = insertQuery.compile(this.dialect);
|
|
346
|
+
await this.executor.executeSql(sql, params);
|
|
347
|
+
|
|
348
|
+
// If ID was provided in data, return it
|
|
349
|
+
if (insertData[this.pkName] !== undefined) {
|
|
350
|
+
return insertData[this.pkName];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// For auto-increment, query for the inserted node by its unique lft value
|
|
354
|
+
const findQuery = selectFrom(this.table)
|
|
355
|
+
.where(eq(this.table.columns[this.config.leftKey], insertPos.lft));
|
|
356
|
+
const { sql: findSql, params: findParams } = findQuery.compile(this.dialect);
|
|
357
|
+
const results = await this.executor.executeSql(findSql, findParams);
|
|
358
|
+
const rows = queryResultsToRows(results);
|
|
359
|
+
|
|
360
|
+
if (rows.length > 0) {
|
|
361
|
+
return rows[0][this.pkName];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Removes a node and re-parents its children to the node's parent.
|
|
369
|
+
*/
|
|
370
|
+
async removeFromTree(node: TreeNodeResult): Promise<void> {
|
|
371
|
+
const nodeId = (node.data as Record<string, unknown>)[this.pkName];
|
|
372
|
+
|
|
373
|
+
await this.executeUpdate(
|
|
374
|
+
eq(this.table.columns[this.config.parentKey], nodeId),
|
|
375
|
+
{ [this.config.parentKey]: node.parentId }
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
const gap = NestedSetStrategy.calculateDeleteGap(node.lft, node.rght);
|
|
379
|
+
await this.shiftForDelete(node.rght, 2);
|
|
380
|
+
|
|
381
|
+
NestedSetStrategy.calculateShiftForDelete(node.lft + 1, gap.width - 2);
|
|
382
|
+
if (gap.width > 2) {
|
|
383
|
+
await this.executeRawUpdate(
|
|
384
|
+
`UPDATE ${this.quoteTable()} SET ` +
|
|
385
|
+
`${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} - 1, ` +
|
|
386
|
+
`${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} - 1 ` +
|
|
387
|
+
`WHERE ${this.quoteCol(this.config.leftKey)} > ? AND ${this.quoteCol(this.config.rightKey)} < ?`,
|
|
388
|
+
[node.lft, node.rght]
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Deletes a node and all its descendants.
|
|
395
|
+
*/
|
|
396
|
+
async deleteSubtree(node: TreeNodeResult): Promise<number> {
|
|
397
|
+
const bounds = { lft: node.lft, rght: node.rght };
|
|
398
|
+
const width = NestedSetStrategy.subtreeWidth(bounds.lft, bounds.rght);
|
|
399
|
+
|
|
400
|
+
const deleteQuery = deleteFrom(this.table)
|
|
401
|
+
.where(
|
|
402
|
+
and(
|
|
403
|
+
gte(this.table.columns[this.config.leftKey], bounds.lft),
|
|
404
|
+
lte(this.table.columns[this.config.rightKey], bounds.rght)
|
|
405
|
+
)
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
const scopeConditions = buildScopeConditions(this.config.scope, this.scopeValues);
|
|
409
|
+
let finalQuery = deleteQuery;
|
|
410
|
+
for (const [key, value] of Object.entries(scopeConditions)) {
|
|
411
|
+
finalQuery = finalQuery.where(eq(this.table.columns[key], value));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const { sql, params } = finalQuery.compile(this.dialect);
|
|
415
|
+
await this.executor.executeSql(sql, params);
|
|
416
|
+
|
|
417
|
+
await this.shiftForDelete(bounds.rght, width);
|
|
418
|
+
|
|
419
|
+
return width / 2;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Rebuilds the tree structure from parent_id relationships.
|
|
424
|
+
* Useful for fixing corrupted trees or initial population.
|
|
425
|
+
*/
|
|
426
|
+
async recover(): Promise<RecoverResult> {
|
|
427
|
+
try {
|
|
428
|
+
const nodes = await this.getAllNodesForRecovery();
|
|
429
|
+
const updates = NestedSetStrategy.recover(nodes, (a, b) => {
|
|
430
|
+
if (this.config.recoverOrder) {
|
|
431
|
+
const [key, dir] = Object.entries(this.config.recoverOrder)[0];
|
|
432
|
+
const aVal = a[key as keyof typeof a];
|
|
433
|
+
const bVal = b[key as keyof typeof b];
|
|
434
|
+
const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
|
|
435
|
+
return dir === 'DESC' ? -cmp : cmp;
|
|
436
|
+
}
|
|
437
|
+
return (a.pk as number) - (b.pk as number);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
for (const update of updates) {
|
|
441
|
+
const updateData: Record<string, unknown> = {
|
|
442
|
+
[this.config.leftKey]: update.lft,
|
|
443
|
+
[this.config.rightKey]: update.rght,
|
|
444
|
+
};
|
|
445
|
+
if (this.config.depthKey) {
|
|
446
|
+
updateData[this.config.depthKey] = update.depth;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
await this.executeUpdate(
|
|
450
|
+
eq(this.table.columns[this.pkName], update.pk),
|
|
451
|
+
updateData
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return { processed: updates.length, success: true };
|
|
456
|
+
} catch (error) {
|
|
457
|
+
return {
|
|
458
|
+
processed: 0,
|
|
459
|
+
success: false,
|
|
460
|
+
errors: [(error as Error).message],
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Validates the tree structure.
|
|
467
|
+
* @returns Array of validation errors (empty if valid)
|
|
468
|
+
*/
|
|
469
|
+
async validate(): Promise<string[]> {
|
|
470
|
+
const query = this.query.findTreeList();
|
|
471
|
+
const { sql, params } = query.compile(this.dialect);
|
|
472
|
+
const queryResults = await this.executor.executeSql(sql, params);
|
|
473
|
+
const rows = queryResultsToRows(queryResults);
|
|
474
|
+
|
|
475
|
+
return NestedSetStrategy.validateTree(
|
|
476
|
+
rows,
|
|
477
|
+
row => row[this.config.leftKey] as number,
|
|
478
|
+
row => row[this.config.rightKey] as number,
|
|
479
|
+
row => row[this.pkName]
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Creates a new TreeManager with different scope values.
|
|
485
|
+
*/
|
|
486
|
+
withScope(scope: TreeScope): TreeManager<T> {
|
|
487
|
+
return new TreeManager({
|
|
488
|
+
executor: this.executor,
|
|
489
|
+
dialect: this.dialect,
|
|
490
|
+
table: this.table,
|
|
491
|
+
config: this.config,
|
|
492
|
+
scope: { ...this.scopeValues, ...scope },
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ===== Private Helpers =====
|
|
497
|
+
|
|
498
|
+
private createNodeResult(row: Record<string, unknown>): TreeNodeResult {
|
|
499
|
+
const lft = row[this.config.leftKey] as number;
|
|
500
|
+
const rght = row[this.config.rightKey] as number;
|
|
501
|
+
const parentId = row[this.config.parentKey];
|
|
502
|
+
const depth = this.config.depthKey ? row[this.config.depthKey] as number | undefined : undefined;
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
data: row,
|
|
506
|
+
lft,
|
|
507
|
+
rght,
|
|
508
|
+
parentId,
|
|
509
|
+
depth,
|
|
510
|
+
isLeaf: NestedSetStrategy.isLeaf(lft, rght),
|
|
511
|
+
isRoot: NestedSetStrategy.isRoot(parentId),
|
|
512
|
+
childCount: NestedSetStrategy.childCount(lft, rght),
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private getBounds(node: TreeNodeResult | NestedSetBounds): NestedSetBounds {
|
|
517
|
+
if ('data' in node) {
|
|
518
|
+
return { lft: node.lft, rght: node.rght };
|
|
519
|
+
}
|
|
520
|
+
return node;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private getPrimaryKeyName(): string {
|
|
524
|
+
for (const [name, col] of Object.entries(this.table.columns)) {
|
|
525
|
+
if (col.primary) {
|
|
526
|
+
return name;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (this.table.primaryKey && this.table.primaryKey.length > 0) {
|
|
530
|
+
return this.table.primaryKey[0];
|
|
531
|
+
}
|
|
532
|
+
return 'id';
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private async getMaxRght(): Promise<number> {
|
|
536
|
+
const query = selectFrom(this.table)
|
|
537
|
+
.selectRaw(`MAX(${this.config.rightKey}) as max_rght`);
|
|
538
|
+
const { sql, params } = query.compile(this.dialect);
|
|
539
|
+
const queryResults = await this.executor.executeSql(sql, params);
|
|
540
|
+
const rows = queryResultsToRows(queryResults);
|
|
541
|
+
|
|
542
|
+
const maxRght = rows[0]?.max_rght;
|
|
543
|
+
return typeof maxRght === 'number' ? maxRght : 0;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private async shiftForInsert(insertPoint: number): Promise<void> {
|
|
547
|
+
await this.executeRawUpdate(
|
|
548
|
+
`UPDATE ${this.quoteTable()} SET ${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} + 2 ` +
|
|
549
|
+
`WHERE ${this.quoteCol(this.config.rightKey)} >= ?`,
|
|
550
|
+
[insertPoint]
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
await this.executeRawUpdate(
|
|
554
|
+
`UPDATE ${this.quoteTable()} SET ${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} + 2 ` +
|
|
555
|
+
`WHERE ${this.quoteCol(this.config.leftKey)} > ?`,
|
|
556
|
+
[insertPoint]
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private async shiftForDelete(deletedRght: number, width: number): Promise<void> {
|
|
561
|
+
await this.executeRawUpdate(
|
|
562
|
+
`UPDATE ${this.quoteTable()} SET ${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} - ? ` +
|
|
563
|
+
`WHERE ${this.quoteCol(this.config.leftKey)} > ?`,
|
|
564
|
+
[width, deletedRght]
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
await this.executeRawUpdate(
|
|
568
|
+
`UPDATE ${this.quoteTable()} SET ${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} - ? ` +
|
|
569
|
+
`WHERE ${this.quoteCol(this.config.rightKey)} > ?`,
|
|
570
|
+
[width, deletedRght]
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private async swapNodes(nodeA: TreeNodeResult, nodeB: TreeNodeResult): Promise<boolean> {
|
|
575
|
+
const shift = NestedSetStrategy.calculateMoveUp(
|
|
576
|
+
{ lft: nodeA.lft, rght: nodeA.rght },
|
|
577
|
+
{ lft: nodeB.lft, rght: nodeB.rght }
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
if (!shift) return false;
|
|
581
|
+
|
|
582
|
+
const tempOffset = 10000000;
|
|
583
|
+
|
|
584
|
+
await this.executeRawUpdate(
|
|
585
|
+
`UPDATE ${this.quoteTable()} SET ` +
|
|
586
|
+
`${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} + ?, ` +
|
|
587
|
+
`${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} + ? ` +
|
|
588
|
+
`WHERE ${this.quoteCol(this.config.leftKey)} >= ? AND ${this.quoteCol(this.config.rightKey)} <= ?`,
|
|
589
|
+
[tempOffset, tempOffset, nodeA.lft, nodeA.rght]
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
await this.executeRawUpdate(
|
|
593
|
+
`UPDATE ${this.quoteTable()} SET ` +
|
|
594
|
+
`${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} + ?, ` +
|
|
595
|
+
`${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} + ? ` +
|
|
596
|
+
`WHERE ${this.quoteCol(this.config.leftKey)} >= ? AND ${this.quoteCol(this.config.rightKey)} <= ?`,
|
|
597
|
+
[shift.siblingShift, shift.siblingShift, nodeB.lft, nodeB.rght]
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
await this.executeRawUpdate(
|
|
601
|
+
`UPDATE ${this.quoteTable()} SET ` +
|
|
602
|
+
`${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} - ? + ?, ` +
|
|
603
|
+
`${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} - ? + ? ` +
|
|
604
|
+
`WHERE ${this.quoteCol(this.config.leftKey)} >= ?`,
|
|
605
|
+
[tempOffset, shift.nodeShift, tempOffset, shift.nodeShift, nodeA.lft + tempOffset]
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private async moveSubtree(
|
|
612
|
+
node: TreeNodeResult,
|
|
613
|
+
newLft: number,
|
|
614
|
+
newParentId: unknown | null,
|
|
615
|
+
newDepth: number
|
|
616
|
+
): Promise<void> {
|
|
617
|
+
const width = NestedSetStrategy.subtreeWidth(node.lft, node.rght);
|
|
618
|
+
const delta = newLft - node.lft;
|
|
619
|
+
const depthDelta = this.config.depthKey
|
|
620
|
+
? newDepth - (node.depth ?? 0)
|
|
621
|
+
: 0;
|
|
622
|
+
const nodeId = (node.data as Record<string, unknown>)[this.pkName];
|
|
623
|
+
|
|
624
|
+
const tempOffset = 10000000;
|
|
625
|
+
|
|
626
|
+
await this.executeRawUpdate(
|
|
627
|
+
`UPDATE ${this.quoteTable()} SET ` +
|
|
628
|
+
`${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} + ? ` +
|
|
629
|
+
`WHERE ${this.quoteCol(this.config.leftKey)} >= ? AND ${this.quoteCol(this.config.rightKey)} <= ?`,
|
|
630
|
+
[tempOffset, node.lft, node.rght]
|
|
631
|
+
);
|
|
632
|
+
|
|
633
|
+
await this.executeRawUpdate(
|
|
634
|
+
`UPDATE ${this.quoteTable()} SET ` +
|
|
635
|
+
`${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} + ? ` +
|
|
636
|
+
`WHERE ${this.quoteCol(this.config.leftKey)} >= ? AND ${this.quoteCol(this.config.rightKey)} <= ?`,
|
|
637
|
+
[tempOffset, node.lft + tempOffset, node.rght]
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
await this.shiftForDelete(node.rght, width);
|
|
641
|
+
await this.shiftForInsert(newLft);
|
|
642
|
+
|
|
643
|
+
let updateSql = `UPDATE ${this.quoteTable()} SET ` +
|
|
644
|
+
`${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} - ? + ?, ` +
|
|
645
|
+
`${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} - ? + ?`;
|
|
646
|
+
|
|
647
|
+
const updateParams: unknown[] = [tempOffset, delta, tempOffset, delta];
|
|
648
|
+
|
|
649
|
+
if (this.config.depthKey && depthDelta !== 0) {
|
|
650
|
+
updateSql += `, ${this.quoteCol(this.config.depthKey)} = ${this.quoteCol(this.config.depthKey)} + ?`;
|
|
651
|
+
updateParams.push(depthDelta);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
updateSql += ` WHERE ${this.quoteCol(this.config.leftKey)} >= ?`;
|
|
655
|
+
updateParams.push(node.lft + tempOffset);
|
|
656
|
+
|
|
657
|
+
await this.executeRawUpdate(updateSql, updateParams);
|
|
658
|
+
|
|
659
|
+
await this.executeUpdate(
|
|
660
|
+
eq(this.table.columns[this.pkName], nodeId),
|
|
661
|
+
{ [this.config.parentKey]: newParentId }
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private async getAllNodesForRecovery(): Promise<NodeWithPk<unknown>[]> {
|
|
666
|
+
const query = selectFrom(this.table)
|
|
667
|
+
.select(this.pkName, this.config.parentKey, this.config.leftKey, this.config.rightKey);
|
|
668
|
+
|
|
669
|
+
const scopeConditions = buildScopeConditions(this.config.scope, this.scopeValues);
|
|
670
|
+
let finalQuery = query;
|
|
671
|
+
for (const [key, value] of Object.entries(scopeConditions)) {
|
|
672
|
+
finalQuery = finalQuery.where(eq(this.table.columns[key], value));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const { sql, params } = finalQuery.compile(this.dialect);
|
|
676
|
+
const queryResults = await this.executor.executeSql(sql, params);
|
|
677
|
+
const rows = queryResultsToRows(queryResults);
|
|
678
|
+
|
|
679
|
+
return rows.map(row => ({
|
|
680
|
+
pk: row[this.pkName],
|
|
681
|
+
lft: row[this.config.leftKey] as number,
|
|
682
|
+
rght: row[this.config.rightKey] as number,
|
|
683
|
+
parentId: row[this.config.parentKey],
|
|
684
|
+
}));
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
private async executeUpdate(
|
|
688
|
+
condition: ReturnType<typeof eq>,
|
|
689
|
+
data: Record<string, unknown>
|
|
690
|
+
): Promise<void> {
|
|
691
|
+
const query = update(this.table).set(data).where(condition);
|
|
692
|
+
const { sql, params } = query.compile(this.dialect);
|
|
693
|
+
await this.executor.executeSql(sql, params);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private async executeRawUpdate(sql: string, params: unknown[]): Promise<void> {
|
|
697
|
+
await this.executor.executeSql(sql, params);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
private quoteTable(): string {
|
|
701
|
+
const quote = this.getQuoteChar();
|
|
702
|
+
return `${quote}${this.table.name}${quote}`;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
private quoteCol(name: string): string {
|
|
706
|
+
const quote = this.getQuoteChar();
|
|
707
|
+
return `${quote}${name}${quote}`;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
private getQuoteChar(): string {
|
|
711
|
+
const dialectName = this.dialect.constructor.name.toLowerCase();
|
|
712
|
+
return dialectName.includes('mysql') ? '`' : '"';
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Creates a TreeManager from an OrmSession.
|
|
718
|
+
* Convenience factory for Level 2 integration.
|
|
719
|
+
*/
|
|
720
|
+
export function createTreeManager<T extends TableDef>(
|
|
721
|
+
session: OrmSession,
|
|
722
|
+
table: T,
|
|
723
|
+
config?: Partial<TreeConfig>,
|
|
724
|
+
scope?: TreeScope
|
|
725
|
+
): TreeManager<T> {
|
|
726
|
+
return new TreeManager({
|
|
727
|
+
executor: session.executor,
|
|
728
|
+
dialect: session.dialect,
|
|
729
|
+
table,
|
|
730
|
+
config,
|
|
731
|
+
scope,
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Converts QueryResult[] to row objects.
|
|
737
|
+
* Handles the canonical { columns, values } format.
|
|
738
|
+
*/
|
|
739
|
+
function queryResultsToRows(results: QueryResult[]): Record<string, unknown>[] {
|
|
740
|
+
const rows: Record<string, unknown>[] = [];
|
|
741
|
+
|
|
742
|
+
for (const result of results) {
|
|
743
|
+
const { columns, values } = result;
|
|
744
|
+
for (const valueRow of values) {
|
|
745
|
+
const row: Record<string, unknown> = {};
|
|
746
|
+
for (let i = 0; i < columns.length; i++) {
|
|
747
|
+
row[columns[i]] = valueRow[i];
|
|
748
|
+
}
|
|
749
|
+
rows.push(row);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return rows;
|
|
754
|
+
}
|