@simplysm/orm-common 13.0.69 → 13.0.71

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 (204) hide show
  1. package/README.md +54 -1447
  2. package/dist/create-db-context.d.ts +10 -10
  3. package/dist/create-db-context.js +9 -9
  4. package/dist/create-db-context.js.map +1 -1
  5. package/dist/ddl/column-ddl.d.ts +4 -4
  6. package/dist/ddl/initialize.d.ts +17 -17
  7. package/dist/ddl/initialize.js +2 -2
  8. package/dist/ddl/initialize.js.map +1 -1
  9. package/dist/ddl/relation-ddl.d.ts +6 -6
  10. package/dist/ddl/schema-ddl.d.ts +4 -4
  11. package/dist/ddl/table-ddl.d.ts +24 -24
  12. package/dist/ddl/table-ddl.js +4 -4
  13. package/dist/ddl/table-ddl.js.map +1 -1
  14. package/dist/errors/db-transaction-error.d.ts +15 -15
  15. package/dist/errors/db-transaction-error.d.ts.map +1 -1
  16. package/dist/exec/executable.d.ts +23 -23
  17. package/dist/exec/executable.js +3 -3
  18. package/dist/exec/executable.js.map +1 -1
  19. package/dist/exec/queryable.d.ts +160 -160
  20. package/dist/exec/queryable.js +119 -119
  21. package/dist/exec/queryable.js.map +1 -1
  22. package/dist/exec/search-parser.d.ts +37 -37
  23. package/dist/exec/search-parser.d.ts.map +1 -1
  24. package/dist/expr/expr-unit.d.ts +4 -4
  25. package/dist/expr/expr.d.ts +257 -257
  26. package/dist/expr/expr.js +265 -265
  27. package/dist/expr/expr.js.map +1 -1
  28. package/dist/query-builder/base/expr-renderer-base.d.ts +9 -9
  29. package/dist/query-builder/base/expr-renderer-base.js +2 -2
  30. package/dist/query-builder/base/expr-renderer-base.js.map +1 -1
  31. package/dist/query-builder/base/query-builder-base.d.ts +26 -26
  32. package/dist/query-builder/base/query-builder-base.d.ts.map +1 -1
  33. package/dist/query-builder/base/query-builder-base.js +22 -22
  34. package/dist/query-builder/base/query-builder-base.js.map +1 -1
  35. package/dist/query-builder/mssql/mssql-expr-renderer.d.ts +4 -4
  36. package/dist/query-builder/mssql/mssql-expr-renderer.d.ts.map +1 -1
  37. package/dist/query-builder/mssql/mssql-expr-renderer.js +18 -18
  38. package/dist/query-builder/mssql/mssql-expr-renderer.js.map +1 -1
  39. package/dist/query-builder/mssql/mssql-query-builder.d.ts +2 -2
  40. package/dist/query-builder/mssql/mssql-query-builder.d.ts.map +1 -1
  41. package/dist/query-builder/mssql/mssql-query-builder.js +11 -11
  42. package/dist/query-builder/mssql/mssql-query-builder.js.map +1 -1
  43. package/dist/query-builder/mysql/mysql-expr-renderer.d.ts +4 -4
  44. package/dist/query-builder/mysql/mysql-expr-renderer.d.ts.map +1 -1
  45. package/dist/query-builder/mysql/mysql-expr-renderer.js +17 -17
  46. package/dist/query-builder/mysql/mysql-expr-renderer.js.map +1 -1
  47. package/dist/query-builder/mysql/mysql-query-builder.d.ts +8 -8
  48. package/dist/query-builder/mysql/mysql-query-builder.d.ts.map +1 -1
  49. package/dist/query-builder/mysql/mysql-query-builder.js +5 -5
  50. package/dist/query-builder/mysql/mysql-query-builder.js.map +1 -1
  51. package/dist/query-builder/postgresql/postgresql-expr-renderer.d.ts +4 -4
  52. package/dist/query-builder/postgresql/postgresql-expr-renderer.d.ts.map +1 -1
  53. package/dist/query-builder/postgresql/postgresql-expr-renderer.js +17 -17
  54. package/dist/query-builder/postgresql/postgresql-expr-renderer.js.map +1 -1
  55. package/dist/query-builder/postgresql/postgresql-query-builder.d.ts +5 -5
  56. package/dist/query-builder/postgresql/postgresql-query-builder.d.ts.map +1 -1
  57. package/dist/query-builder/postgresql/postgresql-query-builder.js +8 -8
  58. package/dist/query-builder/postgresql/postgresql-query-builder.js.map +1 -1
  59. package/dist/query-builder/query-builder.d.ts +1 -1
  60. package/dist/schema/factory/column-builder.d.ts +79 -79
  61. package/dist/schema/factory/column-builder.js +42 -42
  62. package/dist/schema/factory/index-builder.d.ts +39 -39
  63. package/dist/schema/factory/index-builder.js +26 -26
  64. package/dist/schema/factory/relation-builder.d.ts +99 -99
  65. package/dist/schema/factory/relation-builder.d.ts.map +1 -1
  66. package/dist/schema/factory/relation-builder.js +38 -38
  67. package/dist/schema/procedure-builder.d.ts +49 -49
  68. package/dist/schema/procedure-builder.d.ts.map +1 -1
  69. package/dist/schema/procedure-builder.js +33 -33
  70. package/dist/schema/table-builder.d.ts +59 -59
  71. package/dist/schema/table-builder.d.ts.map +1 -1
  72. package/dist/schema/table-builder.js +43 -43
  73. package/dist/schema/view-builder.d.ts +49 -49
  74. package/dist/schema/view-builder.d.ts.map +1 -1
  75. package/dist/schema/view-builder.js +32 -32
  76. package/dist/types/column.d.ts +22 -22
  77. package/dist/types/column.js +1 -1
  78. package/dist/types/column.js.map +1 -1
  79. package/dist/types/db.d.ts +40 -40
  80. package/dist/types/expr.d.ts +59 -59
  81. package/dist/types/expr.d.ts.map +1 -1
  82. package/dist/types/query-def.d.ts +44 -44
  83. package/dist/types/query-def.d.ts.map +1 -1
  84. package/dist/utils/result-parser.d.ts +11 -11
  85. package/dist/utils/result-parser.js +3 -3
  86. package/dist/utils/result-parser.js.map +1 -1
  87. package/package.json +5 -5
  88. package/src/create-db-context.ts +20 -20
  89. package/src/ddl/column-ddl.ts +4 -4
  90. package/src/ddl/initialize.ts +259 -259
  91. package/src/ddl/relation-ddl.ts +89 -89
  92. package/src/ddl/schema-ddl.ts +4 -4
  93. package/src/ddl/table-ddl.ts +189 -189
  94. package/src/errors/db-transaction-error.ts +13 -13
  95. package/src/exec/executable.ts +25 -25
  96. package/src/exec/queryable.ts +2033 -2033
  97. package/src/exec/search-parser.ts +57 -57
  98. package/src/expr/expr-unit.ts +4 -4
  99. package/src/expr/expr.ts +2140 -2140
  100. package/src/query-builder/base/expr-renderer-base.ts +237 -237
  101. package/src/query-builder/base/query-builder-base.ts +213 -213
  102. package/src/query-builder/mssql/mssql-expr-renderer.ts +607 -607
  103. package/src/query-builder/mssql/mssql-query-builder.ts +650 -650
  104. package/src/query-builder/mysql/mysql-expr-renderer.ts +613 -613
  105. package/src/query-builder/mysql/mysql-query-builder.ts +759 -759
  106. package/src/query-builder/postgresql/postgresql-expr-renderer.ts +611 -611
  107. package/src/query-builder/postgresql/postgresql-query-builder.ts +686 -686
  108. package/src/query-builder/query-builder.ts +19 -19
  109. package/src/schema/factory/column-builder.ts +423 -423
  110. package/src/schema/factory/index-builder.ts +164 -164
  111. package/src/schema/factory/relation-builder.ts +453 -453
  112. package/src/schema/procedure-builder.ts +232 -232
  113. package/src/schema/table-builder.ts +319 -319
  114. package/src/schema/view-builder.ts +221 -221
  115. package/src/types/column.ts +188 -188
  116. package/src/types/db.ts +208 -208
  117. package/src/types/expr.ts +697 -697
  118. package/src/types/query-def.ts +513 -513
  119. package/src/utils/result-parser.ts +458 -458
  120. package/tests/db-context/create-db-context.spec.ts +224 -0
  121. package/tests/db-context/define-db-context.spec.ts +68 -0
  122. package/tests/ddl/basic.expected.ts +341 -0
  123. package/tests/ddl/basic.spec.ts +714 -0
  124. package/tests/ddl/column-builder.expected.ts +310 -0
  125. package/tests/ddl/column-builder.spec.ts +637 -0
  126. package/tests/ddl/index-builder.expected.ts +38 -0
  127. package/tests/ddl/index-builder.spec.ts +202 -0
  128. package/tests/ddl/procedure-builder.expected.ts +52 -0
  129. package/tests/ddl/procedure-builder.spec.ts +234 -0
  130. package/tests/ddl/relation-builder.expected.ts +36 -0
  131. package/tests/ddl/relation-builder.spec.ts +372 -0
  132. package/tests/ddl/table-builder.expected.ts +113 -0
  133. package/tests/ddl/table-builder.spec.ts +433 -0
  134. package/tests/ddl/view-builder.expected.ts +38 -0
  135. package/tests/ddl/view-builder.spec.ts +176 -0
  136. package/tests/dml/delete.expected.ts +96 -0
  137. package/tests/dml/delete.spec.ts +160 -0
  138. package/tests/dml/insert.expected.ts +192 -0
  139. package/tests/dml/insert.spec.ts +288 -0
  140. package/tests/dml/update.expected.ts +176 -0
  141. package/tests/dml/update.spec.ts +318 -0
  142. package/tests/dml/upsert.expected.ts +215 -0
  143. package/tests/dml/upsert.spec.ts +242 -0
  144. package/tests/errors/queryable-errors.spec.ts +177 -0
  145. package/tests/escape.spec.ts +100 -0
  146. package/tests/examples/pivot.expected.ts +211 -0
  147. package/tests/examples/pivot.spec.ts +533 -0
  148. package/tests/examples/sampling.expected.ts +69 -0
  149. package/tests/examples/sampling.spec.ts +105 -0
  150. package/tests/examples/unpivot.expected.ts +120 -0
  151. package/tests/examples/unpivot.spec.ts +226 -0
  152. package/tests/exec/search-parser.spec.ts +283 -0
  153. package/tests/executable/basic.expected.ts +18 -0
  154. package/tests/executable/basic.spec.ts +54 -0
  155. package/tests/expr/comparison.expected.ts +282 -0
  156. package/tests/expr/comparison.spec.ts +400 -0
  157. package/tests/expr/conditional.expected.ts +134 -0
  158. package/tests/expr/conditional.spec.ts +276 -0
  159. package/tests/expr/date.expected.ts +332 -0
  160. package/tests/expr/date.spec.ts +526 -0
  161. package/tests/expr/math.expected.ts +62 -0
  162. package/tests/expr/math.spec.ts +106 -0
  163. package/tests/expr/string.expected.ts +218 -0
  164. package/tests/expr/string.spec.ts +356 -0
  165. package/tests/expr/utility.expected.ts +147 -0
  166. package/tests/expr/utility.spec.ts +182 -0
  167. package/tests/select/basic.expected.ts +322 -0
  168. package/tests/select/basic.spec.ts +502 -0
  169. package/tests/select/filter.expected.ts +357 -0
  170. package/tests/select/filter.spec.ts +1068 -0
  171. package/tests/select/group.expected.ts +169 -0
  172. package/tests/select/group.spec.ts +244 -0
  173. package/tests/select/join.expected.ts +582 -0
  174. package/tests/select/join.spec.ts +805 -0
  175. package/tests/select/order.expected.ts +150 -0
  176. package/tests/select/order.spec.ts +189 -0
  177. package/tests/select/recursive-cte.expected.ts +244 -0
  178. package/tests/select/recursive-cte.spec.ts +514 -0
  179. package/tests/select/result-meta.spec.ts +270 -0
  180. package/tests/select/subquery.expected.ts +363 -0
  181. package/tests/select/subquery.spec.ts +537 -0
  182. package/tests/select/view.expected.ts +155 -0
  183. package/tests/select/view.spec.ts +235 -0
  184. package/tests/select/window.expected.ts +345 -0
  185. package/tests/select/window.spec.ts +618 -0
  186. package/tests/setup/MockExecutor.ts +18 -0
  187. package/tests/setup/TestDbContext.ts +59 -0
  188. package/tests/setup/models/Company.ts +13 -0
  189. package/tests/setup/models/Employee.ts +10 -0
  190. package/tests/setup/models/MonthlySales.ts +11 -0
  191. package/tests/setup/models/Post.ts +16 -0
  192. package/tests/setup/models/Sales.ts +10 -0
  193. package/tests/setup/models/User.ts +19 -0
  194. package/tests/setup/procedure/GetAllUsers.ts +9 -0
  195. package/tests/setup/procedure/GetUserById.ts +12 -0
  196. package/tests/setup/test-utils.ts +72 -0
  197. package/tests/setup/views/ActiveUsers.ts +8 -0
  198. package/tests/setup/views/UserSummary.ts +11 -0
  199. package/tests/types/nullable-queryable-record.spec.ts +145 -0
  200. package/tests/utils/result-parser-perf.spec.ts +210 -0
  201. package/tests/utils/result-parser.spec.ts +701 -0
  202. package/docs/expressions.md +0 -172
  203. package/docs/queries.md +0 -444
  204. 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 - 대상 타입 (ColumnPrimitiveStr)
