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.
- package/dist/migration/migrator.d.ts.map +1 -1
- package/dist/migration/migrator.js +3 -2
- package/dist/migration/postgresql-schema-reader.d.ts +30 -0
- package/dist/migration/postgresql-schema-reader.d.ts.map +1 -1
- package/dist/migration/postgresql-schema-reader.js +115 -29
- package/dist/tooling/cli-tooling.d.ts.map +1 -1
- package/dist/tooling/cli-tooling.js +16 -1
- package/package.json +1 -1
- package/src/migration/migrator.ts +5 -4
- package/src/migration/postgresql-schema-reader.ts +152 -25
- package/src/tooling/cli-tooling.ts +28 -0
|
@@ -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.
|
|
286
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
)
|
|
426
|
+
) WITH ORDINALITY AS expr(column_expr, expr_ord)
|
|
353
427
|
) pgroonga_col ON am.amname = 'pgroonga' AND u.attnum = 0
|
|
354
|
-
WHERE t.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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;
|