sonamu 0.2.46 → 0.2.48

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;
@@ -257,5 +279,414 @@ export class FixtureManagerClass {
257
279
  }
258
280
  await BaseModel.destroy();
259
281
  }
282
+
283
+ async getFixtures(
284
+ sourceDBName: keyof SonamuDBConfig,
285
+ targetDBName: keyof SonamuDBConfig,
286
+ searchOptions: FixtureSearchOptions
287
+ ) {
288
+ const sourceDB = knex(Sonamu.dbConfig[sourceDBName]);
289
+ const targetDB = knex(Sonamu.dbConfig[targetDBName]);
290
+ const { entityId, field, value, searchType } = searchOptions;
291
+
292
+ const entity = EntityManager.get(entityId);
293
+ const column =
294
+ entity.props.find((prop) => prop.name === field)?.type === "relation"
295
+ ? `${field}_id`
296
+ : field;
297
+
298
+ let query = sourceDB(entity.table);
299
+ if (searchType === "equals") {
300
+ query = query.where(column, value);
301
+ } else if (searchType === "like") {
302
+ query = query.where(column, "like", `%${value}%`);
303
+ }
304
+
305
+ const rows = await query;
306
+ if (rows.length === 0) {
307
+ throw new Error("No records found");
308
+ }
309
+
310
+ const visitedEntities = new Set<string>();
311
+ const records: FixtureRecord[] = [];
312
+ for (const row of rows) {
313
+ const initialRecordsLength = records.length;
314
+ await this.createFixtureRecord(entity, row, visitedEntities, records);
315
+ const currentFixtureRecord = records.find(
316
+ (r) => r.fixtureId === `${entityId}#${row.id}`
317
+ );
318
+
319
+ if (currentFixtureRecord) {
320
+ // 현재 fixture로부터 생성된 fetchedRecords 설정
321
+ currentFixtureRecord.fetchedRecords = records
322
+ .filter((r) => r.fixtureId !== currentFixtureRecord.fixtureId)
323
+ .slice(initialRecordsLength)
324
+ .map((r) => r.fixtureId);
325
+ }
326
+ }
327
+
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
+ 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];
342
+ }
343
+ }
344
+
345
+ return records;
346
+ }
347
+
348
+ async createFixtureRecord(
349
+ entity: Entity,
350
+ row: any,
351
+ visitedEntities: Set<string>,
352
+ records: FixtureRecord[],
353
+ singleRecord = false,
354
+ _db?: Knex
355
+ ) {
356
+ const fixtureId = `${entity.id}#${row.id}`;
357
+ if (visitedEntities.has(fixtureId)) {
358
+ return;
359
+ }
360
+ visitedEntities.add(fixtureId);
361
+
362
+ const record: FixtureRecord = {
363
+ fixtureId,
364
+ entityId: entity.id,
365
+ id: row.id,
366
+ columns: {},
367
+ fetchedRecords: [],
368
+ belongsRecords: [],
369
+ };
370
+
371
+ for (const prop of entity.props) {
372
+ if (isVirtualProp(prop)) {
373
+ continue;
374
+ }
375
+
376
+ record.columns[prop.name] = {
377
+ prop: prop,
378
+ value: row[prop.name],
379
+ };
380
+
381
+ const db = _db ?? BaseModel.getDB("w");
382
+ if (isManyToManyRelationProp(prop)) {
383
+ const relatedEntity = EntityManager.get(prop.with);
384
+ const throughTable = prop.joinTable;
385
+ const fromColumn = `${inflection.singularize(entity.table)}_id`;
386
+ const toColumn = `${inflection.singularize(relatedEntity.table)}_id`;
387
+
388
+ const relatedIds = await db(throughTable)
389
+ .where(fromColumn, row.id)
390
+ .pluck(toColumn);
391
+ record.columns[prop.name].value = relatedIds;
392
+ } else if (isHasManyRelationProp(prop)) {
393
+ const relatedEntity = EntityManager.get(prop.with);
394
+ const relatedIds = await db(relatedEntity.table)
395
+ .where(prop.joinColumn, row.id)
396
+ .pluck("id");
397
+ record.columns[prop.name].value = relatedIds;
398
+ } else if (isOneToOneRelationProp(prop) && !prop.hasJoinColumn) {
399
+ const relatedEntity = EntityManager.get(prop.with);
400
+ const relatedProp = relatedEntity.props.find(
401
+ (p) => p.type === "relation" && p.with === entity.id
402
+ );
403
+ if (relatedProp) {
404
+ const relatedRow = await db(relatedEntity.table)
405
+ .where("id", row.id)
406
+ .first();
407
+ record.columns[prop.name].value = relatedRow?.id;
408
+ }
409
+ } else if (isRelationProp(prop)) {
410
+ const relatedId = row[`${prop.name}_id`];
411
+ record.columns[prop.name].value = relatedId;
412
+ if (relatedId) {
413
+ record.belongsRecords.push(`${prop.with}#${relatedId}`);
414
+ }
415
+ if (!singleRecord && relatedId) {
416
+ const relatedEntity = EntityManager.get(prop.with);
417
+ const relatedRow = await db(relatedEntity.table)
418
+ .where("id", relatedId)
419
+ .first();
420
+ if (relatedRow) {
421
+ await this.createFixtureRecord(
422
+ relatedEntity,
423
+ relatedRow,
424
+ visitedEntities,
425
+ records,
426
+ singleRecord,
427
+ _db
428
+ );
429
+ }
430
+ }
431
+ }
432
+ }
433
+
434
+ records.push(record);
435
+ }
436
+
437
+ async insertFixtures(
438
+ dbName: keyof SonamuDBConfig,
439
+ fixtures: FixtureRecord[]
440
+ ) {
441
+ this.buildDependencyGraph(fixtures);
442
+ const insertionOrder = this.getInsertionOrder();
443
+ const db = knex(Sonamu.dbConfig[dbName]);
444
+
445
+ await db.transaction(async (trx) => {
446
+ await trx.raw(`SET FOREIGN_KEY_CHECKS = 0`);
447
+
448
+ for (const fixtureId of insertionOrder) {
449
+ const fixture = fixtures.find((f) => f.fixtureId === fixtureId)!;
450
+ await this.insertFixture(trx, fixture);
451
+ }
452
+
453
+ for (const fixtureId of insertionOrder) {
454
+ const fixture = fixtures.find((f) => f.fixtureId === fixtureId)!;
455
+ await this.handleManyToManyRelations(trx, fixture, fixtures);
456
+ }
457
+ await trx.raw(`SET FOREIGN_KEY_CHECKS = 1`);
458
+ });
459
+
460
+ const records: FixtureImportResult[] = [];
461
+
462
+ for await (const r of fixtures) {
463
+ const entity = EntityManager.get(r.entityId);
464
+ const record = await db(entity.table).where("id", r.id).first();
465
+ records.push({
466
+ entityId: r.entityId,
467
+ data: record,
468
+ });
469
+ }
470
+
471
+ return records;
472
+ }
473
+
474
+ private getInsertionOrder() {
475
+ const visited = new Set<string>();
476
+ const order: string[] = [];
477
+ const tempVisited = new Set<string>();
478
+
479
+ const visit = (fixtureId: string) => {
480
+ if (visited.has(fixtureId)) return;
481
+ if (tempVisited.has(fixtureId)) {
482
+ console.warn(`Circular dependency detected involving: ${fixtureId}`);
483
+ return;
484
+ }
485
+
486
+ tempVisited.add(fixtureId);
487
+
488
+ const node = this.dependencyGraph.get(fixtureId)!;
489
+ const entity = EntityManager.get(node.entityId);
490
+
491
+ for (const depId of node.dependencies) {
492
+ const depNode = this.dependencyGraph.get(depId)!;
493
+
494
+ // BelongsToOne 관계이면서 nullable이 아닌 경우 먼저 방문
495
+ const relationProp = entity.props.find(
496
+ (prop) =>
497
+ isRelationProp(prop) &&
498
+ (isBelongsToOneRelationProp(prop) ||
499
+ (isOneToOneRelationProp(prop) && prop.hasJoinColumn)) &&
500
+ prop.with === depNode.entityId
501
+ );
502
+ if (relationProp && !relationProp.nullable) {
503
+ visit(depId);
504
+ }
505
+ }
506
+
507
+ tempVisited.delete(fixtureId);
508
+ visited.add(fixtureId);
509
+ order.push(fixtureId);
510
+ };
511
+
512
+ for (const fixtureId of this.dependencyGraph.keys()) {
513
+ visit(fixtureId);
514
+ }
515
+
516
+ // circular dependency로 인해 방문되지 않은 fixtureId 추가
517
+ for (const fixtureId of this.dependencyGraph.keys()) {
518
+ if (!visited.has(fixtureId)) {
519
+ order.push(fixtureId);
520
+ }
521
+ }
522
+
523
+ return order;
524
+ }
525
+
526
+ private prepareInsertData(fixture: FixtureRecord) {
527
+ const insertData: any = {};
528
+ for (const [propName, column] of Object.entries(fixture.columns)) {
529
+ if (isVirtualProp(column.prop)) {
530
+ continue;
531
+ }
532
+
533
+ const prop = column.prop as EntityProp;
534
+ if (!isRelationProp(prop)) {
535
+ if (prop.type === "json") {
536
+ insertData[propName] = JSON.stringify(column.value);
537
+ } else {
538
+ insertData[propName] = column.value;
539
+ }
540
+ } else if (
541
+ isBelongsToOneRelationProp(prop) ||
542
+ (isOneToOneRelationProp(prop) && prop.hasJoinColumn)
543
+ ) {
544
+ insertData[`${propName}_id`] = column.value;
545
+ }
546
+ }
547
+ return insertData;
548
+ }
549
+
550
+ private buildDependencyGraph(fixtures: FixtureRecord[]) {
551
+ this.dependencyGraph.clear();
552
+
553
+ // 1. 노드 추가
554
+ for (const fixture of fixtures) {
555
+ this.dependencyGraph.set(fixture.fixtureId, {
556
+ fixtureId: fixture.fixtureId,
557
+ entityId: fixture.entityId,
558
+ dependencies: new Set(),
559
+ });
560
+ }
561
+
562
+ // 2. 의존성 추가
563
+ for (const fixture of fixtures) {
564
+ const node = this.dependencyGraph.get(fixture.fixtureId)!;
565
+
566
+ for (const [, column] of Object.entries(fixture.columns)) {
567
+ const prop = column.prop as EntityProp;
568
+
569
+ if (isRelationProp(prop)) {
570
+ if (
571
+ isBelongsToOneRelationProp(prop) ||
572
+ (isOneToOneRelationProp(prop) && prop.hasJoinColumn)
573
+ ) {
574
+ const relatedFixtureId = `${prop.with}#${column.value}`;
575
+ if (this.dependencyGraph.has(relatedFixtureId)) {
576
+ node.dependencies.add(relatedFixtureId);
577
+ }
578
+ } else if (isManyToManyRelationProp(prop)) {
579
+ // ManyToMany 관계의 경우 양방향 의존성 추가
580
+ const relatedIds = column.value as number[];
581
+ for (const relatedId of relatedIds) {
582
+ const relatedFixtureId = `${prop.with}#${relatedId}`;
583
+ if (this.dependencyGraph.has(relatedFixtureId)) {
584
+ node.dependencies.add(relatedFixtureId);
585
+ this.dependencyGraph
586
+ .get(relatedFixtureId)!
587
+ .dependencies.add(fixture.fixtureId);
588
+ }
589
+ }
590
+ }
591
+ }
592
+ }
593
+ }
594
+ }
595
+
596
+ private async insertFixture(db: Knex, fixture: FixtureRecord) {
597
+ const insertData = this.prepareInsertData(fixture);
598
+ const entity = EntityManager.get(fixture.entityId);
599
+
600
+ try {
601
+ const found = await db(entity.table).where("id", fixture.id).first();
602
+ if (found && !fixture.override) {
603
+ return {
604
+ entityId: fixture.entityId,
605
+ id: found.id,
606
+ };
607
+ }
608
+
609
+ const q = db.insert(insertData).into(entity.table);
610
+ await q.onDuplicateUpdate.apply(q, Object.keys(insertData));
611
+ return {
612
+ entityId: fixture.entityId,
613
+ id: fixture.id,
614
+ };
615
+ } catch (err) {
616
+ console.log(err);
617
+ throw err;
618
+ }
619
+ }
620
+
621
+ private async handleManyToManyRelations(
622
+ db: Knex,
623
+ fixture: FixtureRecord,
624
+ fixtures: FixtureRecord[]
625
+ ) {
626
+ for (const [, column] of Object.entries(fixture.columns)) {
627
+ const prop = column.prop as EntityProp;
628
+ if (isManyToManyRelationProp(prop)) {
629
+ const joinTable = (prop as ManyToManyRelationProp).joinTable;
630
+ const relatedIds = column.value as number[];
631
+
632
+ for (const relatedId of relatedIds) {
633
+ if (
634
+ !fixtures.find((f) => f.fixtureId === `${prop.with}#${relatedId}`)
635
+ ) {
636
+ continue;
637
+ }
638
+
639
+ const entity = EntityManager.get(fixture.entityId);
640
+ const relatedEntity = EntityManager.get(prop.with);
641
+ if (!entity || !relatedEntity) {
642
+ throw new Error(
643
+ `Entity not found: ${fixture.entityId}, ${prop.with}`
644
+ );
645
+ }
646
+
647
+ const [found] = await db(joinTable)
648
+ .where({
649
+ [`${inflection.singularize(entity.table)}_id`]: fixture.id,
650
+ [`${inflection.singularize(relatedEntity.table)}_id`]: relatedId,
651
+ })
652
+ .limit(1);
653
+ if (found) {
654
+ continue;
655
+ }
656
+
657
+ const newIds = await db(joinTable).insert({
658
+ [`${inflection.singularize(entity.table)}_id`]: fixture.id,
659
+ [`${inflection.singularize(relatedEntity.table)}_id`]: relatedId,
660
+ });
661
+ console.log(
662
+ chalk.green(
663
+ `Inserted into ${joinTable}: ${entity.table}(${fixture.id}) - ${relatedEntity.table}(${relatedId}) ID: ${newIds}`
664
+ )
665
+ );
666
+ }
667
+ }
668
+ }
669
+ }
670
+
671
+ async addFixtureLoader(code: string) {
672
+ const path = Sonamu.apiRootPath + "/src/testing/fixture.ts";
673
+ let content = readFileSync(path).toString();
674
+
675
+ const fixtureLoaderStart = content.indexOf("const fixtureLoader = {");
676
+ const fixtureLoaderEnd = content.indexOf("};", fixtureLoaderStart);
677
+
678
+ if (fixtureLoaderStart !== -1 && fixtureLoaderEnd !== -1) {
679
+ const newContent =
680
+ content.slice(0, fixtureLoaderEnd) +
681
+ " " +
682
+ code +
683
+ "\n" +
684
+ content.slice(fixtureLoaderEnd);
685
+
686
+ writeFileSync(path, newContent);
687
+ } else {
688
+ throw new Error("Failed to find fixtureLoader in fixture.ts");
689
+ }
690
+ }
260
691
  }
261
692
  export const FixtureManager = new FixtureManagerClass();
@@ -617,6 +617,10 @@ export const TemplateOptions = z.object({
617
617
  parentId: z.string().optional(),
618
618
  title: z.string(),
619
619
  table: z.string().optional(),
620
+ props: z.array(z.object({})).optional(),
621
+ indexes: z.array(z.object({})).optional(),
622
+ subsets: z.object({}).optional(),
623
+ enums: z.object({}).optional(),
620
624
  }),
621
625
  init_types: z.object({
622
626
  entityId: z.string(),
@@ -715,3 +719,33 @@ export const PathAndCode = z.object({
715
719
  code: z.string(),
716
720
  });
717
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 레코드
743
+ override?: boolean;
744
+ };
745
+
746
+ export type FixtureImportResult = {
747
+ entityId: string;
748
+ data: {
749
+ [key: string]: any;
750
+ };
751
+ };