16
- * @returns 파싱된
17
- * @throws 파싱 실패 Error
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(`number 파싱 실패: ${String(value)}`);
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(`Bytes 파싱 실패: ${typeof value}`);
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
- // 중첩 키: "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
- // 단순
100
- result[key] = parsedValue;
101
- }
102
- }
103
-
104
- return result;
105
- }
106
-
107
- /**
108
- * 객체가 비어있는지 확인 (모든 값이 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 간격: N개 처리마다 이벤트 루프 양보 */
119
- const YIELD_INTERVAL = 100;
120
-
121
- /** 이벤트 루프 양보: Node.js에서는 setImmediate, 브라우저에서는 setTimeout 폴백 */
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 쿼리 결과를 ResultMeta를 통해 TypeScript 객체로 변환
129
- *
130
- * @param rawResults - DB에서 받은 raw 결과 배열
131
- * @param meta - 타입 변환 JOIN 구조 정보 (필수)
132
- * @returns 타입 변환 중첩화된 결과 배열. 입력이 비거나 유효한 결과가 없으면 undefined
133
- * @throws 타입 파싱 실패 Error
134
- *
135
- * @remarks
136
- * - meta 필수: meta가 없으면 함수를 호출할 필요 없음 (입력 = 출력)
137
- * - async only: 대용량 처리 외부 인터럽트를 위해 동기 버전 미제공
138
- * - browser/node 모두 지원: setTimeout(resolve, 0)으로 yield
139
- * - 결과 처리: 입력 배열이 비거나, 파싱 모든 레코드가 객체인 경우 undefined 반환
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
- // JOIN이 없는 경우: 단순 타입 파싱만
174
- if (joinKeys.length === 0) {
175
- return parseSimpleRecords<TRecord>(rawResults, meta.columns);
176
- }
177
-
178
- // JOIN이 있는 경우: 그룹핑 + 중첩화
179
- return parseJoinedRecords<TRecord>(rawResults, meta);
180
- }
181
-
182
- /**
183
- * JOIN 없는 단순 레코드 파싱
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 처리
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
- // 배열은 undefined 반환
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
- * JOIN 있는 레코드 파싱 (재귀적 그룹핑)
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
- * 그룹 키를 문자열로 직렬화 (Map 키로 사용)
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
- * Map 기반 그룹핑으로 O(n) 복잡도 달성
263
- *
264
- * @param records - 그룹핑할 레코드 배열
265
- * @param allJoinKeys - 모든 JOIN (깊이 정렬됨)
266
- * @param joinsConfig - JOIN 설정
267
- * @param currentPath - 현재 경로 (예: "", "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
- // 현재 경로에 직접 해당하는 JOIN 키들 찾기
276
- // 예: currentPath="" → ["posts", "company"]
277
- // 예: currentPath="posts" → ["posts.comments"]
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
- // 이상 그룹핑할 JOIN이 없음
292
- return records;
293
- }
294
-
295
- // Map 기반 그룹핑 (O(n) 복잡도)
296
- const groupMap = new Map<string, Record<string, unknown>>();
297
-
298
- // 순서 캐싱 ( 번째 레코드에서 결정 재사용)
299
- let groupKeyOrder: string[] | undefined;
300
-
301
- for (const record of records) {
302
- // 그룹 추출 직렬화 (JOIN 키 제외)
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
- // 기존 그룹에 JOIN 데이터 병합
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
- // JOIN 키를 배열 또는 단일 객체로 초기화
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
- // 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
- // 배열인 경우: 하위 레벨 재귀 처리
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
- // 단일 객체인 경우 (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
- // __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
- * 레코드에서 JOIN 키를 제외한 그룹 추출
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
- // JOIN 키가 아닌 것만 포함
395
- if (!joinKeys.some((jk) => jk === key || jk.startsWith(key + "."))) {
396
- // 객체/배열이 아닌 primitive 값만 그룹 키로 사용
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 관계 '${localKey}' 여러 개의 다른 결과가 존재합니다.`);
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
- // Set 기반 해시 중복 체크를 위한 내부 속성 초기화
437
- existingGroup[hashSetKey] = new Set([serializeGroupKey(newJoinData)]);
438
- } else {
439
- // Set 기반 중복 체크 (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
- // hashSet이 없으면 폴백 (기존 방식)
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
+ }