sonamu 0.11.2 → 0.11.4

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.
@@ -68,6 +68,12 @@ type PgForeign = {
68
68
  delete_rule: string;
69
69
  };
70
70
 
71
+ type TableSchemaRows = {
72
+ columns: PgColumn[];
73
+ indexes: PgIndex[];
74
+ foreigns: PgForeign[];
75
+ };
76
+
71
77
  type RawCapableKnex = Pick<Knex, "raw">;
72
78
 
73
79
  class PostgreSQLSchemaReaderClass {
@@ -96,6 +102,53 @@ class PostgreSQLSchemaReaderClass {
96
102
  // vector 컬럼의 dimensions 조회
97
103
  const vectorDimensions = await this.getVectorDimensions(compareDB, table);
98
104
 
105
+ return this.buildMigrationSet(table, dbColumns, dbIndexes, dbForeigns, vectorDimensions);
106
+ }
107
+
108
+ /**
109
+ * public 스키마의 모든 테이블을 한 번에 읽어서 테이블별 MigrationSet을 반환합니다.
110
+ *
111
+ * getMigrationSetFromDB를 테이블마다 호출하면 (컬럼/인덱스/FK/벡터차원) × 테이블 수 만큼
112
+ * 왕복이 발생한다. 비교 대상이 수십 개면 원격 DB에서 왕복 비용이 지배적이 되므로,
113
+ * 종류별로 한 번씩만 조회한다. 행 해석은 getMigrationSetFromDB와 동일한
114
+ * buildMigrationSet을 공유한다.
115
+ *
116
+ * @returns 테이블명 → MigrationSet. DB에 없는 테이블은 항목이 없다.
117
+ */
118
+ async getMigrationSetFromDBAll(compareDB: RawCapableKnex): Promise<Map<string, MigrationSet>> {
119
+ const [tables, vectorDimensions] = await Promise.all([
120
+ this.readTables(compareDB),
121
+ this.getVectorDimensionsAll(compareDB),
122
+ ]);
123
+
124
+ const migrationSets = new Map<string, MigrationSet>();
125
+ for (const [table, rows] of tables) {
126
+ migrationSets.set(
127
+ table,
128
+ this.buildMigrationSet(
129
+ table,
130
+ rows.columns,
131
+ rows.indexes,
132
+ rows.foreigns,
133
+ vectorDimensions.get(table) ?? {},
134
+ ),
135
+ );
136
+ }
137
+ return migrationSets;
138
+ }
139
+
140
+ /**
141
+ * 조회 결과 행들을 MigrationSet으로 변환합니다.
142
+ * 테이블 단위 조회(getMigrationSetFromDB)와 전체 일괄 조회(getMigrationSetFromDBAll)가
143
+ * 이 로직을 공유합니다.
144
+ */
145
+ private buildMigrationSet(
146
+ table: string,
147
+ dbColumns: PgColumn[],
148
+ dbIndexes: PgIndex[],
149
+ dbForeigns: PgForeign[],
150
+ vectorDimensions: Record<string, number>,
151
+ ): MigrationSet {
99
152
  const columns: MigrationColumn[] = dbColumns.map((dbColumn) => {
100
153
  const dbColType = this.resolveDBColType(dbColumn);
101
154
 
@@ -257,9 +310,32 @@ class PostgreSQLSchemaReaderClass {
257
310
  compareDB: RawCapableKnex,
258
311
  tableName: string,
259
312
  ): Promise<[PgColumn[], PgIndex[], PgForeign[]]> {
313
+ const tables = await this.readTables(compareDB, tableName);
314
+ const rows = tables.get(tableName);
315
+ if (rows === undefined) {
316
+ throw new Error(`Table not found: ${tableName}`);
317
+ }
318
+ return [rows.columns, rows.indexes, rows.foreigns];
319
+ }
320
+
321
+ /**
322
+ * public 스키마 테이블들의 cols/indexes/foreigns를 테이블별로 묶어서 반환합니다.
323
+ *
324
+ * tableName을 주면 그 테이블만, 생략하면 전체를 조회합니다. 쿼리는 두 경로가 공유하므로
325
+ * 단일 조회와 일괄 조회의 해석이 갈라지지 않습니다. 전체 조회 시에는 테이블마다 4번씩
326
+ * 왕복하는 대신 종류별로 한 번씩만 돌기 때문에, 원격 DB에서 왕복 비용이 크게 줄어듭니다.
327
+ */
328
+ private async readTables(
329
+ compareDB: RawCapableKnex,
330
+ tableName?: string,
331
+ ): Promise<Map<string, TableSchemaRows>> {
332
+ const bindings = tableName === undefined ? [] : [tableName, tableName, tableName];
333
+ const tableFilter = (column: string) => (tableName === undefined ? "" : `AND ${column} = ?`);
334
+
260
335
  // Columns 조회 (Generated Column 정보 포함)
261
336
  const columnsQuery = `
262
337
  SELECT
338
+ c.table_name,
263
339
  c.column_name,
264
340
  c.data_type,
265
341
  c.udt_name,
@@ -282,20 +358,15 @@ class PostgreSQLSchemaReaderClass {
282
358
  SELECT oid FROM pg_class WHERE relname = c.table_name
283
359
  AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema)
284
360
  )
285
- WHERE c.table_name = ?
286
- AND c.table_schema = 'public'
287
- ORDER BY c.ordinal_position
361
+ WHERE c.table_schema = 'public'
362
+ ${tableFilter("c.table_name")}
363
+ ORDER BY c.table_name, c.ordinal_position
288
364
  `;
289
- const columns = /* SAFETY: Knex와 PostgreSQL 스키마 조회 계약이 이 값의 타입을 보장한다. */ (
290
- await compareDB.raw(columnsQuery, [tableName])
291
- ).rows as PgColumn[];
292
- if (columns.length === 0) {
293
- throw new Error(`Table not found: ${tableName}`);
294
- }
295
365
 
296
366
  // Indexes 조회 (PGroonga 표현식 인덱스 포함)
297
367
  const indexesQuery = `
298
368
  SELECT
369
+ t.relname AS table_name,
299
370
  i.relname AS index_name,
300
371
  CASE
301
372
  WHEN am.amname = 'pgroonga' AND u.attnum = 0 THEN
@@ -325,6 +396,7 @@ class PostgreSQLSchemaReaderClass {
325
396
  pg_get_indexdef(ix.indexrelid) AS index_definition,
326
397
  pg_get_expr(ix.indpred, ix.indrelid) AS predicate
327
398
  FROM pg_class t
399
+ JOIN pg_namespace ns ON ns.oid = t.relnamespace
328
400
  JOIN pg_index ix ON t.oid = ix.indrelid
329
401
  JOIN pg_class i ON i.oid = ix.indexrelid
330
402
  JOIN pg_am am ON i.relam = am.oid
@@ -334,9 +406,11 @@ class PostgreSQLSchemaReaderClass {
334
406
  AND a.attnum = u.attnum
335
407
  AND u.attnum > 0
336
408
  LEFT JOIN LATERAL (
337
- SELECT
338
- unnest(
339
- CASE
409
+ -- PGroonga 표현식 인덱스는 모든 행의 attnum이 0이라 u.ord가 같다.
410
+ -- 표현식 배열의 순번(expr_ord)을 함께 뽑아 정렬을 확정한다.
411
+ SELECT expr.column_expr, expr.expr_ord
412
+ FROM unnest(
413
+ CASE
340
414
  WHEN pg_get_expr(ix.indexprs, ix.indrelid) ~ '^ARRAY\\[' THEN
341
415
  string_to_array(
342
416
  regexp_replace(
@@ -349,13 +423,14 @@ class PostgreSQLSchemaReaderClass {
349
423
  ELSE
350
424
  ARRAY[pg_get_expr(ix.indexprs, ix.indrelid)]
351
425
  END
352
- ) as column_expr
426
+ ) WITH ORDINALITY AS expr(column_expr, expr_ord)
353
427
  ) pgroonga_col ON am.amname = 'pgroonga' AND u.attnum = 0
354
- WHERE t.relname = ?
428
+ WHERE t.relkind = 'r'
429
+ AND ns.nspname = 'public'
430
+ ${tableFilter("t.relname")}
355
431
  AND (u.attnum > 0 OR (am.amname = 'pgroonga' AND u.attnum = 0))
356
- ORDER BY i.relname, u.ord;
432
+ ORDER BY t.relname, i.relname, u.ord, pgroonga_col.expr_ord;
357
433
  `;
358
- const indexes = (await compareDB.raw(indexesQuery, [tableName])).rows;
359
434
 
360
435
  // Foreign Keys 조회
361
436
  //
@@ -371,6 +446,7 @@ class PostgreSQLSchemaReaderClass {
371
446
  // 짝지어야 복합 FK에서 컬럼 대응이 어긋나지 않는다.
372
447
  const foreignsQuery = `
373
448
  SELECT
449
+ cl.relname AS table_name,
374
450
  con.conname AS constraint_name,
375
451
  att.attname AS column_name,
376
452
  ref_cl.relname AS foreign_table_name,
@@ -402,12 +478,46 @@ class PostgreSQLSchemaReaderClass {
402
478
  ON ref_att.attrelid = ref_cl.oid AND ref_att.attnum = ref_key_col.attnum
403
479
  WHERE con.contype = 'f'
404
480
  AND ns.nspname = 'public'
405
- AND cl.relname = ?
406
- ORDER BY con.conname, key_col.ord
481
+ ${tableFilter("cl.relname")}
482
+ ORDER BY cl.relname, con.conname, key_col.ord
407
483
  `;
408
- const foreigns = (await compareDB.raw(foreignsQuery, [tableName])).rows;
409
484
 
410
- return [columns, indexes, foreigns];
485
+ // 종류별로 한 번씩만 조회한 뒤 테이블명으로 묶습니다.
486
+ const [columnRows, indexRows, foreignRows] = await Promise.all([
487
+ compareDB
488
+ .raw(columnsQuery, bindings.slice(0, 1))
489
+ .then((result: { rows: (PgColumn & { table_name: string })[] }) => result.rows),
490
+ compareDB
491
+ .raw(indexesQuery, bindings.slice(1, 2))
492
+ .then((result: { rows: (PgIndex & { table_name: string })[] }) => result.rows),
493
+ compareDB
494
+ .raw(foreignsQuery, bindings.slice(2, 3))
495
+ .then((result: { rows: (PgForeign & { table_name: string })[] }) => result.rows),
496
+ ]);
497
+
498
+ const tables = new Map<string, TableSchemaRows>();
499
+ const ensure = (table: string): TableSchemaRows => {
500
+ const existing = tables.get(table);
501
+ if (existing !== undefined) {
502
+ return existing;
503
+ }
504
+ const created: TableSchemaRows = { columns: [], indexes: [], foreigns: [] };
505
+ tables.set(table, created);
506
+ return created;
507
+ };
508
+
509
+ // 컬럼이 없는 테이블은 존재하지 않는 것으로 취급하므로 컬럼 기준으로 항목을 만듭니다.
510
+ for (const row of columnRows) {
511
+ ensure(row.table_name).columns.push(row);
512
+ }
513
+ for (const row of indexRows) {
514
+ tables.get(row.table_name)?.indexes.push(row);
515
+ }
516
+ for (const row of foreignRows) {
517
+ tables.get(row.table_name)?.foreigns.push(row);
518
+ }
519
+
520
+ return tables;
411
521
  }
412
522
 
413
523
  private restoreMigrationIndexType(
@@ -788,24 +898,41 @@ class PostgreSQLSchemaReaderClass {
788
898
  compareDB: RawCapableKnex,
789
899
  tableName: string,
790
900
  ): Promise<Record<string, number>> {
901
+ return (await this.getVectorDimensionsAll(compareDB, tableName)).get(tableName) ?? {};
902
+ }
903
+
904
+ /**
905
+ * vector 컬럼의 dimensions를 테이블별로 조회합니다.
906
+ * tableName을 생략하면 public 스키마 전체를 한 번에 조회합니다.
907
+ */
908
+ private async getVectorDimensionsAll(
909
+ compareDB: RawCapableKnex,
910
+ tableName?: string,
911
+ ): Promise<Map<string, Record<string, number>>> {
791
912
  const query = `
792
913
  SELECT
914
+ c.relname as table_name,
793
915
  a.attname as column_name,
794
916
  a.atttypmod as dimensions
795
917
  FROM pg_attribute a
796
918
  JOIN pg_class c ON a.attrelid = c.oid
919
+ JOIN pg_namespace n ON n.oid = c.relnamespace
797
920
  JOIN pg_type t ON a.atttypid = t.oid
798
- WHERE c.relname = ?
799
- AND t.typname = 'vector'
921
+ WHERE t.typname = 'vector'
800
922
  AND a.attnum > 0
923
+ AND n.nspname = 'public'
924
+ ${tableName === undefined ? "" : "AND c.relname = ?"}
801
925
  `;
802
- const result = await compareDB.raw(query, [tableName]);
803
- const dimensions: Record<string, number> = {};
926
+ const result = await compareDB.raw(query, tableName === undefined ? [] : [tableName]);
927
+
928
+ const dimensionsByTable = new Map<string, Record<string, number>>();
804
929
  for (const row of result.rows) {
930
+ const dimensions = dimensionsByTable.get(row.table_name) ?? {};
805
931
  // atttypmod에서 실제 dimensions 값 추출
806
932
  dimensions[row.column_name] = row.dimensions > 0 ? row.dimensions : 0;
933
+ dimensionsByTable.set(row.table_name, dimensions);
807
934
  }
808
- return dimensions;
935
+ return dimensionsByTable;
809
936
  }
810
937
 
811
938
  /**
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
 
3
3
  import { type FixtureCommandOptions } from "../bin/fixture";
4
+ import { type TemplateKey } from "../types/types";
4
5
 
5
6
  const jsonValueSchema = z.json();
6
7
  type CommandValue = z.infer<typeof jsonValueSchema> | undefined;
@@ -323,6 +324,29 @@ async function renderScaffolds(input: CommandInput) {
323
324
  return rendered;
324
325
  }
325
326
 
327
+ /**
328
+ * 템플릿이 요구하는 dict 키를 프로젝트 사전에 채웁니다.
329
+ *
330
+ * 생성 코드에는 SD("error.entityNotFound") 같은 호출이 박히는데, 키가 프로젝트 사전에
331
+ * 없으면 번역이 되지 않아 사람이 매번 손으로 채우거나 다른 키로 바꿔야 한다.
332
+ * Sonamu UI 경로(ui/api.ts)는 이미 같은 처리를 하므로, CLI도 동일하게 맞춘다.
333
+ */
334
+ async function ensureTemplateDictKeys(templateKeys: TemplateKey[]): Promise<void> {
335
+ const [{ TemplateManager }, { sonamuDictionary }] = await Promise.all([
336
+ import("../template/template-manager"),
337
+ import("../dict/sonamu-dictionary"),
338
+ ]);
339
+
340
+ const requiredKeys = templateKeys.flatMap(
341
+ (templateKey) => TemplateManager.get(templateKey).getRequiredDictKeys() ?? [],
342
+ );
343
+ if (requiredKeys.length === 0) {
344
+ return;
345
+ }
346
+
347
+ await sonamuDictionary.ensureDictKeys([...new Set(requiredKeys)]);
348
+ }
349
+
326
350
  async function scaffoldOperation(method: string, input: CommandInput): Promise<ToolingResult> {
327
351
  const directTemplate = z
328
352
  .enum(["model", "model_test", "view_list", "view_form"])
@@ -331,6 +355,7 @@ async function scaffoldOperation(method: string, input: CommandInput): Promise<T
331
355
  await ensureSonamu();
332
356
  const { Sonamu } = await import("../api/sonamu");
333
357
  const entityId = requiredText(input, "entityId");
358
+ await ensureTemplateDictKeys([directTemplate.data]);
334
359
  return directTemplate.data === "view_list"
335
360
  ? Sonamu.syncer.generateTemplate("view_list", { entityId, extra: input.extra })
336
361
  : Sonamu.syncer.generateTemplate(directTemplate.data, { entityId });
@@ -359,6 +384,9 @@ async function scaffoldOperation(method: string, input: CommandInput): Promise<T
359
384
  if (method === "status") return items.map(({ content: _content, ...item }) => item);
360
385
  if (method === "preview" || (method === "batch" && explicitlyDryRun(input))) return items;
361
386
 
387
+ // 실제로 파일을 쓰는 경우에만 사전을 건드린다(status/preview/dry-run은 위에서 반환됨).
388
+ await ensureTemplateDictKeys([...new Set(items.map((item) => item.template))]);
389
+
362
390
  const results = [];
363
391
  for (const item of items) {
364
392
  const { content: _content, ...summary } = item;