sonamu 0.2.48 → 0.2.50

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.
@@ -13,7 +13,7 @@ export type RowWithId<Id extends string> = {
13
13
  * Batch update rows in a table. Technically its a patch since it only updates the specified columns. Any omitted columns will not be affected
14
14
  * @param knex
15
15
  * @param tableName
16
- * @param id
16
+ * @param ids
17
17
  * @param rows
18
18
  * @param chunkSize
19
19
  * @param trx
@@ -21,7 +21,7 @@ export type RowWithId<Id extends string> = {
21
21
  export async function batchUpdate<Id extends string>(
22
22
  knex: Knex,
23
23
  tableName: string,
24
- id: Id,
24
+ ids: Id[],
25
25
  rows: RowWithId<Id>[],
26
26
  chunkSize = 50,
27
27
  trx: Knex.Transaction | null = null
@@ -35,7 +35,7 @@ export async function batchUpdate<Id extends string>(
35
35
  chunk: RowWithId<Id>[],
36
36
  transaction: Knex.Transaction
37
37
  ) => {
38
- const sql = generateBatchUpdateSQL(knex, tableName, chunk, id);
38
+ const sql = generateBatchUpdateSQL(knex, tableName, chunk, ids);
39
39
  return knex.raw(sql).transacting(transaction);
40
40
  };
41
41
 
@@ -73,20 +73,30 @@ function generateBatchUpdateSQL<Id extends string>(
73
73
  knex: Knex,
74
74
  tableName: string,
75
75
  data: Record<string, any>[],
76
- identifier: Id
76
+ identifiers: Id[]
77
77
  ) {
78
78
  const keySet = generateKeySetFromData(data);
79
79
  const bindings = [];
80
80
 
81
+ const invalidIdentifiers = identifiers.filter((id) => !keySet.has(id));
82
+ if (invalidIdentifiers.length > 0) {
83
+ throw new Error(
84
+ `Invalid identifiers: ${invalidIdentifiers.join(", ")}. Identifiers must exist in the data`
85
+ );
86
+ }
87
+
81
88
  const cases = [];
82
89
  for (const key of keySet) {
83
- if (key === identifier) continue;
90
+ if (identifiers.includes(key as Id)) continue;
84
91
 
85
92
  const rows = [];
86
93
  for (const row of data) {
87
94
  if (Object.hasOwnProperty.call(row, key)) {
88
- rows.push(`WHEN \`${identifier}\` = ? THEN ?`);
89
- bindings.push(row[identifier], row[key]);
95
+ const whereClause = identifiers
96
+ .map((id) => `\`${id}\` = ?`)
97
+ .join(" AND ");
98
+ rows.push(`WHEN (${whereClause}) THEN ?`);
99
+ bindings.push(...identifiers.map((i) => row[i]), row[key]);
90
100
  }
91
101
  }
92
102
 
@@ -94,13 +104,18 @@ function generateBatchUpdateSQL<Id extends string>(
94
104
  cases.push(`\`${key}\` = CASE ${whenThen} ELSE \`${key}\` END`);
95
105
  }
96
106
 
97
- const whereInIds = data.map((row) => row[identifier]);
98
- const whereInPlaceholders = whereInIds.map(() => "?").join(", ");
107
+ const whereInClauses = identifiers
108
+ .map((col) => `${col} IN (${data.map(() => "?").join(", ")})`)
109
+ .join(" AND ");
110
+
111
+ const whereInBindings = identifiers.flatMap((col) =>
112
+ data.map((row) => row[col])
113
+ );
114
+
99
115
  const sql = knex.raw(
100
- `UPDATE \`${tableName}\` SET ${cases.join(
101
- ", "
102
- )} WHERE ${identifier} IN (${whereInPlaceholders})`,
103
- [...bindings, ...whereInIds]
116
+ `UPDATE \`${tableName}\` SET ${cases.join(", ")} WHERE ${whereInClauses}`,
117
+ [...bindings, ...whereInBindings]
104
118
  );
105
- return sql.toString();
119
+
120
+ return sql.toQuery();
106
121
  }
@@ -144,17 +144,26 @@ export class UpsertBuilder {
144
144
  };
145
145
  }
146
146
 
147
- async upsert(wdb: Knex, tableName: string): Promise<number[]> {
148
- return this.upsertOrInsert(wdb, tableName, "upsert");
147
+ async upsert(
148
+ wdb: Knex,
149
+ tableName: string,
150
+ chunkSize?: number
151
+ ): Promise<number[]> {
152
+ return this.upsertOrInsert(wdb, tableName, "upsert", chunkSize);
149
153
  }
150
- async insertOnly(wdb: Knex, tableName: string): Promise<number[]> {
151
- return this.upsertOrInsert(wdb, tableName, "insert");
154
+ async insertOnly(
155
+ wdb: Knex,
156
+ tableName: string,
157
+ chunkSize?: number
158
+ ): Promise<number[]> {
159
+ return this.upsertOrInsert(wdb, tableName, "insert", chunkSize);
152
160
  }
153
161
 
154
162
  async upsertOrInsert(
155
163
  wdb: Knex,
156
164
  tableName: string,
157
- mode: "upsert" | "insert"
165
+ mode: "upsert" | "insert",
166
+ chunkSize?: number
158
167
  ): Promise<number[]> {
159
168
  if (this.hasTable(tableName) === false) {
160
169
  return [];
@@ -177,22 +186,6 @@ export class UpsertBuilder {
177
186
  throw new Error(`${tableName} 해결되지 않은 참조가 있습니다.`);
178
187
  }
179
188
 
180
- // 내부 참조 있는 경우 필터하여 분리
181
- const groups = _.groupBy(table.rows, (row) =>
182
- Object.entries(row).some(([, value]) => isRefField(value))
183
- ? "selfRef"
184
- : "normal"
185
- );
186
- const targetRows = groups.normal;
187
-
188
- // Insert On Duplicate Update
189
- const q = wdb.insert(targetRows).into(tableName);
190
- if (mode === "insert") {
191
- await q;
192
- } else if (mode === "upsert") {
193
- await q.onDuplicateUpdate.apply(q, Object.keys(targetRows[0]));
194
- }
195
-
196
189
  // 전체 테이블 순회하여 현재 테이블 참조하는 모든 테이블 추출
197
190
  const { references, refTables } = Array.from(this.tables).reduce(
198
191
  (r, [, table]) => {
@@ -211,19 +204,39 @@ export class UpsertBuilder {
211
204
  refTables: [] as TableData[],
212
205
  }
213
206
  );
214
-
215
207
  const extractFields = _.uniq(references).map(
216
208
  (reference) => reference.split(".")[1]
217
209
  );
218
210
 
219
- // UUID 기준으로 id 추출
220
- const uuids = table.rows.map((row) => row.uuid);
221
- const upsertedRows = await wdb(tableName)
222
- .select(_.uniq(["uuid", "id", ...extractFields]))
223
- .whereIn("uuid", uuids);
224
- const uuidMap = new Map<string, any>(
225
- upsertedRows.map((row: any) => [row.uuid, row])
211
+ // 내부 참조 있는 경우 필터하여 분리
212
+ const groups = _.groupBy(table.rows, (row) =>
213
+ Object.entries(row).some(([, value]) => isRefField(value))
214
+ ? "selfRef"
215
+ : "normal"
226
216
  );
217
+ const normalRows = groups.normal ?? [];
218
+ const selfRefRows = groups.selfRef ?? [];
219
+
220
+ const chunks = chunkSize ? _.chunk(normalRows, chunkSize) : [normalRows];
221
+ const uuidMap = new Map<string, any>();
222
+
223
+ for (const chunk of chunks) {
224
+ const q = wdb.insert(chunk).into(tableName);
225
+ if (mode === "insert") {
226
+ await q;
227
+ } else if (mode === "upsert") {
228
+ await q.onDuplicateUpdate.apply(q, Object.keys(normalRows[0]));
229
+ }
230
+
231
+ // upsert된 row들을 다시 조회하여 uuidMap에 저장
232
+ const uuids = chunk.map((row) => row.uuid);
233
+ const upsertedRows = await wdb(tableName)
234
+ .select(_.uniq(["uuid", "id", ...extractFields]))
235
+ .whereIn("uuid", uuids);
236
+ upsertedRows.forEach((row: any) => {
237
+ uuidMap.set(row.uuid, row);
238
+ });
239
+ }
227
240
 
228
241
  // 해당 테이블 참조를 실제 밸류로 변경
229
242
  refTables.map((table) => {
@@ -245,14 +258,17 @@ export class UpsertBuilder {
245
258
  });
246
259
  });
247
260
 
248
- const ids = Array.from(uuidMap.values()).map((val) => val.id);
261
+ const allIds = Array.from(uuidMap.values()).map((row) => row.id);
249
262
 
250
- if (groups.selfRef) {
251
- const selfRefIds = await this.upsert(wdb, tableName);
252
- return [...ids, ...selfRefIds];
263
+ // 자기 참조가 있는 경우 재귀적으로 upsert
264
+ if (selfRefRows.length > 0) {
265
+ // 처리된 데이터를 제외하고 다시 upsert
266
+ table.rows = selfRefRows;
267
+ const selfRefIds = await this.upsert(wdb, tableName, chunkSize);
268
+ allIds.push(...selfRefIds);
253
269
  }
254
270
 
255
- return ids;
271
+ return allIds;
256
272
  }
257
273
 
258
274
  async updateBatch(
@@ -260,7 +276,7 @@ export class UpsertBuilder {
260
276
  tableName: string,
261
277
  options?: {
262
278
  chunkSize?: number;
263
- where?: string;
279
+ where?: string | string[];
264
280
  }
265
281
  ): Promise<void> {
266
282
  options = _.defaults(options, {
@@ -276,16 +292,14 @@ export class UpsertBuilder {
276
292
  return;
277
293
  }
278
294
 
295
+ const whereColumns = Array.isArray(options.where)
296
+ ? options.where
297
+ : [options.where ?? "id"];
279
298
  const rows = table.rows.map((_row) => {
280
299
  const { uuid, ...row } = _row;
281
300
  return row as RowWithId<string>;
282
301
  });
283
- await batchUpdate(
284
- wdb,
285
- tableName,
286
- options.where ?? "id",
287
- rows,
288
- options.chunkSize
289
- );
302
+
303
+ await batchUpdate(wdb, tableName, whereColumns, rows, options.chunkSize);
290
304
  }
291
305
  }
@@ -30,6 +30,11 @@ export class Template__service extends Template {
30
30
  // 서비스 TypeSource
31
31
  const { lines, importKeys } = this.getTypeSource(apis);
32
32
 
33
+ // AxiosProgressEvent 있는지 확인
34
+ const hasAxiosProgressEvent = apis.find((api) =>
35
+ (api.options.clients ?? []).includes("axios-multipart")
36
+ );
37
+
33
38
  return {
34
39
  ...this.getTargetAndPath(names),
35
40
  body: lines.join("\n"),
@@ -41,6 +46,9 @@ export class Template__service extends Template {
41
46
  `import qs from "qs";`,
42
47
  `import useSWR, { SWRResponse } from "swr";`,
43
48
  `import { fetch, ListResult, SWRError, SwrOptions, handleConditional, swrPostFetcher } from '../sonamu.shared';`,
49
+ ...(hasAxiosProgressEvent
50
+ ? [`import { AxiosProgressEvent } from 'axios';`]
51
+ : []),
44
52
  ],
45
53
  };
46
54
  }
@@ -210,7 +218,7 @@ export async function ${methodNameAxios}${typeParamsDef}(${paramsDef}): Promise<
210
218
  export async function ${api.methodName}${typeParamsDef}(
211
219
  ${paramsDef}${paramsDefComma}
212
220
  file: File,
213
- onUploadProgress?: (pe:ProgressEvent) => void
221
+ onUploadProgress?: (pe:AxiosProgressEvent) => void
214
222
  ): Promise<${returnTypeDef}> {
215
223
  const formData = new FormData();
216
224
  ${formDataDef}
@@ -158,6 +158,10 @@ export class FixtureManagerClass {
158
158
  await transaction(tableName).truncate();
159
159
 
160
160
  const rows = await frdb(tableName);
161
+ if (rows.length === 0) {
162
+ return;
163
+ }
164
+
161
165
  console.log(chalk.blue(tableName), rows.length);
162
166
  await transaction
163
167
  .insert(
@@ -307,58 +311,72 @@ export class FixtureManagerClass {
307
311
  throw new Error("No records found");
308
312
  }
309
313
 
310
- const visitedEntities = new Set<string>();
311
- const records: FixtureRecord[] = [];
314
+ const fixtures: FixtureRecord[] = [];
312
315
  for (const row of rows) {
313
- const initialRecordsLength = records.length;
314
- await this.createFixtureRecord(entity, row, visitedEntities, records);
315
- const currentFixtureRecord = records.find(
316
+ const initialRecordsLength = fixtures.length;
317
+ const newRecords = await this.createFixtureRecord(entity, row);
318
+ fixtures.push(...newRecords);
319
+ const currentFixtureRecord = fixtures.find(
316
320
  (r) => r.fixtureId === `${entityId}#${row.id}`
317
321
  );
318
322
 
319
323
  if (currentFixtureRecord) {
320
324
  // 현재 fixture로부터 생성된 fetchedRecords 설정
321
- currentFixtureRecord.fetchedRecords = records
325
+ currentFixtureRecord.fetchedRecords = fixtures
322
326
  .filter((r) => r.fixtureId !== currentFixtureRecord.fixtureId)
323
327
  .slice(initialRecordsLength)
324
328
  .map((r) => r.fixtureId);
325
329
  }
326
330
  }
327
331
 
328
- for await (const record of records) {
329
- const entity = EntityManager.get(record.entityId);
330
- const rows: FixtureRecord[] = [];
331
- const row = await targetDB(entity.table).where("id", record.id).first();
332
+ for await (const fixture of fixtures) {
333
+ const entity = EntityManager.get(fixture.entityId);
334
+
335
+ // targetDB에 해당 레코드가 존재하는지 확인
336
+ const row = await targetDB(entity.table).where("id", fixture.id).first();
332
337
  if (row) {
333
- await this.createFixtureRecord(
334
- entity,
335
- row,
336
- new Set(),
337
- rows,
338
- true,
339
- targetDB
340
- );
341
- record.target = rows[0];
338
+ const [record] = await this.createFixtureRecord(entity, row, {
339
+ singleRecord: true,
340
+ _db: targetDB,
341
+ });
342
+ fixture.target = record;
343
+ continue;
344
+ }
345
+
346
+ // targetDB에 해당 레코드가 존재하지 않는 경우, unique 제약을 위반하는지 확인
347
+ const uniqueRow = await this.checkUniqueViolation(
348
+ targetDB,
349
+ entity,
350
+ fixture
351
+ );
352
+ if (uniqueRow) {
353
+ const [record] = await this.createFixtureRecord(entity, uniqueRow, {
354
+ singleRecord: true,
355
+ _db: targetDB,
356
+ });
357
+ fixture.unique = record;
342
358
  }
343
359
  }
344
360
 
345
- return records;
361
+ return fixtures;
346
362
  }
347
363
 
348
364
  async createFixtureRecord(
349
365
  entity: Entity,
350
366
  row: any,
351
- visitedEntities: Set<string>,
352
- records: FixtureRecord[],
353
- singleRecord = false,
354
- _db?: Knex
355
- ) {
367
+ options?: {
368
+ singleRecord?: boolean;
369
+ _db?: Knex;
370
+ },
371
+ visitedEntities = new Set<string>()
372
+ ): Promise<FixtureRecord[]> {
356
373
  const fixtureId = `${entity.id}#${row.id}`;
357
374
  if (visitedEntities.has(fixtureId)) {
358
- return;
375
+ return [];
359
376
  }
360
377
  visitedEntities.add(fixtureId);
361
378
 
379
+ const records: FixtureRecord[] = [];
362
380
  const record: FixtureRecord = {
363
381
  fixtureId,
364
382
  entityId: entity.id,
@@ -378,7 +396,7 @@ export class FixtureManagerClass {
378
396
  value: row[prop.name],
379
397
  };
380
398
 
381
- const db = _db ?? BaseModel.getDB("w");
399
+ const db = options?._db ?? BaseModel.getDB("w");
382
400
  if (isManyToManyRelationProp(prop)) {
383
401
  const relatedEntity = EntityManager.get(prop.with);
384
402
  const throughTable = prop.joinTable;
@@ -412,32 +430,34 @@ export class FixtureManagerClass {
412
430
  if (relatedId) {
413
431
  record.belongsRecords.push(`${prop.with}#${relatedId}`);
414
432
  }
415
- if (!singleRecord && relatedId) {
433
+ if (!options?.singleRecord && relatedId) {
416
434
  const relatedEntity = EntityManager.get(prop.with);
417
435
  const relatedRow = await db(relatedEntity.table)
418
436
  .where("id", relatedId)
419
437
  .first();
420
438
  if (relatedRow) {
421
- await this.createFixtureRecord(
439
+ const newRecords = await this.createFixtureRecord(
422
440
  relatedEntity,
423
441
  relatedRow,
424
- visitedEntities,
425
- records,
426
- singleRecord,
427
- _db
442
+ options,
443
+ visitedEntities
428
444
  );
445
+ records.push(...newRecords);
429
446
  }
430
447
  }
431
448
  }
432
449
  }
433
450
 
434
451
  records.push(record);
452
+ return records;
435
453
  }
436
454
 
437
455
  async insertFixtures(
438
456
  dbName: keyof SonamuDBConfig,
439
- fixtures: FixtureRecord[]
457
+ _fixtures: FixtureRecord[]
440
458
  ) {
459
+ const fixtures = _.uniqBy(_fixtures, (f) => f.fixtureId);
460
+
441
461
  this.buildDependencyGraph(fixtures);
442
462
  const insertionOrder = this.getInsertionOrder();
443
463
  const db = knex(Sonamu.dbConfig[dbName]);
@@ -447,7 +467,27 @@ export class FixtureManagerClass {
447
467
 
448
468
  for (const fixtureId of insertionOrder) {
449
469
  const fixture = fixtures.find((f) => f.fixtureId === fixtureId)!;
450
- await this.insertFixture(trx, fixture);
470
+ const result = await this.insertFixture(trx, fixture);
471
+ if (result.id !== fixture.id) {
472
+ // ID가 변경된 경우, 다른 fixture에서 참조하는 경우가 찾아서 수정
473
+ console.log(
474
+ chalk.yellow(
475
+ `Unique constraint violation: ${fixture.entityId}#${fixture.id} -> ${fixture.entityId}#${result.id}`
476
+ )
477
+ );
478
+ fixtures.forEach((f) => {
479
+ Object.values(f.columns).forEach((column) => {
480
+ if (
481
+ column.prop.type === "relation" &&
482
+ column.prop.with === result.entityId &&
483
+ column.value === fixture.id
484
+ ) {
485
+ column.value = result.id;
486
+ }
487
+ });
488
+ });
489
+ fixture.id = result.id;
490
+ }
451
491
  }
452
492
 
453
493
  for (const fixtureId of insertionOrder) {
@@ -468,7 +508,7 @@ export class FixtureManagerClass {
468
508
  });
469
509
  }
470
510
 
471
- return records;
511
+ return _.uniqBy(records, (r) => `${r.entityId}#${r.data.id}`);
472
512
  }
473
513
 
474
514
  private getInsertionOrder() {
@@ -598,6 +638,14 @@ export class FixtureManagerClass {
598
638
  const entity = EntityManager.get(fixture.entityId);
599
639
 
600
640
  try {
641
+ const uniqueFound = await this.checkUniqueViolation(db, entity, fixture);
642
+ if (uniqueFound) {
643
+ return {
644
+ entityId: fixture.entityId,
645
+ id: uniqueFound.id,
646
+ };
647
+ }
648
+
601
649
  const found = await db(entity.table).where("id", fixture.id).first();
602
650
  if (found && !fixture.override) {
603
651
  return {
@@ -688,5 +736,43 @@ export class FixtureManagerClass {
688
736
  throw new Error("Failed to find fixtureLoader in fixture.ts");
689
737
  }
690
738
  }
739
+
740
+ // 해당 픽스쳐의 값으로 유니크 제약에 위배되는 레코드가 있는지 확인
741
+ private async checkUniqueViolation(
742
+ db: Knex,
743
+ entity: Entity,
744
+ fixture: FixtureRecord
745
+ ) {
746
+ const uniqueIndexes = entity.indexes.filter((i) => i.type === "unique");
747
+ if (uniqueIndexes.length === 0) {
748
+ return null;
749
+ }
750
+
751
+ let uniqueQuery = db(entity.table);
752
+ for (const index of uniqueIndexes) {
753
+ // 컬럼 중 하나라도 null이면 유니크 제약을 위반하지 않기 때문에 해당 인덱스는 무시
754
+ if (
755
+ index.columns.some(
756
+ (column) => fixture.columns[column.split("_id")[0]].value === null
757
+ )
758
+ ) {
759
+ continue;
760
+ }
761
+
762
+ uniqueQuery = uniqueQuery.orWhere((qb) => {
763
+ for (const column of index.columns) {
764
+ const field = column.split("_id")[0];
765
+
766
+ if (Array.isArray(fixture.columns[field].value)) {
767
+ qb.whereIn(column, fixture.columns[field].value);
768
+ } else {
769
+ qb.andWhere(column, fixture.columns[field].value);
770
+ }
771
+ }
772
+ });
773
+ }
774
+ const [uniqueFound] = await uniqueQuery;
775
+ return uniqueFound;
776
+ }
691
777
  }
692
778
  export const FixtureManager = new FixtureManagerClass();
@@ -738,8 +738,9 @@ export type FixtureRecord = {
738
738
  };
739
739
  };
740
740
  fetchedRecords: string[];
741
- belongsRecords: string[]; //
742
- target?: FixtureRecord; // Import 대상 DB 레코드
741
+ belongsRecords: string[];
742
+ target?: FixtureRecord; // Import 대상 DB 레코드(id가 같은)
743
+ unique?: FixtureRecord; // Import 대상 DB 레코드(unique key가 같은)
743
744
  override?: boolean;
744
745
  };
745
746
 
@@ -17,19 +17,21 @@ export async function importMultiple(
17
17
  filePaths: string[],
18
18
  doRefresh: boolean = false
19
19
  ): Promise<{ filePath: string; imported: any }[]> {
20
- return Promise.all(
21
- filePaths.map(async (filePath) => {
22
- const importPath = "./" + path.relative(__dirname, filePath);
23
- if (doRefresh) {
24
- delete require.cache[require.resolve(importPath)];
25
- }
26
- const imported = await import(importPath);
27
- return {
28
- filePath,
29
- imported,
30
- };
31
- })
32
- );
20
+ const results: { filePath: string; imported: any }[] = [];
21
+
22
+ for (const filePath of filePaths) {
23
+ const importPath = "./" + path.relative(__dirname, filePath);
24
+ if (doRefresh) {
25
+ delete require.cache[require.resolve(importPath)];
26
+ }
27
+ const imported = await import(importPath);
28
+ results.push({
29
+ filePath,
30
+ imported,
31
+ });
32
+ }
33
+
34
+ return results;
33
35
  }
34
36
  export async function findAppRootPath() {
35
37
  const apiRootPath = await findApiRootPath();
package/tsup.config.js CHANGED
@@ -4,10 +4,7 @@ import { defineConfig } from "tsup";
4
4
  export default defineConfig({
5
5
  entry: ["src/index.ts", "src/bin/cli.ts", "src/bin/cli-wrapper.ts"],
6
6
  dts: true,
7
- format: [
8
- // "esm",
9
- "cjs",
10
- ],
7
+ format: ["esm", "cjs"],
11
8
  target: "es2020",
12
9
  clean: true,
13
10
  sourcemap: true,