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.
@@ -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
- ): string | undefined {
209
- const locale = getCurrentLocale();
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 keys = [
213
- localizedKey(column, locale),
214
- column,
215
- localizedKey(column, DEFAULT_LOCALE),
216
- ...otherLocales.map((l) => localizedKey(column, l)),
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 key of keys) {
220
- if (!(key in row)) {
221
- continue;
276
+ for (const { value, source } of values) {
277
+ if (!isEmptyLocalizedColumnValue(value) && isLocalizedColumnValue(value)) {
278
+ return value as LocalizedColumnReturn<T, K>;
222
279
  }
223
-
224
- if (row[key] !== null && row[key] !== undefined && row[key] !== "") {
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] = {
@@ -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. subsets이 지정되지 않으면 `{ "A": ["id"] }`로 반환합니다.
340
- 6. relation 필드명은 `_id` 접미어 대신 관련 엔티티를 나타내는 이름을 사용합니다. (예: "user", "author", "category")
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": {
@@ -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