metal-orm 1.1.10 → 1.1.16

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.
Files changed (192) hide show
  1. package/README.md +758 -769
  2. package/dist/index.cjs +106 -43
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +5 -5
  5. package/dist/index.d.ts +5 -5
  6. package/dist/index.js +106 -43
  7. package/dist/index.js.map +1 -1
  8. package/package.json +100 -96
  9. package/scripts/generate-entities/render.mjs +21 -12
  10. package/scripts/generate-entities/schema.mjs +209 -195
  11. package/scripts/generate-entities/tree-detection.mjs +159 -153
  12. package/scripts/inflection/pt-br.mjs +468 -468
  13. package/scripts/run-eslint.mjs +33 -34
  14. package/scripts/show-sql-seed.sql +102 -0
  15. package/scripts/show-sql.mjs +321 -325
  16. package/src/bulk/bulk-delete-executor.ts +3 -5
  17. package/src/bulk/bulk-insert-executor.ts +7 -7
  18. package/src/bulk/bulk-update-executor.ts +2 -2
  19. package/src/bulk/bulk-upsert-executor.ts +5 -7
  20. package/src/codegen/naming-strategy.ts +54 -54
  21. package/src/codegen/typescript.ts +482 -482
  22. package/src/core/ast/adapters.ts +1 -1
  23. package/src/core/ast/builders.ts +123 -123
  24. package/src/core/ast/expression-builders.ts +745 -745
  25. package/src/core/ast/expression-nodes.ts +304 -304
  26. package/src/core/ast/expression-visitor.ts +212 -212
  27. package/src/core/ast/expression.ts +20 -20
  28. package/src/core/ast/helpers.ts +14 -14
  29. package/src/core/ast/join.ts +18 -18
  30. package/src/core/ast/query.ts +200 -200
  31. package/src/core/ddl/dialects/base-schema-dialect.ts +1 -1
  32. package/src/core/ddl/dialects/mssql-schema-dialect.ts +1 -1
  33. package/src/core/ddl/dialects/mysql-schema-dialect.ts +1 -1
  34. package/src/core/ddl/dialects/postgres-schema-dialect.ts +1 -1
  35. package/src/core/ddl/dialects/render-reference.test.ts +70 -70
  36. package/src/core/ddl/dialects/sqlite-schema-dialect.ts +1 -1
  37. package/src/core/ddl/introspect/catalogs/index.ts +5 -5
  38. package/src/core/ddl/introspect/catalogs/postgres.ts +146 -146
  39. package/src/core/ddl/introspect/context.ts +15 -15
  40. package/src/core/ddl/introspect/functions/postgres.ts +35 -35
  41. package/src/core/ddl/introspect/mysql.ts +113 -36
  42. package/src/core/ddl/introspect/registry.ts +40 -40
  43. package/src/core/ddl/introspect/run-select.ts +35 -35
  44. package/src/core/ddl/introspect/utils.ts +56 -56
  45. package/src/core/ddl/naming-strategy.ts +16 -16
  46. package/src/core/ddl/schema-dialect.ts +56 -56
  47. package/src/core/ddl/schema-diff.ts +234 -234
  48. package/src/core/ddl/schema-generator.ts +1 -1
  49. package/src/core/ddl/schema-introspect.ts +25 -25
  50. package/src/core/ddl/sql-writing.ts +171 -171
  51. package/src/core/dialect/abstract.ts +541 -541
  52. package/src/core/dialect/base/cte-compiler.ts +33 -33
  53. package/src/core/dialect/base/function-table-formatter.ts +103 -103
  54. package/src/core/dialect/base/groupby-compiler.ts +21 -21
  55. package/src/core/dialect/base/join-compiler.ts +25 -25
  56. package/src/core/dialect/base/orderby-compiler.ts +35 -35
  57. package/src/core/dialect/base/pagination-strategy.ts +32 -32
  58. package/src/core/dialect/base/returning-strategy.ts +57 -57
  59. package/src/core/dialect/base/sql-dialect.ts +294 -294
  60. package/src/core/dialect/dialect-factory.ts +91 -91
  61. package/src/core/dialect/mssql/functions.ts +120 -120
  62. package/src/core/dialect/mssql/index.ts +317 -317
  63. package/src/core/dialect/mysql/functions.ts +103 -103
  64. package/src/core/dialect/mysql/index.ts +105 -105
  65. package/src/core/dialect/postgres/functions.ts +108 -108
  66. package/src/core/dialect/postgres/index.ts +91 -91
  67. package/src/core/dialect/sqlite/functions.ts +129 -129
  68. package/src/core/dialect/sqlite/index.ts +67 -67
  69. package/src/core/driver/database-driver.ts +19 -19
  70. package/src/core/driver/mssql-driver.ts +23 -23
  71. package/src/core/driver/mysql-driver.ts +23 -23
  72. package/src/core/driver/postgres-driver.ts +23 -23
  73. package/src/core/driver/sqlite-driver.ts +23 -23
  74. package/src/core/execution/db-executor.ts +92 -92
  75. package/src/core/execution/executors/better-sqlite3-executor.ts +94 -94
  76. package/src/core/execution/executors/mssql-executor.ts +184 -184
  77. package/src/core/execution/executors/mysql-executor.ts +88 -88
  78. package/src/core/execution/executors/postgres-executor.ts +26 -26
  79. package/src/core/execution/executors/sqlite-executor.ts +42 -42
  80. package/src/core/execution/pooling/pool-types.ts +30 -30
  81. package/src/core/execution/pooling/pool.ts +275 -275
  82. package/src/core/functions/array.ts +35 -35
  83. package/src/core/functions/control-flow.ts +79 -79
  84. package/src/core/functions/datetime.ts +237 -237
  85. package/src/core/functions/json.ts +70 -70
  86. package/src/core/functions/numeric.ts +297 -297
  87. package/src/core/functions/text.ts +334 -334
  88. package/src/core/functions/types.ts +33 -33
  89. package/src/core/hydration/types.ts +51 -51
  90. package/src/core/sql/sql-operator-config.ts +39 -39
  91. package/src/core/sql/sql.ts +152 -152
  92. package/src/decorators/column-decorator.ts +87 -87
  93. package/src/decorators/decorator-metadata.ts +143 -143
  94. package/src/decorators/entity.ts +93 -93
  95. package/src/decorators/index.ts +39 -39
  96. package/src/decorators/relations.ts +231 -231
  97. package/src/decorators/transformers/built-in/string-transformers.ts +231 -231
  98. package/src/decorators/transformers/transformer-decorators.ts +134 -134
  99. package/src/decorators/transformers/transformer-executor.ts +171 -171
  100. package/src/decorators/transformers/transformer-metadata.ts +73 -73
  101. package/src/decorators/validators/built-in/br-cep-validator.ts +55 -55
  102. package/src/decorators/validators/built-in/br-cnpj-validator.ts +105 -105
  103. package/src/decorators/validators/built-in/br-cpf-validator.ts +103 -103
  104. package/src/decorators/validators/country-validator-registry.ts +64 -64
  105. package/src/decorators/validators/country-validators-decorators.ts +203 -203
  106. package/src/decorators/validators/country-validators.ts +105 -105
  107. package/src/decorators/validators/validator-metadata.ts +59 -59
  108. package/src/dto/dto-types.ts +226 -226
  109. package/src/dto/filter-types.ts +141 -141
  110. package/src/dto/index.ts +95 -95
  111. package/src/dto/openapi/generators/base.ts +29 -29
  112. package/src/dto/openapi/generators/column.ts +37 -37
  113. package/src/dto/openapi/generators/dto.ts +91 -91
  114. package/src/dto/openapi/generators/filter.ts +75 -75
  115. package/src/dto/openapi/generators/nested-dto.ts +618 -618
  116. package/src/dto/openapi/generators/pagination.ts +111 -111
  117. package/src/dto/openapi/generators/relation-filter.ts +228 -228
  118. package/src/dto/openapi/index.ts +12 -12
  119. package/src/dto/openapi/types.ts +101 -101
  120. package/src/dto/openapi/utilities.ts +95 -95
  121. package/src/dto/pagination-utils.ts +150 -150
  122. package/src/dto/transform.ts +197 -197
  123. package/src/index.ts +86 -86
  124. package/src/orm/als.ts +58 -58
  125. package/src/orm/domain-event-bus.ts +119 -119
  126. package/src/orm/entity-context.ts +86 -86
  127. package/src/orm/entity-materializer.ts +159 -159
  128. package/src/orm/entity-meta.ts +97 -97
  129. package/src/orm/entity-registry.ts +39 -39
  130. package/src/orm/entity-relation-cache.ts +39 -39
  131. package/src/orm/execute.ts +194 -194
  132. package/src/orm/execution-context.ts +18 -18
  133. package/src/orm/hydration-context.ts +26 -26
  134. package/src/orm/hydration.ts +112 -112
  135. package/src/orm/identity-map.ts +61 -61
  136. package/src/orm/interceptor-pipeline.ts +16 -16
  137. package/src/orm/lazy-batch/belongs-to-many.ts +119 -119
  138. package/src/orm/lazy-batch/belongs-to.ts +108 -108
  139. package/src/orm/lazy-batch/has-many.ts +69 -69
  140. package/src/orm/lazy-batch/has-one.ts +68 -68
  141. package/src/orm/lazy-batch/shared.ts +125 -125
  142. package/src/orm/orm-session.ts +620 -620
  143. package/src/orm/orm.ts +34 -34
  144. package/src/orm/pooled-executor-factory.ts +121 -121
  145. package/src/orm/query-logger.ts +40 -40
  146. package/src/orm/relation-change-processor.ts +420 -420
  147. package/src/orm/relations/belongs-to.ts +143 -143
  148. package/src/orm/relations/has-many.ts +204 -204
  149. package/src/orm/relations/has-one.ts +174 -174
  150. package/src/orm/relations/many-to-many.ts +288 -288
  151. package/src/orm/relations/morph-many.ts +156 -156
  152. package/src/orm/relations/morph-one.ts +151 -151
  153. package/src/orm/relations/morph-to.ts +162 -162
  154. package/src/orm/runtime-types.ts +92 -92
  155. package/src/orm/save-graph.ts +561 -561
  156. package/src/orm/transaction-runner.ts +24 -24
  157. package/src/orm/unit-of-work.ts +426 -426
  158. package/src/query/index.ts +69 -69
  159. package/src/query/target.ts +46 -46
  160. package/src/query-builder/column-selector.ts +81 -81
  161. package/src/query-builder/delete.ts +141 -141
  162. package/src/query-builder/insert-query-state.ts +149 -149
  163. package/src/query-builder/insert.ts +117 -117
  164. package/src/query-builder/query-ast-service.ts +287 -287
  165. package/src/query-builder/query-resolution.ts +78 -78
  166. package/src/query-builder/raw-column-parser.ts +38 -38
  167. package/src/query-builder/relation-filter-utils.ts +153 -153
  168. package/src/query-builder/relation-join-planner.ts +10 -10
  169. package/src/query-builder/relation-manager.ts +87 -87
  170. package/src/query-builder/relation-projection-helper.ts +102 -102
  171. package/src/query-builder/relation-types.ts +9 -9
  172. package/src/query-builder/relation-utils.ts +15 -15
  173. package/src/query-builder/select/cte-facet.ts +40 -40
  174. package/src/query-builder/select/from-facet.ts +80 -80
  175. package/src/query-builder/select/join-facet.ts +62 -62
  176. package/src/query-builder/select/predicate-facet.ts +104 -104
  177. package/src/query-builder/select/projection-facet.ts +70 -70
  178. package/src/query-builder/select/relation-facet.ts +81 -81
  179. package/src/query-builder/select/setop-facet.ts +36 -36
  180. package/src/query-builder/select-helpers.ts +1 -1
  181. package/src/query-builder/select-query-builder-deps.ts +130 -130
  182. package/src/query-builder/select-query-state.ts +207 -207
  183. package/src/query-builder/update.ts +151 -151
  184. package/src/schema/column-types.ts +322 -322
  185. package/src/schema/table-guards.ts +37 -37
  186. package/src/schema/table.ts +281 -281
  187. package/src/tree/index.ts +13 -13
  188. package/src/tree/nested-set-strategy.ts +545 -545
  189. package/src/tree/tree-decorator.ts +397 -397
  190. package/src/tree/tree-manager.ts +754 -754
  191. package/src/tree/tree-query.ts +427 -427
  192. package/src/tree/tree-types.ts +299 -299
@@ -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
+ }