sonamu 0.8.13 → 0.8.14
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/api/sonamu.d.ts.map +1 -1
- package/dist/api/sonamu.js +2 -3
- package/dist/auth/auth-generator.d.ts +8 -0
- package/dist/auth/auth-generator.d.ts.map +1 -1
- package/dist/auth/auth-generator.js +33 -1
- package/dist/auth/better-auth-entities.d.ts.map +1 -1
- package/dist/auth/better-auth-entities.js +12 -2
- package/dist/bin/cli.js +18 -3
- package/dist/cone/cone-generator.js +10 -4
- package/dist/database/knex.d.ts.map +1 -1
- package/dist/database/knex.js +64 -2
- package/dist/database/puri.d.ts +9 -1
- package/dist/database/puri.d.ts.map +1 -1
- package/dist/database/puri.js +42 -1
- package/dist/database/puri.types.d.ts +2 -0
- package/dist/database/puri.types.d.ts.map +1 -1
- package/dist/database/puri.types.js +6 -2
- package/dist/entity/entity-manager.d.ts +149 -1
- package/dist/entity/entity-manager.d.ts.map +1 -1
- package/dist/entity/entity-manager.js +68 -4
- package/dist/migration/__tests__/code-generation.search-text.test.js +435 -0
- package/dist/migration/code-generation.d.ts.map +1 -1
- package/dist/migration/code-generation.js +696 -32
- package/dist/migration/migration-set.js +3 -1
- package/dist/migration/postgresql-schema-reader.d.ts +16 -2
- package/dist/migration/postgresql-schema-reader.d.ts.map +1 -1
- package/dist/migration/postgresql-schema-reader.js +281 -7
- package/dist/stream/sse.js +5 -3
- package/dist/template/__tests__/generated.template.search-text.test.js +99 -0
- package/dist/template/generated.template.test-d.js +24 -0
- package/dist/template/implementations/generated.template.d.ts.map +1 -1
- package/dist/template/implementations/generated.template.js +2 -2
- package/dist/template/implementations/init_types.template.d.ts.map +1 -1
- package/dist/template/implementations/init_types.template.js +11 -3
- package/dist/template/zod-converter.d.ts.map +1 -1
- package/dist/template/zod-converter.js +6 -2
- package/dist/testing/dev-test-routes.d.ts.map +1 -1
- package/dist/testing/dev-test-routes.js +5 -3
- package/dist/testing/fixture-generator.d.ts +13 -0
- package/dist/testing/fixture-generator.d.ts.map +1 -1
- package/dist/testing/fixture-generator.js +105 -8
- package/dist/testing/fixture-manager.d.ts.map +1 -1
- package/dist/testing/fixture-manager.js +19 -2
- package/dist/types/__tests__/entity-json-schema-search-text.test.js +256 -0
- package/dist/types/types.d.ts +494 -1
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/types.js +117 -13
- package/dist/ui/api.d.ts.map +1 -1
- package/dist/ui/api.js +14 -2
- package/dist/ui/cdd-service.d.ts +16 -14
- package/dist/ui/cdd-service.d.ts.map +1 -1
- package/dist/ui/cdd-service.js +145 -37
- package/dist/ui/cdd-types.d.ts +60 -0
- package/dist/ui/cdd-types.d.ts.map +1 -0
- package/dist/ui/cdd-types.js +3 -0
- package/dist/ui-web/assets/index-D4XFBV-f.css +1 -0
- package/dist/ui-web/assets/{index-CQ_S40bD.js → index-D_19-Pi4.js} +87 -87
- package/dist/ui-web/index.html +2 -2
- package/package.json +7 -3
- package/src/api/sonamu.ts +1 -2
- package/src/auth/auth-generator.ts +38 -0
- package/src/auth/better-auth-entities.ts +18 -1
- package/src/bin/cli.ts +15 -1
- package/src/cone/cone-generator.ts +9 -3
- package/src/database/knex.ts +62 -4
- package/src/database/puri.ts +71 -0
- package/src/database/puri.types.ts +2 -0
- package/src/entity/entity-manager.ts +95 -3
- package/src/migration/__tests__/code-generation.search-text.test.ts +390 -0
- package/src/migration/code-generation.ts +848 -34
- package/src/migration/migration-set.ts +2 -0
- package/src/migration/postgresql-schema-reader.ts +366 -9
- package/src/skills/sonamu/auth-migration.md +80 -0
- package/src/skills/sonamu/cdd.md +148 -28
- package/src/skills/sonamu/cone.md +16 -0
- package/src/skills/sonamu/entity-relations.md +1 -1
- package/src/skills/sonamu/fixture-cli.md +4 -0
- package/src/skills/sonamu/frontend.md +65 -0
- package/src/skills/sonamu/migration.md +3 -1
- package/src/skills/sonamu/model.md +28 -0
- package/src/skills/sonamu/workflow.md +12 -5
- package/src/stream/sse.ts +4 -2
- package/src/template/__tests__/generated.template.search-text.test.ts +89 -0
- package/src/template/generated.template.test-d.ts +46 -0
- package/src/template/implementations/generated.template.ts +4 -1
- package/src/template/implementations/init_types.template.ts +20 -5
- package/src/template/zod-converter.ts +5 -0
- package/src/testing/dev-test-routes.ts +4 -2
- package/src/testing/fixture-generator.ts +157 -9
- package/src/testing/fixture-manager.ts +15 -1
- package/src/types/__tests__/entity-json-schema-search-text.test.ts +179 -0
- package/src/types/types.ts +168 -12
- package/src/ui/api.ts +24 -1
- package/src/ui/cdd-service.ts +195 -55
- package/src/ui/cdd-types.ts +73 -0
- package/dist/ui-web/assets/index-egkMxKos.css +0 -1
|
@@ -225,7 +225,10 @@ export class Template__generated extends Template {
|
|
|
225
225
|
* - generated 속성이 있는 컬럼 (INSERT/UPDATE 시 값 제공 불가)
|
|
226
226
|
*/
|
|
227
227
|
const generatedColumns = entity.props
|
|
228
|
-
.filter(
|
|
228
|
+
.filter(
|
|
229
|
+
(prop) =>
|
|
230
|
+
prop.type !== "relation" && (prop.generated !== undefined || prop.type === "searchText"),
|
|
231
|
+
)
|
|
229
232
|
.map((prop) => prop.name);
|
|
230
233
|
|
|
231
234
|
const hasMetadata =
|
|
@@ -19,9 +19,24 @@ export class Template__init_types extends Template {
|
|
|
19
19
|
|
|
20
20
|
render({ entityId }: TemplateOptions["init_types"]) {
|
|
21
21
|
const names = EntityManager.getNamesFromId(entityId);
|
|
22
|
+
const entity = EntityManager.get(entityId);
|
|
22
23
|
|
|
23
|
-
const
|
|
24
|
-
|
|
24
|
+
const partialColumns = ["id"];
|
|
25
|
+
if (entity.props.some((prop) => prop.name === "created_at")) {
|
|
26
|
+
partialColumns.push("created_at");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const writeIneligibleColumns = entity.props
|
|
30
|
+
.filter(
|
|
31
|
+
(prop) =>
|
|
32
|
+
prop.type !== "relation" && (prop.generated !== undefined || prop.type === "searchText"),
|
|
33
|
+
)
|
|
34
|
+
.map((prop) => prop.name);
|
|
35
|
+
|
|
36
|
+
const omitGeneratedColumns =
|
|
37
|
+
writeIneligibleColumns.length > 0
|
|
38
|
+
? `.omit({ ${writeIneligibleColumns.map((column) => `${column}: true`).join(", ")} })`
|
|
39
|
+
: "";
|
|
25
40
|
|
|
26
41
|
return {
|
|
27
42
|
...this.getTargetAndPath(names),
|
|
@@ -34,9 +49,9 @@ export const ${entityId}ListParams = ${entityId}BaseListParams;
|
|
|
34
49
|
export type ${entityId}ListParams = z.infer<typeof ${entityId}ListParams>;
|
|
35
50
|
|
|
36
51
|
// ${entityId} - SaveParams
|
|
37
|
-
export const ${entityId}SaveParams = ${entityId}BaseSchema.partial({
|
|
38
|
-
|
|
39
|
-
|
|
52
|
+
export const ${entityId}SaveParams = ${entityId}BaseSchema${omitGeneratedColumns}.partial({ ${partialColumns
|
|
53
|
+
.map((column) => `${column}: true`)
|
|
54
|
+
.join(", ")} });
|
|
40
55
|
export type ${entityId}SaveParams = z.infer<typeof ${entityId}SaveParams>;
|
|
41
56
|
|
|
42
57
|
`.trim(),
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
isNumericSingleProp,
|
|
47
47
|
isOneToOneRelationProp,
|
|
48
48
|
isRelationProp,
|
|
49
|
+
isSearchTextProp,
|
|
49
50
|
isStringArrayProp,
|
|
50
51
|
isStringSingleProp,
|
|
51
52
|
isTsVectorProp,
|
|
@@ -248,6 +249,8 @@ export async function propToZodType(prop: EntityProp): Promise<z.ZodTypeAny> {
|
|
|
248
249
|
zodType = z.uuid().array();
|
|
249
250
|
} else if (isJsonProp(prop)) {
|
|
250
251
|
zodType = await getZodTypeById(prop.id);
|
|
252
|
+
} else if (isSearchTextProp(prop)) {
|
|
253
|
+
zodType = z.string();
|
|
251
254
|
} else if (isVectorSingleProp(prop)) {
|
|
252
255
|
zodType = z.array(z.number());
|
|
253
256
|
} else if (isVectorArrayProp(prop)) {
|
|
@@ -354,6 +357,8 @@ export function propToZodTypeDef(prop: EntityProp, injectImportKeys: string[]):
|
|
|
354
357
|
stmt = `${prop.name}: ${prop.id}`;
|
|
355
358
|
injectImportKeys.push(prop.id);
|
|
356
359
|
}
|
|
360
|
+
} else if (isSearchTextProp(prop)) {
|
|
361
|
+
stmt = `${prop.name}: z.string()`;
|
|
357
362
|
} else if (isVectorSingleProp(prop)) {
|
|
358
363
|
stmt = `${prop.name}: z.array(z.number())`;
|
|
359
364
|
} else if (isVectorArrayProp(prop)) {
|
|
@@ -200,10 +200,12 @@ export async function registerDevTestRoutes(
|
|
|
200
200
|
};
|
|
201
201
|
manager.addEventListener(listener);
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
const cleanup = () => {
|
|
204
204
|
clearInterval(heartbeatTimer);
|
|
205
205
|
manager.removeEventListener(listener);
|
|
206
|
-
}
|
|
206
|
+
};
|
|
207
|
+
request.socket.on("close", cleanup);
|
|
208
|
+
request.socket.on("error", cleanup);
|
|
207
209
|
});
|
|
208
210
|
}
|
|
209
211
|
|
|
@@ -123,6 +123,16 @@ export class FixtureGenerator {
|
|
|
123
123
|
|
|
124
124
|
// 1. Relation prop 처리
|
|
125
125
|
if (isRelationProp(prop)) {
|
|
126
|
+
// BelongsToOne / OneToOne(hasJoinColumn)은 FK 컬럼명({prop.name}_id)으로도 override를 받는다
|
|
127
|
+
const fkColName = `${prop.name}_id`;
|
|
128
|
+
if (
|
|
129
|
+
fkColName in overrides &&
|
|
130
|
+
(isBelongsToOneRelationProp(prop) || (isOneToOneRelationProp(prop) && prop.hasJoinColumn))
|
|
131
|
+
) {
|
|
132
|
+
fixture[fkColName] = overrides[fkColName];
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
126
136
|
const relationValue = await this.generateRelationValue(entity, prop, context);
|
|
127
137
|
// BelongsToOne, OneToOne(hasJoinColumn)의 경우 foreign key 컬럼명으로 저장
|
|
128
138
|
if (
|
|
@@ -803,13 +813,16 @@ export class FixtureGenerator {
|
|
|
803
813
|
const { createAnthropic } = await import("@ai-sdk/anthropic");
|
|
804
814
|
const { generateText } = await import("ai");
|
|
805
815
|
|
|
806
|
-
const
|
|
816
|
+
const rowResponse = await generateText({
|
|
807
817
|
model: createAnthropic({ apiKey })(this.options.llmModel || "claude-sonnet-4-5"),
|
|
808
818
|
prompt: this.buildRowLLMPrompt(llmProps, entity),
|
|
809
819
|
});
|
|
820
|
+
if (!rowResponse || typeof rowResponse.text !== "string") {
|
|
821
|
+
throw new Error("Invalid LLM response");
|
|
822
|
+
}
|
|
810
823
|
|
|
811
824
|
// 응답을 파싱하여 각 필드에 대한 결과를 캐시에 저장
|
|
812
|
-
const rowResult = this.parseRowLLMResponse(text, llmProps);
|
|
825
|
+
const rowResult = this.parseRowLLMResponse(rowResponse.text, llmProps);
|
|
813
826
|
for (const [fieldName, value] of Object.entries(rowResult)) {
|
|
814
827
|
this.llmCache.set(`${rowKey}:${fieldName}`, value);
|
|
815
828
|
}
|
|
@@ -844,12 +857,15 @@ export class FixtureGenerator {
|
|
|
844
857
|
const { createAnthropic } = await import("@ai-sdk/anthropic");
|
|
845
858
|
const { generateText } = await import("ai");
|
|
846
859
|
|
|
847
|
-
const
|
|
860
|
+
const singleResponse = await generateText({
|
|
848
861
|
model: createAnthropic({ apiKey })(this.options.llmModel || "claude-sonnet-4-5"),
|
|
849
862
|
prompt: this.buildLLMPrompt(fixtureHint, prop, entity),
|
|
850
863
|
});
|
|
864
|
+
if (!singleResponse || typeof singleResponse.text !== "string") {
|
|
865
|
+
throw new Error("Invalid LLM response");
|
|
866
|
+
}
|
|
851
867
|
|
|
852
|
-
const value = this.parseLLMResponse(text, prop.type);
|
|
868
|
+
const value = this.parseLLMResponse(singleResponse.text, prop.type);
|
|
853
869
|
if (this.options.enableLLMCache) {
|
|
854
870
|
this.llmCache.set(cacheKey, value);
|
|
855
871
|
}
|
|
@@ -944,7 +960,13 @@ ${outputShape}
|
|
|
944
960
|
|
|
945
961
|
let parsed: Record<string, unknown>;
|
|
946
962
|
try {
|
|
947
|
-
|
|
963
|
+
const raw = JSON.parse(jsonText);
|
|
964
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
965
|
+
!isTest() &&
|
|
966
|
+
console.warn("[FixtureGenerator] Row LLM response is not a plain object:", text);
|
|
967
|
+
return {};
|
|
968
|
+
}
|
|
969
|
+
parsed = raw as Record<string, unknown>;
|
|
948
970
|
} catch {
|
|
949
971
|
!isTest() && console.warn("[FixtureGenerator] Failed to parse row LLM response:", text);
|
|
950
972
|
return {};
|
|
@@ -1469,11 +1491,137 @@ Rules:
|
|
|
1469
1491
|
// 3. targetDb에 삽입 (FixtureManager가 의존성 정렬 처리)
|
|
1470
1492
|
const results = await FixtureManager.insertFixtures(this.targetDbName, fixtureRecords);
|
|
1471
1493
|
|
|
1494
|
+
// 4. companion fixtures 생성 (fixtureCompanions가 선언된 경우)
|
|
1495
|
+
const companionResults = await this.generateCompanions(specs, results);
|
|
1496
|
+
|
|
1497
|
+
const total = results.length + companionResults.length;
|
|
1472
1498
|
!isTest() &&
|
|
1473
|
-
console.log(
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1499
|
+
console.log(chalk.green(`Generated and saved ${total} fixtures to ${this.targetDbName}`));
|
|
1500
|
+
return [...results, ...companionResults];
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* 부모 fixture 결과를 기반으로 fixtureCompanions에 선언된 companion Entity를 생성합니다.
|
|
1505
|
+
*
|
|
1506
|
+
* generateBatch()에서만 호출되며, companion 생성 시 재귀를 방지하기 위해
|
|
1507
|
+
* generateBatch()를 다시 호출하지 않고 직접 삽입합니다.
|
|
1508
|
+
*/
|
|
1509
|
+
private async generateCompanions(
|
|
1510
|
+
specs: Array<{ entity: string; count: number; overrides?: Record<string, unknown> }>,
|
|
1511
|
+
parentResults: FixtureImportResult[],
|
|
1512
|
+
): Promise<FixtureImportResult[]> {
|
|
1513
|
+
const allResults: FixtureImportResult[] = [];
|
|
1514
|
+
const processedEntities = new Set<string>();
|
|
1515
|
+
|
|
1516
|
+
for (const spec of specs) {
|
|
1517
|
+
if (processedEntities.has(spec.entity)) continue;
|
|
1518
|
+
processedEntities.add(spec.entity);
|
|
1519
|
+
|
|
1520
|
+
const entity = this.entityManager.get(spec.entity);
|
|
1521
|
+
const idProp = entity.props.find((p) => p.name === "id");
|
|
1522
|
+
const companions = idProp?.cone?.fixtureCompanions;
|
|
1523
|
+
if (!companions || companions.length === 0) continue;
|
|
1524
|
+
|
|
1525
|
+
const entityResults = parentResults.filter((r) => r.entityId === spec.entity);
|
|
1526
|
+
if (entityResults.length === 0) continue;
|
|
1527
|
+
|
|
1528
|
+
for (const companion of companions) {
|
|
1529
|
+
// companion entity에서 부모 entity로의 BelongsToOne FK 컬럼명 파악
|
|
1530
|
+
const companionEntity = this.entityManager.get(companion.entity);
|
|
1531
|
+
const fkProp = companionEntity.props.find(
|
|
1532
|
+
(p) => isRelationProp(p) && isBelongsToOneRelationProp(p) && p.with === spec.entity,
|
|
1533
|
+
);
|
|
1534
|
+
if (!fkProp) {
|
|
1535
|
+
!isTest() &&
|
|
1536
|
+
console.warn(
|
|
1537
|
+
chalk.yellow(
|
|
1538
|
+
`[Companion] No BelongsToOne relation from ${companion.entity} to ${spec.entity}. Skipping.`,
|
|
1539
|
+
),
|
|
1540
|
+
);
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1543
|
+
const fkColName = `${fkProp.name}_id`;
|
|
1544
|
+
|
|
1545
|
+
// companion의 idProp, usesSequence, count는 companion 단위로 고정
|
|
1546
|
+
const companionIdProp = companionEntity.props.find((p) => p.name === "id");
|
|
1547
|
+
const usesSequence =
|
|
1548
|
+
companionIdProp?.type === "integer" ||
|
|
1549
|
+
companionIdProp?.type === "bigInteger" ||
|
|
1550
|
+
companionIdProp?.cone?.fixtureStrategy === "sequence";
|
|
1551
|
+
const companionCount = companion.count ?? 1;
|
|
1552
|
+
|
|
1553
|
+
// 각 parent result에 대해 companion fixture 생성
|
|
1554
|
+
const context = this.createContext();
|
|
1555
|
+
const companionFixtureRecords: FixtureRecord[] = [];
|
|
1556
|
+
|
|
1557
|
+
for (const parentResult of entityResults) {
|
|
1558
|
+
const resolvedOverrides = this.resolveTemplateOverrides(
|
|
1559
|
+
companion.overrides ?? {},
|
|
1560
|
+
parentResult.data,
|
|
1561
|
+
);
|
|
1562
|
+
resolvedOverrides[fkColName] = parentResult.data.id;
|
|
1563
|
+
|
|
1564
|
+
for (let i = 0; i < companionCount; i++) {
|
|
1565
|
+
const fixture = await this.generate(companion.entity, resolvedOverrides, context);
|
|
1566
|
+
|
|
1567
|
+
const dataForRecord = usesSequence
|
|
1568
|
+
? { ...fixture, id: Math.floor(Math.random() * 1000000) }
|
|
1569
|
+
: fixture;
|
|
1570
|
+
|
|
1571
|
+
const records = await FixtureManager.createFixtureRecord(
|
|
1572
|
+
companionEntity,
|
|
1573
|
+
dataForRecord as {
|
|
1574
|
+
id: number | string;
|
|
1575
|
+
[key: string]: string | number | boolean | null;
|
|
1576
|
+
},
|
|
1577
|
+
{ singleRecord: true },
|
|
1578
|
+
);
|
|
1579
|
+
companionFixtureRecords.push(...records);
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
const companionResults = await FixtureManager.insertFixtures(
|
|
1584
|
+
this.targetDbName,
|
|
1585
|
+
companionFixtureRecords,
|
|
1586
|
+
);
|
|
1587
|
+
allResults.push(...companionResults);
|
|
1588
|
+
|
|
1589
|
+
!isTest() &&
|
|
1590
|
+
console.log(
|
|
1591
|
+
chalk.green(
|
|
1592
|
+
`[Companion] Generated ${companionResults.length} ${companion.entity} fixtures`,
|
|
1593
|
+
),
|
|
1594
|
+
);
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
return allResults;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/**
|
|
1602
|
+
* overrides 값의 "{{fieldName}}" 템플릿을 부모 fixture 데이터로 치환합니다.
|
|
1603
|
+
*
|
|
1604
|
+
* 예: { "account_id": "{{email}}" } → { "account_id": "user@example.com" }
|
|
1605
|
+
*/
|
|
1606
|
+
private resolveTemplateOverrides(
|
|
1607
|
+
overrides: Record<string, unknown>,
|
|
1608
|
+
parentData: { [key: string]: string | number | boolean | Date | null },
|
|
1609
|
+
): Record<string, unknown> {
|
|
1610
|
+
const resolved: Record<string, unknown> = {};
|
|
1611
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
1612
|
+
if (typeof value === "string" && value.startsWith("{{") && value.endsWith("}}")) {
|
|
1613
|
+
const fieldName = value.slice(2, -2).trim();
|
|
1614
|
+
if (!(fieldName in parentData)) {
|
|
1615
|
+
throw new Error(
|
|
1616
|
+
`템플릿 필드 "${fieldName}"이(가) 부모 fixture 데이터에 존재하지 않습니다 (override key: "${key}")`,
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
resolved[key] = parentData[fieldName];
|
|
1620
|
+
} else {
|
|
1621
|
+
resolved[key] = value;
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
return resolved;
|
|
1477
1625
|
}
|
|
1478
1626
|
|
|
1479
1627
|
/**
|
|
@@ -495,7 +495,21 @@ export class FixtureManagerClass {
|
|
|
495
495
|
this.fixtureRefMap = new Map();
|
|
496
496
|
this.skippedFixtures = new Map();
|
|
497
497
|
|
|
498
|
-
|
|
498
|
+
// 병렬 테스트 모드에서는 worker별 DB에 저장
|
|
499
|
+
const dbConfig =
|
|
500
|
+
process.env.SONAMU_WORKER_DB === "true" && process.env.VITEST_POOL_ID
|
|
501
|
+
? (() => {
|
|
502
|
+
const workerId = parseInt(process.env.VITEST_POOL_ID ?? "1", 10);
|
|
503
|
+
const baseConfig = Sonamu.dbConfig[dbName];
|
|
504
|
+
const connection = baseConfig.connection as { database: string };
|
|
505
|
+
return {
|
|
506
|
+
...baseConfig,
|
|
507
|
+
connection: { ...connection, database: `${connection.database}_${workerId}` },
|
|
508
|
+
pool: { min: 1, max: 1 },
|
|
509
|
+
};
|
|
510
|
+
})()
|
|
511
|
+
: Sonamu.dbConfig[dbName];
|
|
512
|
+
const db = createKnexInstance(dbConfig);
|
|
499
513
|
const results: FixtureImportResult[] = [];
|
|
500
514
|
|
|
501
515
|
try {
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { EntityJsonSchema, isSearchTextJsonSourceZodType, TemplateOptions } from "../types";
|
|
4
|
+
|
|
5
|
+
function createBaseEntity() {
|
|
6
|
+
return {
|
|
7
|
+
id: "SearchTextTest",
|
|
8
|
+
title: "SearchText Test",
|
|
9
|
+
table: "search_text_tests",
|
|
10
|
+
props: [
|
|
11
|
+
{ name: "id", type: "integer" },
|
|
12
|
+
{ name: "title", type: "string" },
|
|
13
|
+
{ name: "tags", type: "string[]" },
|
|
14
|
+
{ name: "aliases", type: "json", id: "StringArray" },
|
|
15
|
+
{
|
|
16
|
+
name: "search_text",
|
|
17
|
+
type: "searchText",
|
|
18
|
+
sourceColumns: [{ name: "title", caseInsensitive: true }],
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
indexes: [
|
|
22
|
+
{
|
|
23
|
+
type: "index",
|
|
24
|
+
name: "search_text_tests_search_text_index",
|
|
25
|
+
using: "gin",
|
|
26
|
+
columns: [{ name: "search_text", opclass: "gin_trgm_ops" }],
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
subsets: {
|
|
30
|
+
A: ["id", "title", "tags", "aliases", "search_text"],
|
|
31
|
+
},
|
|
32
|
+
enums: {},
|
|
33
|
+
} as const;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("EntityJsonSchema searchText/opclass validation", () => {
|
|
37
|
+
test("opclass known/free string과 vectorOps 하위호환을 허용해야 한다", () => {
|
|
38
|
+
const knownOpclass = createBaseEntity();
|
|
39
|
+
const knownResult = EntityJsonSchema.safeParse(knownOpclass);
|
|
40
|
+
expect(knownResult.success).toBe(true);
|
|
41
|
+
|
|
42
|
+
const freeOpclass = createBaseEntity();
|
|
43
|
+
freeOpclass.indexes[0] = {
|
|
44
|
+
...freeOpclass.indexes[0],
|
|
45
|
+
columns: [{ name: "search_text", opclass: "custom_text_ops" }],
|
|
46
|
+
};
|
|
47
|
+
const freeResult = EntityJsonSchema.safeParse(freeOpclass);
|
|
48
|
+
expect(freeResult.success).toBe(true);
|
|
49
|
+
|
|
50
|
+
const legacyVectorOps = createBaseEntity();
|
|
51
|
+
legacyVectorOps.indexes[0] = {
|
|
52
|
+
type: "hnsw",
|
|
53
|
+
name: "search_text_tests_embedding_hnsw",
|
|
54
|
+
columns: [{ name: "embedding", vectorOps: "vector_cosine_ops" }],
|
|
55
|
+
m: 16,
|
|
56
|
+
efConstruction: 64,
|
|
57
|
+
};
|
|
58
|
+
const legacyResult = EntityJsonSchema.safeParse(legacyVectorOps);
|
|
59
|
+
expect(legacyResult.success).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("searchText source column 존재/타입 검증이 동작해야 한다", () => {
|
|
63
|
+
const unknownSource = createBaseEntity();
|
|
64
|
+
unknownSource.props[4] = {
|
|
65
|
+
name: "search_text",
|
|
66
|
+
type: "searchText",
|
|
67
|
+
sourceColumns: [{ name: "missing_col", caseInsensitive: true }],
|
|
68
|
+
};
|
|
69
|
+
const unknownResult = EntityJsonSchema.safeParse(unknownSource);
|
|
70
|
+
expect(unknownResult.success).toBe(false);
|
|
71
|
+
|
|
72
|
+
const unsupportedSourceType = createBaseEntity();
|
|
73
|
+
unsupportedSourceType.props[4] = {
|
|
74
|
+
name: "search_text",
|
|
75
|
+
type: "searchText",
|
|
76
|
+
sourceColumns: [{ name: "id", caseInsensitive: true }],
|
|
77
|
+
};
|
|
78
|
+
const unsupportedResult = EntityJsonSchema.safeParse(unsupportedSourceType);
|
|
79
|
+
expect(unsupportedResult.success).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("schema 단계에서는 searchText json source id naming을 강제하지 않아야 한다", () => {
|
|
83
|
+
const nullableWrapper = createBaseEntity();
|
|
84
|
+
nullableWrapper.props[3] = { name: "aliases", type: "json", id: "NullableStringArray" };
|
|
85
|
+
nullableWrapper.props[4] = {
|
|
86
|
+
name: "search_text",
|
|
87
|
+
type: "searchText",
|
|
88
|
+
sourceColumns: [{ name: "aliases", caseInsensitive: true }],
|
|
89
|
+
};
|
|
90
|
+
const wrapperResult = EntityJsonSchema.safeParse(nullableWrapper);
|
|
91
|
+
expect(wrapperResult.success).toBe(true);
|
|
92
|
+
|
|
93
|
+
const nullableElement = createBaseEntity();
|
|
94
|
+
nullableElement.props[3] = { name: "aliases", type: "json", id: "StringNullableArray" };
|
|
95
|
+
nullableElement.props[4] = {
|
|
96
|
+
name: "search_text",
|
|
97
|
+
type: "searchText",
|
|
98
|
+
sourceColumns: [{ name: "aliases", caseInsensitive: true }],
|
|
99
|
+
};
|
|
100
|
+
const nullableElementResult = EntityJsonSchema.safeParse(nullableElement);
|
|
101
|
+
expect(nullableElementResult.success).toBe(true);
|
|
102
|
+
|
|
103
|
+
const customNamedType = createBaseEntity();
|
|
104
|
+
customNamedType.props[3] = { name: "aliases", type: "json", id: "CustomAliasType" };
|
|
105
|
+
customNamedType.props[4] = {
|
|
106
|
+
name: "search_text",
|
|
107
|
+
type: "searchText",
|
|
108
|
+
sourceColumns: [{ name: "aliases", caseInsensitive: true }],
|
|
109
|
+
};
|
|
110
|
+
const customNamedTypeResult = EntityJsonSchema.safeParse(customNamedType);
|
|
111
|
+
expect(customNamedTypeResult.success).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("TemplateOptions.entity 경로도 searchText source 검증을 동일하게 적용해야 한다", () => {
|
|
115
|
+
const validEntity = createBaseEntity();
|
|
116
|
+
const validTemplateEntity = {
|
|
117
|
+
entityId: validEntity.id,
|
|
118
|
+
title: validEntity.title,
|
|
119
|
+
table: validEntity.table,
|
|
120
|
+
props: validEntity.props,
|
|
121
|
+
indexes: validEntity.indexes,
|
|
122
|
+
subsets: validEntity.subsets,
|
|
123
|
+
enums: validEntity.enums,
|
|
124
|
+
};
|
|
125
|
+
const validResult = TemplateOptions.shape.entity.safeParse(validTemplateEntity);
|
|
126
|
+
expect(validResult.success).toBe(true);
|
|
127
|
+
|
|
128
|
+
const invalidEntity = createBaseEntity();
|
|
129
|
+
invalidEntity.props[4] = {
|
|
130
|
+
name: "search_text",
|
|
131
|
+
type: "searchText",
|
|
132
|
+
sourceColumns: [{ name: "missing_col", caseInsensitive: true }],
|
|
133
|
+
};
|
|
134
|
+
const invalidTemplateEntity = {
|
|
135
|
+
entityId: invalidEntity.id,
|
|
136
|
+
title: invalidEntity.title,
|
|
137
|
+
table: invalidEntity.table,
|
|
138
|
+
props: invalidEntity.props,
|
|
139
|
+
indexes: invalidEntity.indexes,
|
|
140
|
+
subsets: invalidEntity.subsets,
|
|
141
|
+
enums: invalidEntity.enums,
|
|
142
|
+
};
|
|
143
|
+
const invalidResult = TemplateOptions.shape.entity.safeParse(invalidTemplateEntity);
|
|
144
|
+
expect(invalidResult.success).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("TemplateOptions.entity 경로도 json id naming에 의존하지 않아야 한다", () => {
|
|
148
|
+
const customNamedType = createBaseEntity();
|
|
149
|
+
customNamedType.props[3] = { name: "aliases", type: "json", id: "CustomAliasType" };
|
|
150
|
+
customNamedType.props[4] = {
|
|
151
|
+
name: "search_text",
|
|
152
|
+
type: "searchText",
|
|
153
|
+
sourceColumns: [{ name: "aliases", caseInsensitive: true }],
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const customTemplateEntity = {
|
|
157
|
+
entityId: customNamedType.id,
|
|
158
|
+
title: customNamedType.title,
|
|
159
|
+
table: customNamedType.table,
|
|
160
|
+
props: customNamedType.props,
|
|
161
|
+
indexes: customNamedType.indexes,
|
|
162
|
+
subsets: customNamedType.subsets,
|
|
163
|
+
enums: customNamedType.enums,
|
|
164
|
+
};
|
|
165
|
+
const customTemplateResult = TemplateOptions.shape.entity.safeParse(customTemplateEntity);
|
|
166
|
+
expect(customTemplateResult.success).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("searchText json source Zod 구조 검증 유틸이 wrapper/element 타입을 정확히 판정해야 한다", () => {
|
|
170
|
+
expect(isSearchTextJsonSourceZodType(z.array(z.string()))).toBe(true);
|
|
171
|
+
expect(isSearchTextJsonSourceZodType(z.array(z.string()).optional())).toBe(true);
|
|
172
|
+
expect(isSearchTextJsonSourceZodType(z.array(z.string()).nullable())).toBe(true);
|
|
173
|
+
expect(isSearchTextJsonSourceZodType(z.array(z.string()).nullish())).toBe(true);
|
|
174
|
+
|
|
175
|
+
expect(isSearchTextJsonSourceZodType(z.array(z.string().nullable()))).toBe(false);
|
|
176
|
+
expect(isSearchTextJsonSourceZodType(z.array(z.number()))).toBe(false);
|
|
177
|
+
expect(isSearchTextJsonSourceZodType(z.string())).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
});
|