@simplysm/orm-common 13.0.69 → 13.0.70
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 +54 -1447
- package/dist/create-db-context.d.ts +10 -10
- package/dist/create-db-context.js +9 -9
- package/dist/create-db-context.js.map +1 -1
- package/dist/ddl/column-ddl.d.ts +4 -4
- package/dist/ddl/initialize.d.ts +17 -17
- package/dist/ddl/initialize.js +2 -2
- package/dist/ddl/initialize.js.map +1 -1
- package/dist/ddl/relation-ddl.d.ts +6 -6
- package/dist/ddl/schema-ddl.d.ts +4 -4
- package/dist/ddl/table-ddl.d.ts +24 -24
- package/dist/ddl/table-ddl.js +4 -4
- package/dist/ddl/table-ddl.js.map +1 -1
- package/dist/errors/db-transaction-error.d.ts +15 -15
- package/dist/errors/db-transaction-error.d.ts.map +1 -1
- package/dist/exec/executable.d.ts +23 -23
- package/dist/exec/executable.js +3 -3
- package/dist/exec/executable.js.map +1 -1
- package/dist/exec/queryable.d.ts +160 -160
- package/dist/exec/queryable.js +119 -119
- package/dist/exec/queryable.js.map +1 -1
- package/dist/exec/search-parser.d.ts +37 -37
- package/dist/exec/search-parser.d.ts.map +1 -1
- package/dist/expr/expr-unit.d.ts +4 -4
- package/dist/expr/expr.d.ts +257 -257
- package/dist/expr/expr.js +265 -265
- package/dist/expr/expr.js.map +1 -1
- package/dist/query-builder/base/expr-renderer-base.d.ts +9 -9
- package/dist/query-builder/base/expr-renderer-base.js +2 -2
- package/dist/query-builder/base/expr-renderer-base.js.map +1 -1
- package/dist/query-builder/base/query-builder-base.d.ts +26 -26
- package/dist/query-builder/base/query-builder-base.d.ts.map +1 -1
- package/dist/query-builder/base/query-builder-base.js +22 -22
- package/dist/query-builder/base/query-builder-base.js.map +1 -1
- package/dist/query-builder/mssql/mssql-expr-renderer.d.ts +4 -4
- package/dist/query-builder/mssql/mssql-expr-renderer.d.ts.map +1 -1
- package/dist/query-builder/mssql/mssql-expr-renderer.js +18 -18
- package/dist/query-builder/mssql/mssql-expr-renderer.js.map +1 -1
- package/dist/query-builder/mssql/mssql-query-builder.d.ts +2 -2
- package/dist/query-builder/mssql/mssql-query-builder.d.ts.map +1 -1
- package/dist/query-builder/mssql/mssql-query-builder.js +11 -11
- package/dist/query-builder/mssql/mssql-query-builder.js.map +1 -1
- package/dist/query-builder/mysql/mysql-expr-renderer.d.ts +4 -4
- package/dist/query-builder/mysql/mysql-expr-renderer.d.ts.map +1 -1
- package/dist/query-builder/mysql/mysql-expr-renderer.js +17 -17
- package/dist/query-builder/mysql/mysql-expr-renderer.js.map +1 -1
- package/dist/query-builder/mysql/mysql-query-builder.d.ts +8 -8
- package/dist/query-builder/mysql/mysql-query-builder.d.ts.map +1 -1
- package/dist/query-builder/mysql/mysql-query-builder.js +5 -5
- package/dist/query-builder/mysql/mysql-query-builder.js.map +1 -1
- package/dist/query-builder/postgresql/postgresql-expr-renderer.d.ts +4 -4
- package/dist/query-builder/postgresql/postgresql-expr-renderer.d.ts.map +1 -1
- package/dist/query-builder/postgresql/postgresql-expr-renderer.js +17 -17
- package/dist/query-builder/postgresql/postgresql-expr-renderer.js.map +1 -1
- package/dist/query-builder/postgresql/postgresql-query-builder.d.ts +5 -5
- package/dist/query-builder/postgresql/postgresql-query-builder.d.ts.map +1 -1
- package/dist/query-builder/postgresql/postgresql-query-builder.js +8 -8
- package/dist/query-builder/postgresql/postgresql-query-builder.js.map +1 -1
- package/dist/query-builder/query-builder.d.ts +1 -1
- package/dist/schema/factory/column-builder.d.ts +79 -79
- package/dist/schema/factory/column-builder.js +42 -42
- package/dist/schema/factory/index-builder.d.ts +39 -39
- package/dist/schema/factory/index-builder.js +26 -26
- package/dist/schema/factory/relation-builder.d.ts +99 -99
- package/dist/schema/factory/relation-builder.d.ts.map +1 -1
- package/dist/schema/factory/relation-builder.js +38 -38
- package/dist/schema/procedure-builder.d.ts +49 -49
- package/dist/schema/procedure-builder.d.ts.map +1 -1
- package/dist/schema/procedure-builder.js +33 -33
- package/dist/schema/table-builder.d.ts +59 -59
- package/dist/schema/table-builder.d.ts.map +1 -1
- package/dist/schema/table-builder.js +43 -43
- package/dist/schema/view-builder.d.ts +49 -49
- package/dist/schema/view-builder.d.ts.map +1 -1
- package/dist/schema/view-builder.js +32 -32
- package/dist/types/column.d.ts +22 -22
- package/dist/types/column.js +1 -1
- package/dist/types/column.js.map +1 -1
- package/dist/types/db.d.ts +40 -40
- package/dist/types/expr.d.ts +59 -59
- package/dist/types/expr.d.ts.map +1 -1
- package/dist/types/query-def.d.ts +44 -44
- package/dist/types/query-def.d.ts.map +1 -1
- package/dist/utils/result-parser.d.ts +11 -11
- package/dist/utils/result-parser.js +3 -3
- package/dist/utils/result-parser.js.map +1 -1
- package/package.json +5 -5
- package/src/create-db-context.ts +20 -20
- package/src/ddl/column-ddl.ts +4 -4
- package/src/ddl/initialize.ts +259 -259
- package/src/ddl/relation-ddl.ts +89 -89
- package/src/ddl/schema-ddl.ts +4 -4
- package/src/ddl/table-ddl.ts +189 -189
- package/src/errors/db-transaction-error.ts +13 -13
- package/src/exec/executable.ts +25 -25
- package/src/exec/queryable.ts +2033 -2033
- package/src/exec/search-parser.ts +57 -57
- package/src/expr/expr-unit.ts +4 -4
- package/src/expr/expr.ts +2140 -2140
- package/src/query-builder/base/expr-renderer-base.ts +237 -237
- package/src/query-builder/base/query-builder-base.ts +213 -213
- package/src/query-builder/mssql/mssql-expr-renderer.ts +607 -607
- package/src/query-builder/mssql/mssql-query-builder.ts +650 -650
- package/src/query-builder/mysql/mysql-expr-renderer.ts +613 -613
- package/src/query-builder/mysql/mysql-query-builder.ts +759 -759
- package/src/query-builder/postgresql/postgresql-expr-renderer.ts +611 -611
- package/src/query-builder/postgresql/postgresql-query-builder.ts +686 -686
- package/src/query-builder/query-builder.ts +19 -19
- package/src/schema/factory/column-builder.ts +423 -423
- package/src/schema/factory/index-builder.ts +164 -164
- package/src/schema/factory/relation-builder.ts +453 -453
- package/src/schema/procedure-builder.ts +232 -232
- package/src/schema/table-builder.ts +319 -319
- package/src/schema/view-builder.ts +221 -221
- package/src/types/column.ts +188 -188
- package/src/types/db.ts +208 -208
- package/src/types/expr.ts +697 -697
- package/src/types/query-def.ts +513 -513
- package/src/utils/result-parser.ts +458 -458
- package/tests/db-context/create-db-context.spec.ts +224 -0
- package/tests/db-context/define-db-context.spec.ts +68 -0
- package/tests/ddl/basic.expected.ts +341 -0
- package/tests/ddl/basic.spec.ts +714 -0
- package/tests/ddl/column-builder.expected.ts +310 -0
- package/tests/ddl/column-builder.spec.ts +637 -0
- package/tests/ddl/index-builder.expected.ts +38 -0
- package/tests/ddl/index-builder.spec.ts +202 -0
- package/tests/ddl/procedure-builder.expected.ts +52 -0
- package/tests/ddl/procedure-builder.spec.ts +234 -0
- package/tests/ddl/relation-builder.expected.ts +36 -0
- package/tests/ddl/relation-builder.spec.ts +372 -0
- package/tests/ddl/table-builder.expected.ts +113 -0
- package/tests/ddl/table-builder.spec.ts +433 -0
- package/tests/ddl/view-builder.expected.ts +38 -0
- package/tests/ddl/view-builder.spec.ts +176 -0
- package/tests/dml/delete.expected.ts +96 -0
- package/tests/dml/delete.spec.ts +160 -0
- package/tests/dml/insert.expected.ts +192 -0
- package/tests/dml/insert.spec.ts +288 -0
- package/tests/dml/update.expected.ts +176 -0
- package/tests/dml/update.spec.ts +318 -0
- package/tests/dml/upsert.expected.ts +215 -0
- package/tests/dml/upsert.spec.ts +242 -0
- package/tests/errors/queryable-errors.spec.ts +177 -0
- package/tests/escape.spec.ts +100 -0
- package/tests/examples/pivot.expected.ts +211 -0
- package/tests/examples/pivot.spec.ts +533 -0
- package/tests/examples/sampling.expected.ts +69 -0
- package/tests/examples/sampling.spec.ts +104 -0
- package/tests/examples/unpivot.expected.ts +120 -0
- package/tests/examples/unpivot.spec.ts +226 -0
- package/tests/exec/search-parser.spec.ts +283 -0
- package/tests/executable/basic.expected.ts +18 -0
- package/tests/executable/basic.spec.ts +54 -0
- package/tests/expr/comparison.expected.ts +282 -0
- package/tests/expr/comparison.spec.ts +400 -0
- package/tests/expr/conditional.expected.ts +134 -0
- package/tests/expr/conditional.spec.ts +276 -0
- package/tests/expr/date.expected.ts +332 -0
- package/tests/expr/date.spec.ts +526 -0
- package/tests/expr/math.expected.ts +62 -0
- package/tests/expr/math.spec.ts +106 -0
- package/tests/expr/string.expected.ts +218 -0
- package/tests/expr/string.spec.ts +356 -0
- package/tests/expr/utility.expected.ts +147 -0
- package/tests/expr/utility.spec.ts +182 -0
- package/tests/select/basic.expected.ts +322 -0
- package/tests/select/basic.spec.ts +502 -0
- package/tests/select/filter.expected.ts +357 -0
- package/tests/select/filter.spec.ts +1068 -0
- package/tests/select/group.expected.ts +169 -0
- package/tests/select/group.spec.ts +244 -0
- package/tests/select/join.expected.ts +582 -0
- package/tests/select/join.spec.ts +805 -0
- package/tests/select/order.expected.ts +150 -0
- package/tests/select/order.spec.ts +189 -0
- package/tests/select/recursive-cte.expected.ts +244 -0
- package/tests/select/recursive-cte.spec.ts +514 -0
- package/tests/select/result-meta.spec.ts +270 -0
- package/tests/select/subquery.expected.ts +363 -0
- package/tests/select/subquery.spec.ts +537 -0
- package/tests/select/view.expected.ts +155 -0
- package/tests/select/view.spec.ts +235 -0
- package/tests/select/window.expected.ts +345 -0
- package/tests/select/window.spec.ts +618 -0
- package/tests/setup/MockExecutor.ts +18 -0
- package/tests/setup/TestDbContext.ts +59 -0
- package/tests/setup/models/Company.ts +13 -0
- package/tests/setup/models/Employee.ts +10 -0
- package/tests/setup/models/MonthlySales.ts +11 -0
- package/tests/setup/models/Post.ts +16 -0
- package/tests/setup/models/Sales.ts +10 -0
- package/tests/setup/models/User.ts +19 -0
- package/tests/setup/procedure/GetAllUsers.ts +9 -0
- package/tests/setup/procedure/GetUserById.ts +12 -0
- package/tests/setup/test-utils.ts +72 -0
- package/tests/setup/views/ActiveUsers.ts +8 -0
- package/tests/setup/views/UserSummary.ts +11 -0
- package/tests/types/nullable-queryable-record.spec.ts +145 -0
- package/tests/utils/result-parser-perf.spec.ts +210 -0
- package/tests/utils/result-parser.spec.ts +701 -0
- package/docs/expressions.md +0 -172
- package/docs/queries.md +0 -444
- package/docs/schema.md +0 -245
|
@@ -1,458 +1,458 @@
|
|
|
1
|
-
import { bytesFromHex, DateOnly, DateTime, objEqual, Time, Uuid } from "@simplysm/core-common";
|
|
2
|
-
import type { ColumnPrimitiveStr } from "../types/column";
|
|
3
|
-
import type { ResultMeta } from "../types/db";
|
|
4
|
-
|
|
5
|
-
declare function setImmediate(callback: () => void): void;
|
|
6
|
-
|
|
7
|
-
// ============================================
|
|
8
|
-
// Type Parsers
|
|
9
|
-
// ============================================
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* @param value -
|
|
15
|
-
* @param type -
|
|
16
|
-
* @returns
|
|
17
|
-
* @throws
|
|
18
|
-
*/
|
|
19
|
-
function parseValue(value: unknown, type: ColumnPrimitiveStr): unknown {
|
|
20
|
-
// null/undefined
|
|
21
|
-
if (value == null) {
|
|
22
|
-
return undefined;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
switch (type) {
|
|
26
|
-
case "number": {
|
|
27
|
-
const num = Number(value);
|
|
28
|
-
if (Number.isNaN(num)) {
|
|
29
|
-
throw new Error(`
|
|
30
|
-
}
|
|
31
|
-
return num;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
case "string":
|
|
35
|
-
return String(value);
|
|
36
|
-
|
|
37
|
-
case "boolean":
|
|
38
|
-
// 0, 1, "0", "1", true, false
|
|
39
|
-
if (value === 0 || value === "0" || value === false) return false;
|
|
40
|
-
if (value === 1 || value === "1" || value === true) return true;
|
|
41
|
-
return Boolean(value);
|
|
42
|
-
|
|
43
|
-
case "DateTime":
|
|
44
|
-
return DateTime.parse(value as string);
|
|
45
|
-
|
|
46
|
-
case "DateOnly":
|
|
47
|
-
return DateOnly.parse(value as string);
|
|
48
|
-
|
|
49
|
-
case "Time":
|
|
50
|
-
return Time.parse(value as string);
|
|
51
|
-
|
|
52
|
-
case "Uuid":
|
|
53
|
-
if (value instanceof Uint8Array) return Uuid.fromBytes(value);
|
|
54
|
-
return new Uuid(value as string);
|
|
55
|
-
|
|
56
|
-
case "Bytes":
|
|
57
|
-
if (value instanceof Uint8Array) return value;
|
|
58
|
-
if (typeof value === "string") return bytesFromHex(value);
|
|
59
|
-
throw new Error(`
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// ============================================
|
|
64
|
-
// Grouping Utilities
|
|
65
|
-
// ============================================
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* flat
|
|
69
|
-
*
|
|
70
|
-
* @example
|
|
71
|
-
* { "posts.id": 1, "posts.title": "Hi" } → { posts: { id: 1, title: "Hi" } }
|
|
72
|
-
*/
|
|
73
|
-
function flatToNested(
|
|
74
|
-
record: Record<string, unknown>,
|
|
75
|
-
columns: Record<string, ColumnPrimitiveStr>,
|
|
76
|
-
): Record<string, unknown> {
|
|
77
|
-
const result: Record<string, unknown> = {};
|
|
78
|
-
|
|
79
|
-
for (const [key, type] of Object.entries(columns)) {
|
|
80
|
-
const rawValue = record[key];
|
|
81
|
-
const parsedValue = parseValue(rawValue, type);
|
|
82
|
-
|
|
83
|
-
// undefined
|
|
84
|
-
if (parsedValue === undefined) continue;
|
|
85
|
-
|
|
86
|
-
if (key.includes(".")) {
|
|
87
|
-
//
|
|
88
|
-
const parts = key.split(".");
|
|
89
|
-
let current = result;
|
|
90
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
91
|
-
const part = parts[i];
|
|
92
|
-
if (current[part] == null) {
|
|
93
|
-
current[part] = {};
|
|
94
|
-
}
|
|
95
|
-
current = current[part] as Record<string, unknown>;
|
|
96
|
-
}
|
|
97
|
-
current[parts[parts.length - 1]] = parsedValue;
|
|
98
|
-
} else {
|
|
99
|
-
//
|
|
100
|
-
result[key] = parsedValue;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return result;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
*
|
|
109
|
-
*/
|
|
110
|
-
function isEmptyObject(obj: Record<string, unknown>): boolean {
|
|
111
|
-
return Object.keys(obj).length === 0;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// ============================================
|
|
115
|
-
// Main Function
|
|
116
|
-
// ============================================
|
|
117
|
-
|
|
118
|
-
/** yield
|
|
119
|
-
const YIELD_INTERVAL = 100;
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
const yieldToEventLoop: () => Promise<void> =
|
|
123
|
-
typeof setImmediate !== "undefined"
|
|
124
|
-
? () => new Promise<void>((resolve) => setImmediate(resolve))
|
|
125
|
-
: () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* DB
|
|
129
|
-
*
|
|
130
|
-
* @param rawResults -
|
|
131
|
-
* @param meta -
|
|
132
|
-
* @returns
|
|
133
|
-
* @throws
|
|
134
|
-
*
|
|
135
|
-
* @remarks
|
|
136
|
-
* - meta
|
|
137
|
-
* - async only:
|
|
138
|
-
* - browser/node
|
|
139
|
-
* -
|
|
140
|
-
*
|
|
141
|
-
* @example
|
|
142
|
-
* ```typescript
|
|
143
|
-
* // 1.
|
|
144
|
-
* const raw = [{ id: "1", createdAt: "2026-01-07T10:00:00.000Z" }];
|
|
145
|
-
* const meta = { columns: { id: "number", createdAt: "DateTime" }, joins: {} };
|
|
146
|
-
* const result = await parseQueryResult(raw, meta);
|
|
147
|
-
* // [{ id: 1, createdAt: DateTime(...) }]
|
|
148
|
-
*
|
|
149
|
-
* // 2. JOIN
|
|
150
|
-
* const raw = [
|
|
151
|
-
* { id: 1, name: "User1", "posts.id": 10, "posts.title": "Post1" },
|
|
152
|
-
* { id: 1, name: "User1", "posts.id": 11, "posts.title": "Post2" },
|
|
153
|
-
* ];
|
|
154
|
-
* const meta = {
|
|
155
|
-
* columns: { id: "number", name: "string", "posts.id": "number", "posts.title": "string" },
|
|
156
|
-
* joins: { posts: { isSingle: false } }
|
|
157
|
-
* };
|
|
158
|
-
* const result = await parseQueryResult(raw, meta);
|
|
159
|
-
* // [{ id: 1, name: "User1", posts: [{ id: 10, title: "Post1" }, { id: 11, title: "Post2" }] }]
|
|
160
|
-
* ```
|
|
161
|
-
*/
|
|
162
|
-
export async function parseQueryResult<TRecord>(
|
|
163
|
-
rawResults: Record<string, unknown>[],
|
|
164
|
-
meta: ResultMeta,
|
|
165
|
-
): Promise<TRecord[] | undefined> {
|
|
166
|
-
//
|
|
167
|
-
if (rawResults.length === 0) {
|
|
168
|
-
return undefined;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const joinKeys = Object.keys(meta.joins);
|
|
172
|
-
|
|
173
|
-
//
|
|
174
|
-
if (joinKeys.length === 0) {
|
|
175
|
-
return parseSimpleRecords<TRecord>(rawResults, meta.columns);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
//
|
|
179
|
-
return parseJoinedRecords<TRecord>(rawResults, meta);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
*
|
|
184
|
-
*/
|
|
185
|
-
async function parseSimpleRecords<TRecord>(
|
|
186
|
-
rawResults: Record<string, unknown>[],
|
|
187
|
-
columns: Record<string, ColumnPrimitiveStr>,
|
|
188
|
-
): Promise<TRecord[] | undefined> {
|
|
189
|
-
const results: Record<string, unknown>[] = [];
|
|
190
|
-
|
|
191
|
-
for (let i = 0; i < rawResults.length; i++) {
|
|
192
|
-
//
|
|
193
|
-
if (i > 0 && i % YIELD_INTERVAL === 0) {
|
|
194
|
-
await yieldToEventLoop();
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const parsed = flatToNested(rawResults[i], columns);
|
|
198
|
-
|
|
199
|
-
//
|
|
200
|
-
if (!isEmptyObject(parsed)) {
|
|
201
|
-
results.push(parsed);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
//
|
|
206
|
-
return results.length > 0 ? (results as TRecord[]) : undefined;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* JOIN
|
|
211
|
-
* "posts" (1) < "posts.comments" (2)
|
|
212
|
-
*/
|
|
213
|
-
function sortJoinKeysByDepth(joinKeys: string[]): string[] {
|
|
214
|
-
return [...joinKeys].sort((a, b) => {
|
|
215
|
-
const depthA = a.split(".").length;
|
|
216
|
-
const depthB = b.split(".").length;
|
|
217
|
-
return depthA - depthB; //
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
*
|
|
223
|
-
*/
|
|
224
|
-
async function parseJoinedRecords<TRecord>(
|
|
225
|
-
rawResults: Record<string, unknown>[],
|
|
226
|
-
meta: ResultMeta,
|
|
227
|
-
): Promise<TRecord[] | undefined> {
|
|
228
|
-
// 1.
|
|
229
|
-
const nestedRecords: Record<string, unknown>[] = [];
|
|
230
|
-
for (let i = 0; i < rawResults.length; i++) {
|
|
231
|
-
if (i > 0 && i % YIELD_INTERVAL === 0) {
|
|
232
|
-
await yieldToEventLoop();
|
|
233
|
-
}
|
|
234
|
-
nestedRecords.push(flatToNested(rawResults[i], meta.columns));
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// 2. JOIN
|
|
238
|
-
const sortedJoinKeys = sortJoinKeysByDepth(Object.keys(meta.joins));
|
|
239
|
-
|
|
240
|
-
// 3.
|
|
241
|
-
const results = groupRecordsRecursively(nestedRecords, sortedJoinKeys, meta.joins, "");
|
|
242
|
-
|
|
243
|
-
// 4.
|
|
244
|
-
const filteredResults = results.filter((r) => !isEmptyObject(r));
|
|
245
|
-
|
|
246
|
-
return filteredResults.length > 0 ? (filteredResults as TRecord[]) : undefined;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* JSON.stringify
|
|
253
|
-
*/
|
|
254
|
-
function serializeGroupKey(groupKey: Record<string, unknown>, cachedKeyOrder?: string[]): string {
|
|
255
|
-
const keys = cachedKeyOrder ?? Object.keys(groupKey).sort((a, b) => a.localeCompare(b));
|
|
256
|
-
return keys.map((k) => `${k}:${groupKey[k] === null ? "null" : String(groupKey[k])}`).join("|");
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
* @param records -
|
|
265
|
-
* @param allJoinKeys -
|
|
266
|
-
* @param joinsConfig - JOIN
|
|
267
|
-
* @param currentPath -
|
|
268
|
-
*/
|
|
269
|
-
function groupRecordsRecursively(
|
|
270
|
-
records: Record<string, unknown>[],
|
|
271
|
-
allJoinKeys: string[],
|
|
272
|
-
joinsConfig: Record<string, { isSingle: boolean }>,
|
|
273
|
-
currentPath: string,
|
|
274
|
-
): Record<string, unknown>[] {
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
const childJoinKeys = allJoinKeys.filter((key) => {
|
|
279
|
-
if (currentPath === "") {
|
|
280
|
-
//
|
|
281
|
-
return !key.includes(".");
|
|
282
|
-
} else {
|
|
283
|
-
//
|
|
284
|
-
return (
|
|
285
|
-
key.startsWith(currentPath + ".") && key.slice(currentPath.length + 1).indexOf(".") === -1
|
|
286
|
-
);
|
|
287
|
-
}
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
if (childJoinKeys.length === 0) {
|
|
291
|
-
//
|
|
292
|
-
return records;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// Map
|
|
296
|
-
const groupMap = new Map<string, Record<string, unknown>>();
|
|
297
|
-
|
|
298
|
-
//
|
|
299
|
-
let groupKeyOrder: string[] | undefined;
|
|
300
|
-
|
|
301
|
-
for (const record of records) {
|
|
302
|
-
//
|
|
303
|
-
const groupKey = extractGroupKey(record, childJoinKeys);
|
|
304
|
-
if (groupKeyOrder == null) {
|
|
305
|
-
groupKeyOrder = Object.keys(groupKey).sort((a, b) => a.localeCompare(b));
|
|
306
|
-
}
|
|
307
|
-
const keyStr = serializeGroupKey(groupKey, groupKeyOrder);
|
|
308
|
-
|
|
309
|
-
const existingGroup = groupMap.get(keyStr);
|
|
310
|
-
|
|
311
|
-
if (existingGroup != null) {
|
|
312
|
-
//
|
|
313
|
-
for (const joinKey of childJoinKeys) {
|
|
314
|
-
const localKey = currentPath === "" ? joinKey : joinKey.slice(currentPath.length + 1);
|
|
315
|
-
mergeJoinData(existingGroup, record, localKey, joinsConfig[joinKey].isSingle);
|
|
316
|
-
}
|
|
317
|
-
} else {
|
|
318
|
-
//
|
|
319
|
-
const newGroup = { ...record };
|
|
320
|
-
|
|
321
|
-
//
|
|
322
|
-
for (const joinKey of childJoinKeys) {
|
|
323
|
-
const localKey = currentPath === "" ? joinKey : joinKey.slice(currentPath.length + 1);
|
|
324
|
-
const joinData = newGroup[localKey] as Record<string, unknown> | undefined;
|
|
325
|
-
|
|
326
|
-
if (joinData != null && !isEmptyObject(joinData)) {
|
|
327
|
-
if (!joinsConfig[joinKey].isSingle) {
|
|
328
|
-
//
|
|
329
|
-
newGroup[localKey] = [joinData];
|
|
330
|
-
}
|
|
331
|
-
} else {
|
|
332
|
-
//
|
|
333
|
-
delete newGroup[localKey];
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
groupMap.set(keyStr, newGroup);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// Map
|
|
342
|
-
const grouped = Array.from(groupMap.values());
|
|
343
|
-
|
|
344
|
-
//
|
|
345
|
-
for (const group of grouped) {
|
|
346
|
-
for (const joinKey of childJoinKeys) {
|
|
347
|
-
const localKey = currentPath === "" ? joinKey : joinKey.slice(currentPath.length + 1);
|
|
348
|
-
const joinData = group[localKey];
|
|
349
|
-
|
|
350
|
-
if (Array.isArray(joinData) && joinData.length > 0) {
|
|
351
|
-
//
|
|
352
|
-
group[localKey] = groupRecordsRecursively(
|
|
353
|
-
joinData as Record<string, unknown>[],
|
|
354
|
-
allJoinKeys,
|
|
355
|
-
joinsConfig,
|
|
356
|
-
joinKey,
|
|
357
|
-
);
|
|
358
|
-
} else if (joinData != null && typeof joinData === "object" && !Array.isArray(joinData)) {
|
|
359
|
-
//
|
|
360
|
-
const processed = groupRecordsRecursively(
|
|
361
|
-
[joinData as Record<string, unknown>],
|
|
362
|
-
allJoinKeys,
|
|
363
|
-
joinsConfig,
|
|
364
|
-
joinKey,
|
|
365
|
-
);
|
|
366
|
-
if (processed.length > 0) {
|
|
367
|
-
group[localKey] = processed[0];
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// __hashSet__
|
|
374
|
-
for (const group of grouped) {
|
|
375
|
-
for (const key of Object.keys(group)) {
|
|
376
|
-
if (key.startsWith("__hashSet__")) {
|
|
377
|
-
delete group[key];
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
return grouped;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
*
|
|
387
|
-
*/
|
|
388
|
-
function extractGroupKey(
|
|
389
|
-
record: Record<string, unknown>,
|
|
390
|
-
joinKeys: string[],
|
|
391
|
-
): Record<string, unknown> {
|
|
392
|
-
const result: Record<string, unknown> = {};
|
|
393
|
-
for (const [key, value] of Object.entries(record)) {
|
|
394
|
-
//
|
|
395
|
-
if (!joinKeys.some((jk) => jk === key || jk.startsWith(key + "."))) {
|
|
396
|
-
//
|
|
397
|
-
if (value == null || typeof value !== "object") {
|
|
398
|
-
result[key] = value;
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
return result;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
/**
|
|
406
|
-
* JOIN
|
|
407
|
-
*/
|
|
408
|
-
function mergeJoinData(
|
|
409
|
-
existingGroup: Record<string, unknown>,
|
|
410
|
-
newRecord: Record<string, unknown>,
|
|
411
|
-
localKey: string,
|
|
412
|
-
isSingle: boolean,
|
|
413
|
-
): void {
|
|
414
|
-
const newJoinData = newRecord[localKey] as Record<string, unknown> | undefined;
|
|
415
|
-
|
|
416
|
-
if (newJoinData == null || isEmptyObject(newJoinData)) {
|
|
417
|
-
return; //
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
const existingJoinData = existingGroup[localKey];
|
|
421
|
-
|
|
422
|
-
if (isSingle) {
|
|
423
|
-
// isSingle: true
|
|
424
|
-
if (existingJoinData != null) {
|
|
425
|
-
if (!objEqual(existingJoinData as Record<string, unknown>, newJoinData)) {
|
|
426
|
-
throw new Error(`isSingle
|
|
427
|
-
}
|
|
428
|
-
} else {
|
|
429
|
-
existingGroup[localKey] = newJoinData;
|
|
430
|
-
}
|
|
431
|
-
} else {
|
|
432
|
-
// isSingle: false →
|
|
433
|
-
const hashSetKey = `__hashSet__${localKey}`;
|
|
434
|
-
if (!Array.isArray(existingJoinData)) {
|
|
435
|
-
existingGroup[localKey] = [newJoinData];
|
|
436
|
-
//
|
|
437
|
-
existingGroup[hashSetKey] = new Set([serializeGroupKey(newJoinData)]);
|
|
438
|
-
} else {
|
|
439
|
-
// Set
|
|
440
|
-
const hashSet = existingGroup[hashSetKey] as Set<string> | undefined;
|
|
441
|
-
const newHash = serializeGroupKey(newJoinData);
|
|
442
|
-
if (hashSet != null) {
|
|
443
|
-
if (!hashSet.has(newHash)) {
|
|
444
|
-
hashSet.add(newHash);
|
|
445
|
-
existingJoinData.push(newJoinData);
|
|
446
|
-
}
|
|
447
|
-
} else {
|
|
448
|
-
//
|
|
449
|
-
const isDuplicate = existingJoinData.some((item) =>
|
|
450
|
-
objEqual(item as Record<string, unknown>, newJoinData),
|
|
451
|
-
);
|
|
452
|
-
if (!isDuplicate) {
|
|
453
|
-
existingJoinData.push(newJoinData);
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
}
|
|
1
|
+
import { bytesFromHex, DateOnly, DateTime, objEqual, Time, Uuid } from "@simplysm/core-common";
|
|
2
|
+
import type { ColumnPrimitiveStr } from "../types/column";
|
|
3
|
+
import type { ResultMeta } from "../types/db";
|
|
4
|
+
|
|
5
|
+
declare function setImmediate(callback: () => void): void;
|
|
6
|
+
|
|
7
|
+
// ============================================
|
|
8
|
+
// Type Parsers
|
|
9
|
+
// ============================================
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parse value to specified type
|
|
13
|
+
*
|
|
14
|
+
* @param value - value to parse
|
|
15
|
+
* @param type - target type (ColumnPrimitiveStr)
|
|
16
|
+
* @returns parsed value
|
|
17
|
+
* @throws Error if parsing fails
|
|
18
|
+
*/
|
|
19
|
+
function parseValue(value: unknown, type: ColumnPrimitiveStr): unknown {
|
|
20
|
+
// null/undefined returned as-is (key removal handled by caller)
|
|
21
|
+
if (value == null) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
switch (type) {
|
|
26
|
+
case "number": {
|
|
27
|
+
const num = Number(value);
|
|
28
|
+
if (Number.isNaN(num)) {
|
|
29
|
+
throw new Error(`Failed to parse number: ${String(value)}`);
|
|
30
|
+
}
|
|
31
|
+
return num;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
case "string":
|
|
35
|
+
return String(value);
|
|
36
|
+
|
|
37
|
+
case "boolean":
|
|
38
|
+
// Handle 0, 1, "0", "1", true, false, etc.
|
|
39
|
+
if (value === 0 || value === "0" || value === false) return false;
|
|
40
|
+
if (value === 1 || value === "1" || value === true) return true;
|
|
41
|
+
return Boolean(value);
|
|
42
|
+
|
|
43
|
+
case "DateTime":
|
|
44
|
+
return DateTime.parse(value as string);
|
|
45
|
+
|
|
46
|
+
case "DateOnly":
|
|
47
|
+
return DateOnly.parse(value as string);
|
|
48
|
+
|
|
49
|
+
case "Time":
|
|
50
|
+
return Time.parse(value as string);
|
|
51
|
+
|
|
52
|
+
case "Uuid":
|
|
53
|
+
if (value instanceof Uint8Array) return Uuid.fromBytes(value);
|
|
54
|
+
return new Uuid(value as string);
|
|
55
|
+
|
|
56
|
+
case "Bytes":
|
|
57
|
+
if (value instanceof Uint8Array) return value;
|
|
58
|
+
if (typeof value === "string") return bytesFromHex(value);
|
|
59
|
+
throw new Error(`Failed to parse Bytes: ${typeof value}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ============================================
|
|
64
|
+
// Grouping Utilities
|
|
65
|
+
// ============================================
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Transform flat record to nested object
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* { "posts.id": 1, "posts.title": "Hi" } → { posts: { id: 1, title: "Hi" } }
|
|
72
|
+
*/
|
|
73
|
+
function flatToNested(
|
|
74
|
+
record: Record<string, unknown>,
|
|
75
|
+
columns: Record<string, ColumnPrimitiveStr>,
|
|
76
|
+
): Record<string, unknown> {
|
|
77
|
+
const result: Record<string, unknown> = {};
|
|
78
|
+
|
|
79
|
+
for (const [key, type] of Object.entries(columns)) {
|
|
80
|
+
const rawValue = record[key];
|
|
81
|
+
const parsedValue = parseValue(rawValue, type);
|
|
82
|
+
|
|
83
|
+
// undefined values are not added as keys
|
|
84
|
+
if (parsedValue === undefined) continue;
|
|
85
|
+
|
|
86
|
+
if (key.includes(".")) {
|
|
87
|
+
// Nested key: "posts.id" → { posts: { id: ... } }
|
|
88
|
+
const parts = key.split(".");
|
|
89
|
+
let current = result;
|
|
90
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
91
|
+
const part = parts[i];
|
|
92
|
+
if (current[part] == null) {
|
|
93
|
+
current[part] = {};
|
|
94
|
+
}
|
|
95
|
+
current = current[part] as Record<string, unknown>;
|
|
96
|
+
}
|
|
97
|
+
current[parts[parts.length - 1]] = parsedValue;
|
|
98
|
+
} else {
|
|
99
|
+
// Simple key
|
|
100
|
+
result[key] = parsedValue;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Check if object is empty (all values are undefined)
|
|
109
|
+
*/
|
|
110
|
+
function isEmptyObject(obj: Record<string, unknown>): boolean {
|
|
111
|
+
return Object.keys(obj).length === 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ============================================
|
|
115
|
+
// Main Function
|
|
116
|
+
// ============================================
|
|
117
|
+
|
|
118
|
+
/** Yield interval: yield to event loop every N records */
|
|
119
|
+
const YIELD_INTERVAL = 100;
|
|
120
|
+
|
|
121
|
+
/** Event loop yield: setImmediate for Node.js, setTimeout fallback for browser */
|
|
122
|
+
const yieldToEventLoop: () => Promise<void> =
|
|
123
|
+
typeof setImmediate !== "undefined"
|
|
124
|
+
? () => new Promise<void>((resolve) => setImmediate(resolve))
|
|
125
|
+
: () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Transform DB query result to TypeScript object via ResultMeta
|
|
129
|
+
*
|
|
130
|
+
* @param rawResults - Raw result array from database
|
|
131
|
+
* @param meta - Type transformation and JOIN structure information (required)
|
|
132
|
+
* @returns Type-transformed and nested result array. Returns undefined if input is empty or no valid results
|
|
133
|
+
* @throws Error if type parsing fails
|
|
134
|
+
*
|
|
135
|
+
* @remarks
|
|
136
|
+
* - meta required: no need to call this function without meta (input = output)
|
|
137
|
+
* - async only: no synchronous version provided for large-scale processing to allow external interrupts
|
|
138
|
+
* - browser/node compatible: yields via setTimeout(resolve, 0)
|
|
139
|
+
* - empty result handling: returns undefined if input array is empty or all records are empty objects after parsing
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* ```typescript
|
|
143
|
+
* // 1. Simple type parsing
|
|
144
|
+
* const raw = [{ id: "1", createdAt: "2026-01-07T10:00:00.000Z" }];
|
|
145
|
+
* const meta = { columns: { id: "number", createdAt: "DateTime" }, joins: {} };
|
|
146
|
+
* const result = await parseQueryResult(raw, meta);
|
|
147
|
+
* // [{ id: 1, createdAt: DateTime(...) }]
|
|
148
|
+
*
|
|
149
|
+
* // 2. JOIN result nesting
|
|
150
|
+
* const raw = [
|
|
151
|
+
* { id: 1, name: "User1", "posts.id": 10, "posts.title": "Post1" },
|
|
152
|
+
* { id: 1, name: "User1", "posts.id": 11, "posts.title": "Post2" },
|
|
153
|
+
* ];
|
|
154
|
+
* const meta = {
|
|
155
|
+
* columns: { id: "number", name: "string", "posts.id": "number", "posts.title": "string" },
|
|
156
|
+
* joins: { posts: { isSingle: false } }
|
|
157
|
+
* };
|
|
158
|
+
* const result = await parseQueryResult(raw, meta);
|
|
159
|
+
* // [{ id: 1, name: "User1", posts: [{ id: 10, title: "Post1" }, { id: 11, title: "Post2" }] }]
|
|
160
|
+
* ```
|
|
161
|
+
*/
|
|
162
|
+
export async function parseQueryResult<TRecord>(
|
|
163
|
+
rawResults: Record<string, unknown>[],
|
|
164
|
+
meta: ResultMeta,
|
|
165
|
+
): Promise<TRecord[] | undefined> {
|
|
166
|
+
// Handle empty input
|
|
167
|
+
if (rawResults.length === 0) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const joinKeys = Object.keys(meta.joins);
|
|
172
|
+
|
|
173
|
+
// No JOINs: simple type parsing only
|
|
174
|
+
if (joinKeys.length === 0) {
|
|
175
|
+
return parseSimpleRecords<TRecord>(rawResults, meta.columns);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// With JOINs: grouping + nesting
|
|
179
|
+
return parseJoinedRecords<TRecord>(rawResults, meta);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Parse simple records without JOINs
|
|
184
|
+
*/
|
|
185
|
+
async function parseSimpleRecords<TRecord>(
|
|
186
|
+
rawResults: Record<string, unknown>[],
|
|
187
|
+
columns: Record<string, ColumnPrimitiveStr>,
|
|
188
|
+
): Promise<TRecord[] | undefined> {
|
|
189
|
+
const results: Record<string, unknown>[] = [];
|
|
190
|
+
|
|
191
|
+
for (let i = 0; i < rawResults.length; i++) {
|
|
192
|
+
// Yield to event loop
|
|
193
|
+
if (i > 0 && i % YIELD_INTERVAL === 0) {
|
|
194
|
+
await yieldToEventLoop();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const parsed = flatToNested(rawResults[i], columns);
|
|
198
|
+
|
|
199
|
+
// Exclude empty objects
|
|
200
|
+
if (!isEmptyObject(parsed)) {
|
|
201
|
+
results.push(parsed);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Return undefined for empty arrays
|
|
206
|
+
return results.length > 0 ? (results as TRecord[]) : undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Sort JOIN keys by depth (shallower ones first)
|
|
211
|
+
* "posts" (1) < "posts.comments" (2)
|
|
212
|
+
*/
|
|
213
|
+
function sortJoinKeysByDepth(joinKeys: string[]): string[] {
|
|
214
|
+
return [...joinKeys].sort((a, b) => {
|
|
215
|
+
const depthA = a.split(".").length;
|
|
216
|
+
const depthB = b.split(".").length;
|
|
217
|
+
return depthA - depthB; // Shallower ones first
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Parse records with JOINs (recursive grouping)
|
|
223
|
+
*/
|
|
224
|
+
async function parseJoinedRecords<TRecord>(
|
|
225
|
+
rawResults: Record<string, unknown>[],
|
|
226
|
+
meta: ResultMeta,
|
|
227
|
+
): Promise<TRecord[] | undefined> {
|
|
228
|
+
// 1. Transform all records to nested structure
|
|
229
|
+
const nestedRecords: Record<string, unknown>[] = [];
|
|
230
|
+
for (let i = 0; i < rawResults.length; i++) {
|
|
231
|
+
if (i > 0 && i % YIELD_INTERVAL === 0) {
|
|
232
|
+
await yieldToEventLoop();
|
|
233
|
+
}
|
|
234
|
+
nestedRecords.push(flatToNested(rawResults[i], meta.columns));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 2. Sort JOIN keys by depth (shallower ones first)
|
|
238
|
+
const sortedJoinKeys = sortJoinKeysByDepth(Object.keys(meta.joins));
|
|
239
|
+
|
|
240
|
+
// 3. Recursively group from root level
|
|
241
|
+
const results = groupRecordsRecursively(nestedRecords, sortedJoinKeys, meta.joins, "");
|
|
242
|
+
|
|
243
|
+
// 4. Filter empty results
|
|
244
|
+
const filteredResults = results.filter((r) => !isEmptyObject(r));
|
|
245
|
+
|
|
246
|
+
return filteredResults.length > 0 ? (filteredResults as TRecord[]) : undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Serialize group key to string (used as Map key)
|
|
251
|
+
*
|
|
252
|
+
* Custom serialization faster than JSON.stringify
|
|
253
|
+
*/
|
|
254
|
+
function serializeGroupKey(groupKey: Record<string, unknown>, cachedKeyOrder?: string[]): string {
|
|
255
|
+
const keys = cachedKeyOrder ?? Object.keys(groupKey).sort((a, b) => a.localeCompare(b));
|
|
256
|
+
return keys.map((k) => `${k}:${groupKey[k] === null ? "null" : String(groupKey[k])}`).join("|");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Recursively group records for current path
|
|
261
|
+
*
|
|
262
|
+
* Achieves O(n) complexity with Map-based grouping
|
|
263
|
+
*
|
|
264
|
+
* @param records - Record array to group
|
|
265
|
+
* @param allJoinKeys - All JOIN keys (sorted by depth)
|
|
266
|
+
* @param joinsConfig - JOIN configuration
|
|
267
|
+
* @param currentPath - Current path (e.g., "", "posts", "posts.comments")
|
|
268
|
+
*/
|
|
269
|
+
function groupRecordsRecursively(
|
|
270
|
+
records: Record<string, unknown>[],
|
|
271
|
+
allJoinKeys: string[],
|
|
272
|
+
joinsConfig: Record<string, { isSingle: boolean }>,
|
|
273
|
+
currentPath: string,
|
|
274
|
+
): Record<string, unknown>[] {
|
|
275
|
+
// Find JOIN keys directly corresponding to current path
|
|
276
|
+
// e.g., currentPath="" → ["posts", "company"]
|
|
277
|
+
// e.g., currentPath="posts" → ["posts.comments"]
|
|
278
|
+
const childJoinKeys = allJoinKeys.filter((key) => {
|
|
279
|
+
if (currentPath === "") {
|
|
280
|
+
// Root level: keys without dots
|
|
281
|
+
return !key.includes(".");
|
|
282
|
+
} else {
|
|
283
|
+
// Sublevel: current path + "." + key
|
|
284
|
+
return (
|
|
285
|
+
key.startsWith(currentPath + ".") && key.slice(currentPath.length + 1).indexOf(".") === -1
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (childJoinKeys.length === 0) {
|
|
291
|
+
// No more JOINs to group
|
|
292
|
+
return records;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Map-based grouping (O(n) complexity)
|
|
296
|
+
const groupMap = new Map<string, Record<string, unknown>>();
|
|
297
|
+
|
|
298
|
+
// Key order caching (determined from first record and reused)
|
|
299
|
+
let groupKeyOrder: string[] | undefined;
|
|
300
|
+
|
|
301
|
+
for (const record of records) {
|
|
302
|
+
// Extract and serialize group key (excluding JOIN keys)
|
|
303
|
+
const groupKey = extractGroupKey(record, childJoinKeys);
|
|
304
|
+
if (groupKeyOrder == null) {
|
|
305
|
+
groupKeyOrder = Object.keys(groupKey).sort((a, b) => a.localeCompare(b));
|
|
306
|
+
}
|
|
307
|
+
const keyStr = serializeGroupKey(groupKey, groupKeyOrder);
|
|
308
|
+
|
|
309
|
+
const existingGroup = groupMap.get(keyStr);
|
|
310
|
+
|
|
311
|
+
if (existingGroup != null) {
|
|
312
|
+
// Merge JOIN data to existing group
|
|
313
|
+
for (const joinKey of childJoinKeys) {
|
|
314
|
+
const localKey = currentPath === "" ? joinKey : joinKey.slice(currentPath.length + 1);
|
|
315
|
+
mergeJoinData(existingGroup, record, localKey, joinsConfig[joinKey].isSingle);
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
// Generate new group
|
|
319
|
+
const newGroup = { ...record };
|
|
320
|
+
|
|
321
|
+
// Initialize each JOIN key as array or single object
|
|
322
|
+
for (const joinKey of childJoinKeys) {
|
|
323
|
+
const localKey = currentPath === "" ? joinKey : joinKey.slice(currentPath.length + 1);
|
|
324
|
+
const joinData = newGroup[localKey] as Record<string, unknown> | undefined;
|
|
325
|
+
|
|
326
|
+
if (joinData != null && !isEmptyObject(joinData)) {
|
|
327
|
+
if (!joinsConfig[joinKey].isSingle) {
|
|
328
|
+
// Transform to array
|
|
329
|
+
newGroup[localKey] = [joinData];
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
// Delete key if data is empty
|
|
333
|
+
delete newGroup[localKey];
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
groupMap.set(keyStr, newGroup);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Transform Map to array
|
|
342
|
+
const grouped = Array.from(groupMap.values());
|
|
343
|
+
|
|
344
|
+
// Recursively process sublevel of each JOIN
|
|
345
|
+
for (const group of grouped) {
|
|
346
|
+
for (const joinKey of childJoinKeys) {
|
|
347
|
+
const localKey = currentPath === "" ? joinKey : joinKey.slice(currentPath.length + 1);
|
|
348
|
+
const joinData = group[localKey];
|
|
349
|
+
|
|
350
|
+
if (Array.isArray(joinData) && joinData.length > 0) {
|
|
351
|
+
// Array case: process sublevel recursively
|
|
352
|
+
group[localKey] = groupRecordsRecursively(
|
|
353
|
+
joinData as Record<string, unknown>[],
|
|
354
|
+
allJoinKeys,
|
|
355
|
+
joinsConfig,
|
|
356
|
+
joinKey,
|
|
357
|
+
);
|
|
358
|
+
} else if (joinData != null && typeof joinData === "object" && !Array.isArray(joinData)) {
|
|
359
|
+
// Single object case (isSingle: true)
|
|
360
|
+
const processed = groupRecordsRecursively(
|
|
361
|
+
[joinData as Record<string, unknown>],
|
|
362
|
+
allJoinKeys,
|
|
363
|
+
joinsConfig,
|
|
364
|
+
joinKey,
|
|
365
|
+
);
|
|
366
|
+
if (processed.length > 0) {
|
|
367
|
+
group[localKey] = processed[0];
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Remove __hashSet__ internal property (temporary property for duplicate checking)
|
|
374
|
+
for (const group of grouped) {
|
|
375
|
+
for (const key of Object.keys(group)) {
|
|
376
|
+
if (key.startsWith("__hashSet__")) {
|
|
377
|
+
delete group[key];
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return grouped;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Extract group key from record excluding JOIN keys
|
|
387
|
+
*/
|
|
388
|
+
function extractGroupKey(
|
|
389
|
+
record: Record<string, unknown>,
|
|
390
|
+
joinKeys: string[],
|
|
391
|
+
): Record<string, unknown> {
|
|
392
|
+
const result: Record<string, unknown> = {};
|
|
393
|
+
for (const [key, value] of Object.entries(record)) {
|
|
394
|
+
// Only include non-JOIN keys
|
|
395
|
+
if (!joinKeys.some((jk) => jk === key || jk.startsWith(key + "."))) {
|
|
396
|
+
// Only use primitive values (not object/array) as group key
|
|
397
|
+
if (value == null || typeof value !== "object") {
|
|
398
|
+
result[key] = value;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return result;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Merge JOIN data to existing group
|
|
407
|
+
*/
|
|
408
|
+
function mergeJoinData(
|
|
409
|
+
existingGroup: Record<string, unknown>,
|
|
410
|
+
newRecord: Record<string, unknown>,
|
|
411
|
+
localKey: string,
|
|
412
|
+
isSingle: boolean,
|
|
413
|
+
): void {
|
|
414
|
+
const newJoinData = newRecord[localKey] as Record<string, unknown> | undefined;
|
|
415
|
+
|
|
416
|
+
if (newJoinData == null || isEmptyObject(newJoinData)) {
|
|
417
|
+
return; // No data to merge
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const existingJoinData = existingGroup[localKey];
|
|
421
|
+
|
|
422
|
+
if (isSingle) {
|
|
423
|
+
// isSingle: true - error if data exists and values differ
|
|
424
|
+
if (existingJoinData != null) {
|
|
425
|
+
if (!objEqual(existingJoinData as Record<string, unknown>, newJoinData)) {
|
|
426
|
+
throw new Error(`isSingle relationship '${localKey}' has multiple different results.`);
|
|
427
|
+
}
|
|
428
|
+
} else {
|
|
429
|
+
existingGroup[localKey] = newJoinData;
|
|
430
|
+
}
|
|
431
|
+
} else {
|
|
432
|
+
// isSingle: false → Add to array
|
|
433
|
+
const hashSetKey = `__hashSet__${localKey}`;
|
|
434
|
+
if (!Array.isArray(existingJoinData)) {
|
|
435
|
+
existingGroup[localKey] = [newJoinData];
|
|
436
|
+
// Initialize internal property for Set-based duplicate checking
|
|
437
|
+
existingGroup[hashSetKey] = new Set([serializeGroupKey(newJoinData)]);
|
|
438
|
+
} else {
|
|
439
|
+
// Set-based duplicate checking (O(1))
|
|
440
|
+
const hashSet = existingGroup[hashSetKey] as Set<string> | undefined;
|
|
441
|
+
const newHash = serializeGroupKey(newJoinData);
|
|
442
|
+
if (hashSet != null) {
|
|
443
|
+
if (!hashSet.has(newHash)) {
|
|
444
|
+
hashSet.add(newHash);
|
|
445
|
+
existingJoinData.push(newJoinData);
|
|
446
|
+
}
|
|
447
|
+
} else {
|
|
448
|
+
// Fallback without hashSet (legacy approach)
|
|
449
|
+
const isDuplicate = existingJoinData.some((item) =>
|
|
450
|
+
objEqual(item as Record<string, unknown>, newJoinData),
|
|
451
|
+
);
|
|
452
|
+
if (!isDuplicate) {
|
|
453
|
+
existingJoinData.push(newJoinData);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|