sonamu 0.9.13 → 0.9.15
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/database/puri.d.ts +13 -1
- package/dist/database/puri.d.ts.map +1 -1
- package/dist/database/puri.js +47 -5
- package/dist/entity/entity-manager.d.ts +1 -0
- package/dist/entity/entity-manager.d.ts.map +1 -1
- package/dist/migration/code-generation.d.ts.map +1 -1
- package/dist/migration/code-generation.js +74 -8
- package/dist/migration/postgresql-schema-reader.d.ts +1 -0
- package/dist/migration/postgresql-schema-reader.d.ts.map +1 -1
- package/dist/migration/postgresql-schema-reader.js +4 -2
- package/dist/template/implementations/sd.template.d.ts.map +1 -1
- package/dist/template/implementations/sd.template.js +70 -14
- package/dist/types/types.d.ts +6 -0
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/types.js +2 -1
- package/dist/utils/utils.d.ts +1 -1
- package/dist/utils/utils.d.ts.map +1 -1
- package/dist/utils/utils.js +1 -1
- package/package.json +2 -2
- package/src/database/puri.ts +92 -7
- package/src/database/puri.types.test-d.ts +70 -0
- package/src/migration/__tests__/code-generation.search-text.test.ts +146 -1
- package/src/migration/code-generation.ts +85 -6
- package/src/migration/postgresql-schema-reader.ts +4 -1
- package/src/skills/sonamu/i18n.md +9 -1
- package/src/template/implementations/sd.template.ts +69 -13
- package/src/types/__tests__/entity-json-schema-search-text.test.ts +39 -0
- package/src/types/types.ts +5 -0
- package/src/ui/entity.instructions.md +7 -3
- package/src/utils/utils.ts +1 -1
|
@@ -202,27 +202,83 @@ type LocalizedBaseColumn<T> = {
|
|
|
202
202
|
* @example
|
|
203
203
|
* localizedColumn(tag, "name")
|
|
204
204
|
*/
|
|
205
|
+
type LocalizedColumnScalarValue = string | number | boolean | bigint;
|
|
206
|
+
type LocalizedColumnValue = string | string[];
|
|
207
|
+
type NestedLocalizedColumnValueFrom<V> = V extends string
|
|
208
|
+
? string
|
|
209
|
+
: V extends readonly string[]
|
|
210
|
+
? string[]
|
|
211
|
+
: never;
|
|
212
|
+
type LocalizedColumnValueFrom<V> = V extends string
|
|
213
|
+
? string
|
|
214
|
+
: V extends readonly string[]
|
|
215
|
+
? string[]
|
|
216
|
+
: V extends number | boolean | bigint
|
|
217
|
+
? string
|
|
218
|
+
: V extends Partial<Record<(typeof SUPPORTED_LOCALES)[number], infer LV>>
|
|
219
|
+
? NestedLocalizedColumnValueFrom<LV>
|
|
220
|
+
: never;
|
|
221
|
+
type LocalizedColumnCandidate<T, K extends string> =
|
|
222
|
+
| (K extends keyof T ? T[K] : never)
|
|
223
|
+
| {
|
|
224
|
+
[L in (typeof SUPPORTED_LOCALES)[number]]: \`\${K}_\${L}\` extends keyof T ? T[\`\${K}_\${L}\`] : never;
|
|
225
|
+
}[(typeof SUPPORTED_LOCALES)[number]];
|
|
226
|
+
type LocalizedColumnReturn<T, K extends string> =
|
|
227
|
+
| LocalizedColumnValueFrom<LocalizedColumnCandidate<T, K>>
|
|
228
|
+
| undefined;
|
|
229
|
+
type LocaleValueMap = Partial<Record<(typeof SUPPORTED_LOCALES)[number], unknown>>;
|
|
230
|
+
|
|
231
|
+
function isLocalizedColumnValue(value: unknown): value is LocalizedColumnValue {
|
|
232
|
+
return typeof value === "string" || (Array.isArray(value) && value.every((item) => typeof item === "string"));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function isLocalizedColumnScalarValue(value: unknown): value is LocalizedColumnScalarValue {
|
|
236
|
+
return ["string", "number", "boolean", "bigint"].includes(typeof value);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isEmptyLocalizedColumnValue(value: unknown): value is null | undefined | "" {
|
|
240
|
+
return value === null || value === undefined || value === "";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function getNestedLocaleValue<T extends Record<string, unknown>, K extends LocalizedBaseColumn<T>>(
|
|
244
|
+
row: T,
|
|
245
|
+
column: K,
|
|
246
|
+
locale: (typeof SUPPORTED_LOCALES)[number],
|
|
247
|
+
): unknown {
|
|
248
|
+
const columnValue = row[column];
|
|
249
|
+
if (columnValue === null || typeof columnValue !== "object" || Array.isArray(columnValue)) {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return (columnValue as LocaleValueMap)[locale];
|
|
254
|
+
}
|
|
255
|
+
|
|
205
256
|
export function localizedColumn<T extends Record<string, unknown>, K extends LocalizedBaseColumn<T>>(
|
|
206
257
|
row: T,
|
|
207
258
|
column: K,
|
|
208
|
-
):
|
|
209
|
-
const
|
|
259
|
+
): LocalizedColumnReturn<T, K> {
|
|
260
|
+
const currentLocale = getCurrentLocale();
|
|
261
|
+
const locale = SUPPORTED_LOCALES.includes(currentLocale) ? currentLocale : DEFAULT_LOCALE;
|
|
210
262
|
const otherLocales = SUPPORTED_LOCALES.filter((l: string) => l !== locale && l !== DEFAULT_LOCALE);
|
|
211
263
|
const localizedKey = (column: K, locale: (typeof SUPPORTED_LOCALES)[number]) => \`\${column}_\${locale}\`;
|
|
212
|
-
const
|
|
213
|
-
localizedKey(column, locale),
|
|
214
|
-
column,
|
|
215
|
-
|
|
216
|
-
|
|
264
|
+
const values = [
|
|
265
|
+
{ value: row[localizedKey(column, locale)], source: "direct" },
|
|
266
|
+
{ value: getNestedLocaleValue(row, column, locale), source: "nested" },
|
|
267
|
+
{ value: row[column], source: "direct" },
|
|
268
|
+
{ value: row[localizedKey(column, DEFAULT_LOCALE)], source: "direct" },
|
|
269
|
+
{ value: getNestedLocaleValue(row, column, DEFAULT_LOCALE), source: "nested" },
|
|
270
|
+
...otherLocales.flatMap((l) => [
|
|
271
|
+
{ value: row[localizedKey(column, l)], source: "direct" },
|
|
272
|
+
{ value: getNestedLocaleValue(row, column, l), source: "nested" },
|
|
273
|
+
]),
|
|
217
274
|
];
|
|
218
275
|
|
|
219
|
-
for (const
|
|
220
|
-
if (!(
|
|
221
|
-
|
|
276
|
+
for (const { value, source } of values) {
|
|
277
|
+
if (!isEmptyLocalizedColumnValue(value) && isLocalizedColumnValue(value)) {
|
|
278
|
+
return value as LocalizedColumnReturn<T, K>;
|
|
222
279
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
return String(row[key]);
|
|
280
|
+
if (source === "direct" && !isEmptyLocalizedColumnValue(value) && isLocalizedColumnScalarValue(value)) {
|
|
281
|
+
return String(value) as LocalizedColumnReturn<T, K>;
|
|
226
282
|
}
|
|
227
283
|
}
|
|
228
284
|
|
|
@@ -60,6 +60,45 @@ describe("EntityJsonSchema searchText/opclass validation", () => {
|
|
|
60
60
|
expect(legacyResult.success).toBe(true);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
test("index where predicate는 raw SQL 문자열로 허용하되 빈 문자열은 거부해야 한다", () => {
|
|
64
|
+
const baseEntity = createBaseEntity();
|
|
65
|
+
const partialIndex = {
|
|
66
|
+
...baseEntity,
|
|
67
|
+
indexes: [
|
|
68
|
+
{
|
|
69
|
+
...baseEntity.indexes[0],
|
|
70
|
+
where: "deleted_at IS NULL",
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
const partialResult = EntityJsonSchema.safeParse(partialIndex);
|
|
75
|
+
expect(partialResult.success).toBe(true);
|
|
76
|
+
|
|
77
|
+
const emptyPredicate = {
|
|
78
|
+
...baseEntity,
|
|
79
|
+
indexes: [
|
|
80
|
+
{
|
|
81
|
+
...baseEntity.indexes[0],
|
|
82
|
+
where: "",
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
const emptyResult = EntityJsonSchema.safeParse(emptyPredicate);
|
|
87
|
+
expect(emptyResult.success).toBe(false);
|
|
88
|
+
|
|
89
|
+
const blankPredicate = {
|
|
90
|
+
...baseEntity,
|
|
91
|
+
indexes: [
|
|
92
|
+
{
|
|
93
|
+
...baseEntity.indexes[0],
|
|
94
|
+
where: " ",
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
const blankResult = EntityJsonSchema.safeParse(blankPredicate);
|
|
99
|
+
expect(blankResult.success).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
63
102
|
test("searchText source column 존재/타입 검증이 동작해야 한다", () => {
|
|
64
103
|
const unknownSource = createBaseEntity();
|
|
65
104
|
unknownSource.props[4] = {
|
package/src/types/types.ts
CHANGED
|
@@ -410,6 +410,8 @@ export type EntityIndex = {
|
|
|
410
410
|
name: string;
|
|
411
411
|
using?: "btree" | "hash" | "gin" | "gist" | "pgroonga";
|
|
412
412
|
nullsNotDistinct?: boolean; // unique index only
|
|
413
|
+
/** PostgreSQL partial index predicate. Raw SQL WHERE expression without the WHERE keyword. */
|
|
414
|
+
where?: string;
|
|
413
415
|
/**
|
|
414
416
|
* HNSW (Hierarchical Navigable Small World) 인덱스: 각 노드의 최대 연결 수
|
|
415
417
|
*
|
|
@@ -832,6 +834,8 @@ export type MigrationIndex = {
|
|
|
832
834
|
name: string;
|
|
833
835
|
using?: "btree" | "hash" | "gin" | "gist" | "pgroonga";
|
|
834
836
|
nullsNotDistinct?: boolean;
|
|
837
|
+
/** PostgreSQL partial index predicate. Raw SQL WHERE expression without the WHERE keyword. */
|
|
838
|
+
where?: string;
|
|
835
839
|
/** HNSW (Hierarchical Navigable Small World): 각 노드의 최대 연결 수 */
|
|
836
840
|
m?: number;
|
|
837
841
|
/** HNSW (Hierarchical Navigable Small World): 구성 시 탐색 범위 */
|
|
@@ -1495,6 +1499,7 @@ const EntityIndexSchema = z
|
|
|
1495
1499
|
name: z.string().min(1).max(63),
|
|
1496
1500
|
using: z.enum(["btree", "hash", "gin", "gist", "pgroonga"]).optional(),
|
|
1497
1501
|
nullsNotDistinct: z.boolean().optional(),
|
|
1502
|
+
where: z.string().trim().min(1).optional(),
|
|
1498
1503
|
m: z.number().optional(),
|
|
1499
1504
|
efConstruction: z.number().optional(),
|
|
1500
1505
|
lists: z.number().optional(),
|
|
@@ -336,8 +336,11 @@
|
|
|
336
336
|
2. 모든 엔티티는 `EntityNameOrderBy`, `EntityNameSearchField` Enum을 필수로 포함해야 합니다.
|
|
337
337
|
3. 엔티티 내에서 사용하는 Enum ID는 엔티티 이름을 접두어로 사용합니다. (예: UserStatus, ProductType)
|
|
338
338
|
4. indexes가 지정되지 않으면 빈 배열로 반환합니다.
|
|
339
|
-
5.
|
|
340
|
-
|
|
339
|
+
5. partial index가 필요하면 index에 `where`를 raw SQL predicate로 지정합니다. (예:
|
|
340
|
+
`"where": "deleted_at IS NULL"`)
|
|
341
|
+
6. `where`는 PostgreSQL partial index predicate로 그대로 사용되므로 사용자 입력을 조합하지 않습니다.
|
|
342
|
+
7. subsets이 지정되지 않으면 `{ "A": ["id"] }`로 반환합니다.
|
|
343
|
+
8. relation 필드명은 `_id` 접미어 대신 관련 엔티티를 나타내는 이름을 사용합니다. (예: "user", "author", "category")
|
|
341
344
|
|
|
342
345
|
### Property Rules
|
|
343
346
|
|
|
@@ -511,7 +514,8 @@
|
|
|
511
514
|
{
|
|
512
515
|
"name": "products_status_index",
|
|
513
516
|
"type": "index",
|
|
514
|
-
"columns": [{ "name": "status" }]
|
|
517
|
+
"columns": [{ "name": "status" }],
|
|
518
|
+
"where": "status = 'active'"
|
|
515
519
|
}
|
|
516
520
|
],
|
|
517
521
|
"subsets": {
|
package/src/utils/utils.ts
CHANGED
|
@@ -46,7 +46,7 @@ export function nonNullable<T>(value: T): value is NonNullable<T> {
|
|
|
46
46
|
return value !== null && value !== undefined;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
export function exhaustive(_param: never) {
|
|
49
|
+
export function exhaustive(_param: never): never {
|
|
50
50
|
throw new Error(`exhaustive`);
|
|
51
51
|
}
|
|
52
52
|
|