sonamu 0.2.47 → 0.2.49

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.
@@ -5,9 +5,22 @@ import { Sonamu } from "../api";
5
5
  import { BaseModel } from "../database/base-model";
6
6
  import { EntityManager } from "../entity/entity-manager";
7
7
  import {
8
+ EntityProp,
9
+ FixtureImportResult,
10
+ FixtureRecord,
11
+ FixtureSearchOptions,
12
+ ManyToManyRelationProp,
8
13
  isBelongsToOneRelationProp,
14
+ isHasManyRelationProp,
15
+ isManyToManyRelationProp,
9
16
  isOneToOneRelationProp,
17
+ isRelationProp,
18
+ isVirtualProp,
10
19
  } from "../types/types";
20
+ import { Entity } from "../entity/entity";
21
+ import inflection from "inflection";
22
+ import { SonamuDBConfig } from "../database/db";
23
+ import { readFileSync, writeFileSync } from "fs";
11
24
 
12
25
  export class FixtureManagerClass {
13
26
  private _tdb: Knex | null = null;
@@ -32,6 +45,15 @@ export class FixtureManagerClass {
32
45
  return this._fdb;
33
46
  }
34
47
 
48
+ private dependencyGraph: Map<
49
+ string,
50
+ {
51
+ fixtureId: string;
52
+ entityId: string;
53
+ dependencies: Set<string>;
54
+ }
55
+ > = new Map();
56
+
35
57
  init() {
36
58
  if (this._tdb !== null) {
37
59
  return;
@@ -136,6 +158,10 @@ export class FixtureManagerClass {
136
158
  await transaction(tableName).truncate();
137
159
 
138
160
  const rows = await frdb(tableName);
161
+ if (rows.length === 0) {
162
+ return;
163
+ }
164
+
139
165
  console.log(chalk.blue(tableName), rows.length);
140
166
  await transaction
141
167
  .insert(
@@ -257,5 +283,496 @@ export class FixtureManagerClass {
257
283
  }
258
284
  await BaseModel.destroy();
259
285
  }
286
+
287
+ async getFixtures(
288
+ sourceDBName: keyof SonamuDBConfig,
289
+ targetDBName: keyof SonamuDBConfig,
290
+ searchOptions: FixtureSearchOptions
291
+ ) {
292
+ const sourceDB = knex(Sonamu.dbConfig[sourceDBName]);
293
+ const targetDB = knex(Sonamu.dbConfig[targetDBName]);
294
+ const { entityId, field, value, searchType } = searchOptions;
295
+
296
+ const entity = EntityManager.get(entityId);
297
+ const column =
298
+ entity.props.find((prop) => prop.name === field)?.type === "relation"
299
+ ? `${field}_id`
300
+ : field;
301
+
302
+ let query = sourceDB(entity.table);
303
+ if (searchType === "equals") {
304
+ query = query.where(column, value);
305
+ } else if (searchType === "like") {
306
+ query = query.where(column, "like", `%${value}%`);
307
+ }
308
+
309
+ const rows = await query;
310
+ if (rows.length === 0) {
311
+ throw new Error("No records found");
312
+ }
313
+
314
+ const fixtures: FixtureRecord[] = [];
315
+ for (const row of rows) {
316
+ const initialRecordsLength = fixtures.length;
317
+ const newRecords = await this.createFixtureRecord(entity, row);
318
+ fixtures.push(...newRecords);
319
+ const currentFixtureRecord = fixtures.find(
320
+ (r) => r.fixtureId === `${entityId}#${row.id}`
321
+ );
322
+
323
+ if (currentFixtureRecord) {
324
+ // 현재 fixture로부터 생성된 fetchedRecords 설정
325
+ currentFixtureRecord.fetchedRecords = fixtures
326
+ .filter((r) => r.fixtureId !== currentFixtureRecord.fixtureId)
327
+ .slice(initialRecordsLength)
328
+ .map((r) => r.fixtureId);
329
+ }
330
+ }
331
+
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();
337
+ if (row) {
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;
358
+ }
359
+ }
360
+
361
+ return fixtures;
362
+ }
363
+
364
+ async createFixtureRecord(
365
+ entity: Entity,
366
+ row: any,
367
+ options?: {
368
+ singleRecord?: boolean;
369
+ _db?: Knex;
370
+ },
371
+ visitedEntities = new Set<string>()
372
+ ): Promise<FixtureRecord[]> {
373
+ const fixtureId = `${entity.id}#${row.id}`;
374
+ if (visitedEntities.has(fixtureId)) {
375
+ return [];
376
+ }
377
+ visitedEntities.add(fixtureId);
378
+
379
+ const records: FixtureRecord[] = [];
380
+ const record: FixtureRecord = {
381
+ fixtureId,
382
+ entityId: entity.id,
383
+ id: row.id,
384
+ columns: {},
385
+ fetchedRecords: [],
386
+ belongsRecords: [],
387
+ };
388
+
389
+ for (const prop of entity.props) {
390
+ if (isVirtualProp(prop)) {
391
+ continue;
392
+ }
393
+
394
+ record.columns[prop.name] = {
395
+ prop: prop,
396
+ value: row[prop.name],
397
+ };
398
+
399
+ const db = options?._db ?? BaseModel.getDB("w");
400
+ if (isManyToManyRelationProp(prop)) {
401
+ const relatedEntity = EntityManager.get(prop.with);
402
+ const throughTable = prop.joinTable;
403
+ const fromColumn = `${inflection.singularize(entity.table)}_id`;
404
+ const toColumn = `${inflection.singularize(relatedEntity.table)}_id`;
405
+
406
+ const relatedIds = await db(throughTable)
407
+ .where(fromColumn, row.id)
408
+ .pluck(toColumn);
409
+ record.columns[prop.name].value = relatedIds;
410
+ } else if (isHasManyRelationProp(prop)) {
411
+ const relatedEntity = EntityManager.get(prop.with);
412
+ const relatedIds = await db(relatedEntity.table)
413
+ .where(prop.joinColumn, row.id)
414
+ .pluck("id");
415
+ record.columns[prop.name].value = relatedIds;
416
+ } else if (isOneToOneRelationProp(prop) && !prop.hasJoinColumn) {
417
+ const relatedEntity = EntityManager.get(prop.with);
418
+ const relatedProp = relatedEntity.props.find(
419
+ (p) => p.type === "relation" && p.with === entity.id
420
+ );
421
+ if (relatedProp) {
422
+ const relatedRow = await db(relatedEntity.table)
423
+ .where("id", row.id)
424
+ .first();
425
+ record.columns[prop.name].value = relatedRow?.id;
426
+ }
427
+ } else if (isRelationProp(prop)) {
428
+ const relatedId = row[`${prop.name}_id`];
429
+ record.columns[prop.name].value = relatedId;
430
+ if (relatedId) {
431
+ record.belongsRecords.push(`${prop.with}#${relatedId}`);
432
+ }
433
+ if (!options?.singleRecord && relatedId) {
434
+ const relatedEntity = EntityManager.get(prop.with);
435
+ const relatedRow = await db(relatedEntity.table)
436
+ .where("id", relatedId)
437
+ .first();
438
+ if (relatedRow) {
439
+ const newRecords = await this.createFixtureRecord(
440
+ relatedEntity,
441
+ relatedRow,
442
+ options,
443
+ visitedEntities
444
+ );
445
+ records.push(...newRecords);
446
+ }
447
+ }
448
+ }
449
+ }
450
+
451
+ records.push(record);
452
+ return records;
453
+ }
454
+
455
+ async insertFixtures(
456
+ dbName: keyof SonamuDBConfig,
457
+ _fixtures: FixtureRecord[]
458
+ ) {
459
+ const fixtures = _.uniqBy(_fixtures, (f) => f.fixtureId);
460
+
461
+ this.buildDependencyGraph(fixtures);
462
+ const insertionOrder = this.getInsertionOrder();
463
+ const db = knex(Sonamu.dbConfig[dbName]);
464
+
465
+ await db.transaction(async (trx) => {
466
+ await trx.raw(`SET FOREIGN_KEY_CHECKS = 0`);
467
+
468
+ for (const fixtureId of insertionOrder) {
469
+ const fixture = fixtures.find((f) => f.fixtureId === fixtureId)!;
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
+ }
491
+ }
492
+
493
+ for (const fixtureId of insertionOrder) {
494
+ const fixture = fixtures.find((f) => f.fixtureId === fixtureId)!;
495
+ await this.handleManyToManyRelations(trx, fixture, fixtures);
496
+ }
497
+ await trx.raw(`SET FOREIGN_KEY_CHECKS = 1`);
498
+ });
499
+
500
+ const records: FixtureImportResult[] = [];
501
+
502
+ for await (const r of fixtures) {
503
+ const entity = EntityManager.get(r.entityId);
504
+ const record = await db(entity.table).where("id", r.id).first();
505
+ records.push({
506
+ entityId: r.entityId,
507
+ data: record,
508
+ });
509
+ }
510
+
511
+ return _.uniqBy(records, (r) => `${r.entityId}#${r.data.id}`);
512
+ }
513
+
514
+ private getInsertionOrder() {
515
+ const visited = new Set<string>();
516
+ const order: string[] = [];
517
+ const tempVisited = new Set<string>();
518
+
519
+ const visit = (fixtureId: string) => {
520
+ if (visited.has(fixtureId)) return;
521
+ if (tempVisited.has(fixtureId)) {
522
+ console.warn(`Circular dependency detected involving: ${fixtureId}`);
523
+ return;
524
+ }
525
+
526
+ tempVisited.add(fixtureId);
527
+
528
+ const node = this.dependencyGraph.get(fixtureId)!;
529
+ const entity = EntityManager.get(node.entityId);
530
+
531
+ for (const depId of node.dependencies) {
532
+ const depNode = this.dependencyGraph.get(depId)!;
533
+
534
+ // BelongsToOne 관계이면서 nullable이 아닌 경우 먼저 방문
535
+ const relationProp = entity.props.find(
536
+ (prop) =>
537
+ isRelationProp(prop) &&
538
+ (isBelongsToOneRelationProp(prop) ||
539
+ (isOneToOneRelationProp(prop) && prop.hasJoinColumn)) &&
540
+ prop.with === depNode.entityId
541
+ );
542
+ if (relationProp && !relationProp.nullable) {
543
+ visit(depId);
544
+ }
545
+ }
546
+
547
+ tempVisited.delete(fixtureId);
548
+ visited.add(fixtureId);
549
+ order.push(fixtureId);
550
+ };
551
+
552
+ for (const fixtureId of this.dependencyGraph.keys()) {
553
+ visit(fixtureId);
554
+ }
555
+
556
+ // circular dependency로 인해 방문되지 않은 fixtureId 추가
557
+ for (const fixtureId of this.dependencyGraph.keys()) {
558
+ if (!visited.has(fixtureId)) {
559
+ order.push(fixtureId);
560
+ }
561
+ }
562
+
563
+ return order;
564
+ }
565
+
566
+ private prepareInsertData(fixture: FixtureRecord) {
567
+ const insertData: any = {};
568
+ for (const [propName, column] of Object.entries(fixture.columns)) {
569
+ if (isVirtualProp(column.prop)) {
570
+ continue;
571
+ }
572
+
573
+ const prop = column.prop as EntityProp;
574
+ if (!isRelationProp(prop)) {
575
+ if (prop.type === "json") {
576
+ insertData[propName] = JSON.stringify(column.value);
577
+ } else {
578
+ insertData[propName] = column.value;
579
+ }
580
+ } else if (
581
+ isBelongsToOneRelationProp(prop) ||
582
+ (isOneToOneRelationProp(prop) && prop.hasJoinColumn)
583
+ ) {
584
+ insertData[`${propName}_id`] = column.value;
585
+ }
586
+ }
587
+ return insertData;
588
+ }
589
+
590
+ private buildDependencyGraph(fixtures: FixtureRecord[]) {
591
+ this.dependencyGraph.clear();
592
+
593
+ // 1. 노드 추가
594
+ for (const fixture of fixtures) {
595
+ this.dependencyGraph.set(fixture.fixtureId, {
596
+ fixtureId: fixture.fixtureId,
597
+ entityId: fixture.entityId,
598
+ dependencies: new Set(),
599
+ });
600
+ }
601
+
602
+ // 2. 의존성 추가
603
+ for (const fixture of fixtures) {
604
+ const node = this.dependencyGraph.get(fixture.fixtureId)!;
605
+
606
+ for (const [, column] of Object.entries(fixture.columns)) {
607
+ const prop = column.prop as EntityProp;
608
+
609
+ if (isRelationProp(prop)) {
610
+ if (
611
+ isBelongsToOneRelationProp(prop) ||
612
+ (isOneToOneRelationProp(prop) && prop.hasJoinColumn)
613
+ ) {
614
+ const relatedFixtureId = `${prop.with}#${column.value}`;
615
+ if (this.dependencyGraph.has(relatedFixtureId)) {
616
+ node.dependencies.add(relatedFixtureId);
617
+ }
618
+ } else if (isManyToManyRelationProp(prop)) {
619
+ // ManyToMany 관계의 경우 양방향 의존성 추가
620
+ const relatedIds = column.value as number[];
621
+ for (const relatedId of relatedIds) {
622
+ const relatedFixtureId = `${prop.with}#${relatedId}`;
623
+ if (this.dependencyGraph.has(relatedFixtureId)) {
624
+ node.dependencies.add(relatedFixtureId);
625
+ this.dependencyGraph
626
+ .get(relatedFixtureId)!
627
+ .dependencies.add(fixture.fixtureId);
628
+ }
629
+ }
630
+ }
631
+ }
632
+ }
633
+ }
634
+ }
635
+
636
+ private async insertFixture(db: Knex, fixture: FixtureRecord) {
637
+ const insertData = this.prepareInsertData(fixture);
638
+ const entity = EntityManager.get(fixture.entityId);
639
+
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
+
649
+ const found = await db(entity.table).where("id", fixture.id).first();
650
+ if (found && !fixture.override) {
651
+ return {
652
+ entityId: fixture.entityId,
653
+ id: found.id,
654
+ };
655
+ }
656
+
657
+ const q = db.insert(insertData).into(entity.table);
658
+ await q.onDuplicateUpdate.apply(q, Object.keys(insertData));
659
+ return {
660
+ entityId: fixture.entityId,
661
+ id: fixture.id,
662
+ };
663
+ } catch (err) {
664
+ console.log(err);
665
+ throw err;
666
+ }
667
+ }
668
+
669
+ private async handleManyToManyRelations(
670
+ db: Knex,
671
+ fixture: FixtureRecord,
672
+ fixtures: FixtureRecord[]
673
+ ) {
674
+ for (const [, column] of Object.entries(fixture.columns)) {
675
+ const prop = column.prop as EntityProp;
676
+ if (isManyToManyRelationProp(prop)) {
677
+ const joinTable = (prop as ManyToManyRelationProp).joinTable;
678
+ const relatedIds = column.value as number[];
679
+
680
+ for (const relatedId of relatedIds) {
681
+ if (
682
+ !fixtures.find((f) => f.fixtureId === `${prop.with}#${relatedId}`)
683
+ ) {
684
+ continue;
685
+ }
686
+
687
+ const entity = EntityManager.get(fixture.entityId);
688
+ const relatedEntity = EntityManager.get(prop.with);
689
+ if (!entity || !relatedEntity) {
690
+ throw new Error(
691
+ `Entity not found: ${fixture.entityId}, ${prop.with}`
692
+ );
693
+ }
694
+
695
+ const [found] = await db(joinTable)
696
+ .where({
697
+ [`${inflection.singularize(entity.table)}_id`]: fixture.id,
698
+ [`${inflection.singularize(relatedEntity.table)}_id`]: relatedId,
699
+ })
700
+ .limit(1);
701
+ if (found) {
702
+ continue;
703
+ }
704
+
705
+ const newIds = await db(joinTable).insert({
706
+ [`${inflection.singularize(entity.table)}_id`]: fixture.id,
707
+ [`${inflection.singularize(relatedEntity.table)}_id`]: relatedId,
708
+ });
709
+ console.log(
710
+ chalk.green(
711
+ `Inserted into ${joinTable}: ${entity.table}(${fixture.id}) - ${relatedEntity.table}(${relatedId}) ID: ${newIds}`
712
+ )
713
+ );
714
+ }
715
+ }
716
+ }
717
+ }
718
+
719
+ async addFixtureLoader(code: string) {
720
+ const path = Sonamu.apiRootPath + "/src/testing/fixture.ts";
721
+ let content = readFileSync(path).toString();
722
+
723
+ const fixtureLoaderStart = content.indexOf("const fixtureLoader = {");
724
+ const fixtureLoaderEnd = content.indexOf("};", fixtureLoaderStart);
725
+
726
+ if (fixtureLoaderStart !== -1 && fixtureLoaderEnd !== -1) {
727
+ const newContent =
728
+ content.slice(0, fixtureLoaderEnd) +
729
+ " " +
730
+ code +
731
+ "\n" +
732
+ content.slice(fixtureLoaderEnd);
733
+
734
+ writeFileSync(path, newContent);
735
+ } else {
736
+ throw new Error("Failed to find fixtureLoader in fixture.ts");
737
+ }
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
+ }
260
777
  }
261
778
  export const FixtureManager = new FixtureManagerClass();
@@ -719,3 +719,34 @@ export const PathAndCode = z.object({
719
719
  code: z.string(),
720
720
  });
721
721
  export type PathAndCode = z.infer<typeof PathAndCode>;
722
+
723
+ export type FixtureSearchOptions = {
724
+ entityId: string;
725
+ field: string;
726
+ value: string;
727
+ searchType: "equals" | "like";
728
+ };
729
+
730
+ export type FixtureRecord = {
731
+ fixtureId: string;
732
+ entityId: string;
733
+ id: number;
734
+ columns: {
735
+ [key: string]: {
736
+ prop: EntityProp;
737
+ value: any;
738
+ };
739
+ };
740
+ fetchedRecords: string[];
741
+ belongsRecords: string[];
742
+ target?: FixtureRecord; // Import 대상 DB 레코드(id가 같은)
743
+ unique?: FixtureRecord; // Import 대상 DB 레코드(unique key가 같은)
744
+ override?: boolean;
745
+ };
746
+
747
+ export type FixtureImportResult = {
748
+ entityId: string;
749
+ data: {
750
+ [key: string]: any;
751
+ };
752
+ };
@@ -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();