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.
Files changed (96) hide show
  1. package/dist/api/sonamu.d.ts.map +1 -1
  2. package/dist/api/sonamu.js +2 -3
  3. package/dist/auth/auth-generator.d.ts +8 -0
  4. package/dist/auth/auth-generator.d.ts.map +1 -1
  5. package/dist/auth/auth-generator.js +33 -1
  6. package/dist/auth/better-auth-entities.d.ts.map +1 -1
  7. package/dist/auth/better-auth-entities.js +12 -2
  8. package/dist/bin/cli.js +18 -3
  9. package/dist/cone/cone-generator.js +10 -4
  10. package/dist/database/knex.d.ts.map +1 -1
  11. package/dist/database/knex.js +64 -2
  12. package/dist/database/puri.d.ts +9 -1
  13. package/dist/database/puri.d.ts.map +1 -1
  14. package/dist/database/puri.js +42 -1
  15. package/dist/database/puri.types.d.ts +2 -0
  16. package/dist/database/puri.types.d.ts.map +1 -1
  17. package/dist/database/puri.types.js +6 -2
  18. package/dist/entity/entity-manager.d.ts +149 -1
  19. package/dist/entity/entity-manager.d.ts.map +1 -1
  20. package/dist/entity/entity-manager.js +68 -4
  21. package/dist/migration/__tests__/code-generation.search-text.test.js +435 -0
  22. package/dist/migration/code-generation.d.ts.map +1 -1
  23. package/dist/migration/code-generation.js +696 -32
  24. package/dist/migration/migration-set.js +3 -1
  25. package/dist/migration/postgresql-schema-reader.d.ts +16 -2
  26. package/dist/migration/postgresql-schema-reader.d.ts.map +1 -1
  27. package/dist/migration/postgresql-schema-reader.js +281 -7
  28. package/dist/stream/sse.js +5 -3
  29. package/dist/template/__tests__/generated.template.search-text.test.js +99 -0
  30. package/dist/template/generated.template.test-d.js +24 -0
  31. package/dist/template/implementations/generated.template.d.ts.map +1 -1
  32. package/dist/template/implementations/generated.template.js +2 -2
  33. package/dist/template/implementations/init_types.template.d.ts.map +1 -1
  34. package/dist/template/implementations/init_types.template.js +11 -3
  35. package/dist/template/zod-converter.d.ts.map +1 -1
  36. package/dist/template/zod-converter.js +6 -2
  37. package/dist/testing/dev-test-routes.d.ts.map +1 -1
  38. package/dist/testing/dev-test-routes.js +5 -3
  39. package/dist/testing/fixture-generator.d.ts +13 -0
  40. package/dist/testing/fixture-generator.d.ts.map +1 -1
  41. package/dist/testing/fixture-generator.js +105 -8
  42. package/dist/testing/fixture-manager.d.ts.map +1 -1
  43. package/dist/testing/fixture-manager.js +19 -2
  44. package/dist/types/__tests__/entity-json-schema-search-text.test.js +256 -0
  45. package/dist/types/types.d.ts +494 -1
  46. package/dist/types/types.d.ts.map +1 -1
  47. package/dist/types/types.js +117 -13
  48. package/dist/ui/api.d.ts.map +1 -1
  49. package/dist/ui/api.js +14 -2
  50. package/dist/ui/cdd-service.d.ts +16 -14
  51. package/dist/ui/cdd-service.d.ts.map +1 -1
  52. package/dist/ui/cdd-service.js +145 -37
  53. package/dist/ui/cdd-types.d.ts +60 -0
  54. package/dist/ui/cdd-types.d.ts.map +1 -0
  55. package/dist/ui/cdd-types.js +3 -0
  56. package/dist/ui-web/assets/index-D4XFBV-f.css +1 -0
  57. package/dist/ui-web/assets/{index-CQ_S40bD.js → index-D_19-Pi4.js} +87 -87
  58. package/dist/ui-web/index.html +2 -2
  59. package/package.json +7 -3
  60. package/src/api/sonamu.ts +1 -2
  61. package/src/auth/auth-generator.ts +38 -0
  62. package/src/auth/better-auth-entities.ts +18 -1
  63. package/src/bin/cli.ts +15 -1
  64. package/src/cone/cone-generator.ts +9 -3
  65. package/src/database/knex.ts +62 -4
  66. package/src/database/puri.ts +71 -0
  67. package/src/database/puri.types.ts +2 -0
  68. package/src/entity/entity-manager.ts +95 -3
  69. package/src/migration/__tests__/code-generation.search-text.test.ts +390 -0
  70. package/src/migration/code-generation.ts +848 -34
  71. package/src/migration/migration-set.ts +2 -0
  72. package/src/migration/postgresql-schema-reader.ts +366 -9
  73. package/src/skills/sonamu/auth-migration.md +80 -0
  74. package/src/skills/sonamu/cdd.md +148 -28
  75. package/src/skills/sonamu/cone.md +16 -0
  76. package/src/skills/sonamu/entity-relations.md +1 -1
  77. package/src/skills/sonamu/fixture-cli.md +4 -0
  78. package/src/skills/sonamu/frontend.md +65 -0
  79. package/src/skills/sonamu/migration.md +3 -1
  80. package/src/skills/sonamu/model.md +28 -0
  81. package/src/skills/sonamu/workflow.md +12 -5
  82. package/src/stream/sse.ts +4 -2
  83. package/src/template/__tests__/generated.template.search-text.test.ts +89 -0
  84. package/src/template/generated.template.test-d.ts +46 -0
  85. package/src/template/implementations/generated.template.ts +4 -1
  86. package/src/template/implementations/init_types.template.ts +20 -5
  87. package/src/template/zod-converter.ts +5 -0
  88. package/src/testing/dev-test-routes.ts +4 -2
  89. package/src/testing/fixture-generator.ts +157 -9
  90. package/src/testing/fixture-manager.ts +15 -1
  91. package/src/types/__tests__/entity-json-schema-search-text.test.ts +179 -0
  92. package/src/types/types.ts +168 -12
  93. package/src/ui/api.ts +24 -1
  94. package/src/ui/cdd-service.ts +195 -55
  95. package/src/ui/cdd-types.ts +73 -0
  96. package/dist/ui-web/assets/index-egkMxKos.css +0 -1
@@ -10,6 +10,7 @@ import type {
10
10
  MigrationIndex,
11
11
  MigrationSet,
12
12
  } from "../types/types";
13
+ import { isSearchTextProp } from "../types/types";
13
14
  import { formatCode } from "../utils/formatter";
14
15
  import { differenceWith, intersectionBy } from "../utils/utils";
15
16
  import { PostgreSQLSchemaReader } from "./postgresql-schema-reader";
@@ -24,6 +25,718 @@ type ColumnDefinitionResult = {
24
25
  raw: string[];
25
26
  };
26
27
 
28
+ type SearchTextHelperKind = "text-array" | "jsonb-array";
29
+
30
+ type SearchTextExpressionToken =
31
+ | { type: "identifier"; value: string }
32
+ | { type: "quotedIdentifier"; value: string }
33
+ | { type: "string"; value: string }
34
+ | { type: "symbol"; value: "(" | ")" | "," }
35
+ | { type: "operator"; value: "||" | "::" };
36
+
37
+ type SearchTextExpressionNode =
38
+ | { type: "identifier"; name: string; quoted: boolean }
39
+ | { type: "string"; value: string }
40
+ | { type: "boolean"; value: boolean }
41
+ | { type: "function"; name: string; args: SearchTextExpressionNode[] }
42
+ | { type: "concat"; parts: SearchTextExpressionNode[] }
43
+ | { type: "collate"; expr: SearchTextExpressionNode; collation: string; quoted: boolean }
44
+ | { type: "cast"; expr: SearchTextExpressionNode; targetType: string };
45
+
46
+ const SEARCH_TEXT_HELPER_DEFINITIONS: Record<SearchTextHelperKind, string> = {
47
+ "text-array": `await knex.raw(\`CREATE OR REPLACE FUNCTION sonamu_text_array_agg(arr text[], ci boolean DEFAULT true)
48
+ RETURNS text
49
+ LANGUAGE sql IMMUTABLE PARALLEL SAFE RETURNS NULL ON NULL INPUT
50
+ AS $$
51
+ SELECT string_agg(
52
+ CASE WHEN ci THEN lower(value) ELSE value END,
53
+ ' '
54
+ )
55
+ FROM unnest(arr) AS value
56
+ $$\`);`,
57
+ "jsonb-array": `await knex.raw(\`CREATE OR REPLACE FUNCTION sonamu_jsonb_array_agg(arr jsonb, ci boolean DEFAULT true)
58
+ RETURNS text
59
+ LANGUAGE sql IMMUTABLE PARALLEL SAFE RETURNS NULL ON NULL INPUT
60
+ AS $$
61
+ SELECT string_agg(
62
+ CASE WHEN ci THEN lower(value) ELSE value END,
63
+ ' '
64
+ )
65
+ FROM jsonb_array_elements_text(arr)
66
+ $$\`);`,
67
+ };
68
+
69
+ class SearchTextExpressionParser {
70
+ private index = 0;
71
+
72
+ constructor(private readonly tokens: SearchTextExpressionToken[]) {}
73
+
74
+ isAtEnd(): boolean {
75
+ return this.index >= this.tokens.length;
76
+ }
77
+
78
+ parseExpression(): SearchTextExpressionNode {
79
+ return this.parseConcat();
80
+ }
81
+
82
+ private parseConcat(): SearchTextExpressionNode {
83
+ const parts = [this.parsePostfix()];
84
+
85
+ while (this.matchOperator("||")) {
86
+ parts.push(this.parsePostfix());
87
+ }
88
+
89
+ return parts.length === 1 ? parts[0] : { type: "concat", parts };
90
+ }
91
+
92
+ private parsePostfix(): SearchTextExpressionNode {
93
+ let node = this.parsePrimary();
94
+
95
+ while (true) {
96
+ if (this.matchOperator("::")) {
97
+ node = {
98
+ type: "cast",
99
+ expr: node,
100
+ targetType: this.parseTypeName(),
101
+ };
102
+ continue;
103
+ }
104
+
105
+ if (this.matchIdentifier("collate")) {
106
+ const token = this.consumeCollationToken();
107
+ node = {
108
+ type: "collate",
109
+ expr: node,
110
+ collation: token.value,
111
+ quoted: token.type === "quotedIdentifier",
112
+ };
113
+ continue;
114
+ }
115
+
116
+ break;
117
+ }
118
+
119
+ return node;
120
+ }
121
+
122
+ private parsePrimary(): SearchTextExpressionNode {
123
+ const token = this.consumeToken("표현식");
124
+
125
+ if (token.type === "symbol" && token.value === "(") {
126
+ const node = this.parseExpression();
127
+ this.expectSymbol(")");
128
+ return node;
129
+ }
130
+
131
+ if (token.type === "string") {
132
+ return { type: "string", value: token.value };
133
+ }
134
+
135
+ if (token.type === "identifier" || token.type === "quotedIdentifier") {
136
+ const lowerName = token.value.toLowerCase();
137
+ if (token.type === "identifier" && (lowerName === "true" || lowerName === "false")) {
138
+ return { type: "boolean", value: lowerName === "true" };
139
+ }
140
+
141
+ if (this.matchSymbol("(")) {
142
+ if (token.type === "identifier" && lowerName === "trim" && this.isTrimBothFromForm()) {
143
+ this.index += 2;
144
+ const arg = this.parseExpression();
145
+ this.expectSymbol(")");
146
+ return { type: "function", name: "trim", args: [arg] };
147
+ }
148
+
149
+ const args = this.parseFunctionArgs();
150
+ return {
151
+ type: "function",
152
+ name: token.value,
153
+ args,
154
+ };
155
+ }
156
+
157
+ return {
158
+ type: "identifier",
159
+ name: token.value,
160
+ quoted: token.type === "quotedIdentifier",
161
+ };
162
+ }
163
+
164
+ throw new Error(`지원되지 않는 searchText expression token: ${token.type}`);
165
+ }
166
+
167
+ private parseFunctionArgs(): SearchTextExpressionNode[] {
168
+ if (this.matchSymbol(")")) {
169
+ return [];
170
+ }
171
+
172
+ const args: SearchTextExpressionNode[] = [];
173
+ do {
174
+ args.push(this.parseExpression());
175
+ } while (this.matchSymbol(","));
176
+
177
+ this.expectSymbol(")");
178
+ return args;
179
+ }
180
+
181
+ private parseTypeName(): string {
182
+ const parts: string[] = [];
183
+
184
+ while (true) {
185
+ const token = this.peek();
186
+ if (
187
+ token?.type === "identifier" ||
188
+ token?.type === "quotedIdentifier" ||
189
+ (token?.type === "symbol" &&
190
+ (token.value === "(" || token.value === ")" || token.value === ","))
191
+ ) {
192
+ if (token.type === "symbol") {
193
+ break;
194
+ }
195
+
196
+ parts.push(token.value.toLowerCase());
197
+ this.index += 1;
198
+ continue;
199
+ }
200
+
201
+ break;
202
+ }
203
+
204
+ if (parts.length === 0) {
205
+ throw new Error("타입 캐스팅 대상 타입을 찾을 수 없습니다.");
206
+ }
207
+
208
+ return parts.join(" ");
209
+ }
210
+
211
+ private consumeCollationToken(): Extract<
212
+ SearchTextExpressionToken,
213
+ { type: "identifier" | "quotedIdentifier" }
214
+ > {
215
+ const token = this.peek();
216
+ if (token?.type !== "identifier" && token?.type !== "quotedIdentifier") {
217
+ throw new Error("COLLATE 대상 식별자를 찾을 수 없습니다.");
218
+ }
219
+ this.index += 1;
220
+ return token;
221
+ }
222
+
223
+ private isTrimBothFromForm(): boolean {
224
+ const bothToken = this.peek();
225
+ const fromToken = this.peek(1);
226
+
227
+ return (
228
+ bothToken?.type === "identifier" &&
229
+ bothToken.value.toLowerCase() === "both" &&
230
+ fromToken?.type === "identifier" &&
231
+ fromToken.value.toLowerCase() === "from"
232
+ );
233
+ }
234
+
235
+ private expectSymbol(value: "(" | ")" | ","): void {
236
+ if (!this.matchSymbol(value)) {
237
+ throw new Error(`"${value}" 토큰이 필요합니다.`);
238
+ }
239
+ }
240
+
241
+ private matchSymbol(value: "(" | ")" | ","): boolean {
242
+ const token = this.peek();
243
+ if (token?.type === "symbol" && token.value === value) {
244
+ this.index += 1;
245
+ return true;
246
+ }
247
+ return false;
248
+ }
249
+
250
+ private matchOperator(value: "||" | "::"): boolean {
251
+ const token = this.peek();
252
+ if (token?.type === "operator" && token.value === value) {
253
+ this.index += 1;
254
+ return true;
255
+ }
256
+ return false;
257
+ }
258
+
259
+ private matchIdentifier(value: string): boolean {
260
+ const token = this.peek();
261
+ if (token?.type === "identifier" && token.value.toLowerCase() === value.toLowerCase()) {
262
+ this.index += 1;
263
+ return true;
264
+ }
265
+ return false;
266
+ }
267
+
268
+ private consumeToken(context: string): SearchTextExpressionToken {
269
+ const token = this.peek();
270
+ if (!token) {
271
+ throw new Error(`${context} 토큰이 필요합니다.`);
272
+ }
273
+ this.index += 1;
274
+ return token;
275
+ }
276
+
277
+ private peek(offset = 0): SearchTextExpressionToken | undefined {
278
+ return this.tokens[this.index + offset];
279
+ }
280
+ }
281
+
282
+ function getIndexColumnOpclass(column: MigrationIndex["columns"][number]): string | undefined {
283
+ return column.opclass ?? column.vectorOps;
284
+ }
285
+
286
+ function tokenizeSearchTextExpression(expression: string): SearchTextExpressionToken[] {
287
+ const tokens: SearchTextExpressionToken[] = [];
288
+ let index = 0;
289
+
290
+ while (index < expression.length) {
291
+ const char = expression[index];
292
+
293
+ if (char === undefined) {
294
+ break;
295
+ }
296
+
297
+ if (/\s/.test(char)) {
298
+ index += 1;
299
+ continue;
300
+ }
301
+
302
+ if (expression.startsWith("||", index)) {
303
+ tokens.push({ type: "operator", value: "||" });
304
+ index += 2;
305
+ continue;
306
+ }
307
+
308
+ if (expression.startsWith("::", index)) {
309
+ tokens.push({ type: "operator", value: "::" });
310
+ index += 2;
311
+ continue;
312
+ }
313
+
314
+ if (char === "(" || char === ")" || char === ",") {
315
+ tokens.push({ type: "symbol", value: char });
316
+ index += 1;
317
+ continue;
318
+ }
319
+
320
+ if (char === "'") {
321
+ let value = "";
322
+ index += 1;
323
+
324
+ while (index < expression.length) {
325
+ const current = expression[index];
326
+ if (current === "'") {
327
+ if (expression[index + 1] === "'") {
328
+ value += "'";
329
+ index += 2;
330
+ continue;
331
+ }
332
+
333
+ index += 1;
334
+ break;
335
+ }
336
+
337
+ if (current === undefined) {
338
+ break;
339
+ }
340
+
341
+ value += current;
342
+ index += 1;
343
+ }
344
+
345
+ tokens.push({ type: "string", value });
346
+ continue;
347
+ }
348
+
349
+ if (char === '"') {
350
+ let value = "";
351
+ index += 1;
352
+
353
+ while (index < expression.length) {
354
+ const current = expression[index];
355
+ if (current === '"') {
356
+ if (expression[index + 1] === '"') {
357
+ value += '"';
358
+ index += 2;
359
+ continue;
360
+ }
361
+
362
+ index += 1;
363
+ break;
364
+ }
365
+
366
+ if (current === undefined) {
367
+ break;
368
+ }
369
+
370
+ value += current;
371
+ index += 1;
372
+ }
373
+
374
+ tokens.push({ type: "quotedIdentifier", value });
375
+ continue;
376
+ }
377
+
378
+ if (/[A-Za-z_]/.test(char)) {
379
+ let value = char;
380
+ index += 1;
381
+
382
+ while (index < expression.length) {
383
+ const current = expression[index];
384
+ if (current !== undefined && /[A-Za-z0-9_$]/.test(current)) {
385
+ value += current;
386
+ index += 1;
387
+ continue;
388
+ }
389
+ break;
390
+ }
391
+
392
+ tokens.push({ type: "identifier", value });
393
+ continue;
394
+ }
395
+
396
+ throw new Error(`지원되지 않는 searchText expression 문자: ${char}`);
397
+ }
398
+
399
+ return tokens;
400
+ }
401
+
402
+ function canonicalizeSearchTextGeneratedExpression(expression: string): string {
403
+ try {
404
+ const parser = new SearchTextExpressionParser(tokenizeSearchTextExpression(expression));
405
+ const parsedExpression = parser.parseExpression();
406
+
407
+ if (!parser.isAtEnd()) {
408
+ throw new Error("searchText expression 파싱이 끝나지 않았습니다.");
409
+ }
410
+
411
+ return renderSearchTextExpression(normalizeSearchTextExpressionNode(parsedExpression));
412
+ } catch {
413
+ return normalizeSearchTextExpressionFallback(expression);
414
+ }
415
+ }
416
+
417
+ function normalizeSearchTextExpressionNode(
418
+ node: SearchTextExpressionNode,
419
+ ): SearchTextExpressionNode {
420
+ switch (node.type) {
421
+ case "identifier":
422
+ return {
423
+ ...node,
424
+ name: node.quoted ? node.name : node.name.toLowerCase(),
425
+ };
426
+ case "string":
427
+ case "boolean":
428
+ return node;
429
+ case "concat": {
430
+ const parts = node.parts.flatMap((part) => {
431
+ const normalizedPart = normalizeSearchTextExpressionNode(part);
432
+ return normalizedPart.type === "concat" ? normalizedPart.parts : [normalizedPart];
433
+ });
434
+ return { type: "concat", parts };
435
+ }
436
+ case "collate":
437
+ return {
438
+ type: "collate",
439
+ expr: normalizeSearchTextExpressionNode(node.expr),
440
+ collation: node.collation.toUpperCase() === "C" ? "C" : node.collation,
441
+ quoted: node.quoted || node.collation.toUpperCase() === "C",
442
+ };
443
+ case "cast": {
444
+ const normalizedExpr = normalizeSearchTextExpressionNode(node.expr);
445
+ const targetType = node.targetType.replace(/\s+/g, " ").trim().toLowerCase();
446
+ if (targetType === "text" || targetType === "character varying" || targetType === "varchar") {
447
+ return normalizedExpr;
448
+ }
449
+ return {
450
+ type: "cast",
451
+ expr: normalizedExpr,
452
+ targetType,
453
+ };
454
+ }
455
+ case "function": {
456
+ const name = node.name.toLowerCase();
457
+ let args = node.args.map((arg) => normalizeSearchTextExpressionNode(arg));
458
+
459
+ if ((name === "trim" || name === "btrim") && args.length === 1) {
460
+ return {
461
+ type: "function",
462
+ name: "trim",
463
+ args,
464
+ };
465
+ }
466
+
467
+ if (
468
+ (name === "sonamu_text_array_agg" || name === "sonamu_jsonb_array_agg") &&
469
+ args.length === 2 &&
470
+ args[1]?.type === "boolean" &&
471
+ args[1].value === true
472
+ ) {
473
+ args = [args[0]];
474
+ }
475
+
476
+ return {
477
+ type: "function",
478
+ name,
479
+ args,
480
+ };
481
+ }
482
+ }
483
+ }
484
+
485
+ function renderSearchTextExpression(node: SearchTextExpressionNode, parentPrecedence = 0): string {
486
+ const precedence = getSearchTextExpressionPrecedence(node);
487
+ const rendered = (() => {
488
+ switch (node.type) {
489
+ case "identifier":
490
+ return node.quoted ? `"${node.name.replaceAll('"', '""')}"` : node.name;
491
+ case "string":
492
+ return `'${node.value.replaceAll("'", "''")}'`;
493
+ case "boolean":
494
+ return node.value ? "true" : "false";
495
+ case "function":
496
+ return `${node.name}(${node.args
497
+ .map((arg) => renderSearchTextExpression(arg))
498
+ .join(", ")})`;
499
+ case "concat":
500
+ return node.parts.map((part) => renderSearchTextExpression(part, precedence)).join(" || ");
501
+ case "collate": {
502
+ const collation = node.quoted
503
+ ? `"${node.collation.replaceAll('"', '""')}"`
504
+ : node.collation;
505
+ return `${renderSearchTextExpression(node.expr, precedence)} COLLATE ${collation}`;
506
+ }
507
+ case "cast":
508
+ return `${renderSearchTextExpression(node.expr, precedence)}::${node.targetType}`;
509
+ }
510
+ })();
511
+
512
+ if (precedence < parentPrecedence) {
513
+ return `(${rendered})`;
514
+ }
515
+
516
+ return rendered;
517
+ }
518
+
519
+ function getSearchTextExpressionPrecedence(node: SearchTextExpressionNode): number {
520
+ switch (node.type) {
521
+ case "concat":
522
+ return 1;
523
+ case "collate":
524
+ case "cast":
525
+ return 2;
526
+ default:
527
+ return 3;
528
+ }
529
+ }
530
+
531
+ function normalizeSearchTextExpressionFallback(expression: string): string {
532
+ return expression
533
+ .replace(/\s+/g, " ")
534
+ .replace(/\bTRIM\s*\(\s*BOTH\s+FROM\s+/gi, "trim(")
535
+ .replace(/::(?:text|character varying|varchar)\b/gi, "")
536
+ .replace(/,\s*true\b/gi, "")
537
+ .trim();
538
+ }
539
+
540
+ function visitSearchTextExpressionNode(
541
+ node: SearchTextExpressionNode,
542
+ visitor: (node: SearchTextExpressionNode) => void,
543
+ ): void {
544
+ visitor(node);
545
+
546
+ switch (node.type) {
547
+ case "concat":
548
+ node.parts.forEach((part) => {
549
+ visitSearchTextExpressionNode(part, visitor);
550
+ });
551
+ return;
552
+ case "collate":
553
+ case "cast":
554
+ visitSearchTextExpressionNode(node.expr, visitor);
555
+ return;
556
+ case "function":
557
+ node.args.forEach((arg) => {
558
+ visitSearchTextExpressionNode(arg, visitor);
559
+ });
560
+ return;
561
+ case "identifier":
562
+ case "string":
563
+ case "boolean":
564
+ return;
565
+ }
566
+ }
567
+
568
+ function getSearchTextHelperKindsFromExpression(expression: string): Set<SearchTextHelperKind> {
569
+ const helperKinds = new Set<SearchTextHelperKind>();
570
+ const addHelperKindFromName = (name: string) => {
571
+ const normalizedName = name.toLowerCase();
572
+ if (normalizedName === "sonamu_text_array_agg") {
573
+ helperKinds.add("text-array");
574
+ } else if (normalizedName === "sonamu_jsonb_array_agg") {
575
+ helperKinds.add("jsonb-array");
576
+ }
577
+ };
578
+
579
+ try {
580
+ const parser = new SearchTextExpressionParser(tokenizeSearchTextExpression(expression));
581
+ const parsedExpression = parser.parseExpression();
582
+
583
+ if (!parser.isAtEnd()) {
584
+ throw new Error("searchText helper expression 파싱이 끝나지 않았습니다.");
585
+ }
586
+
587
+ visitSearchTextExpressionNode(parsedExpression, (node) => {
588
+ if (node.type === "function") {
589
+ addHelperKindFromName(node.name);
590
+ }
591
+ });
592
+ } catch {
593
+ if (/\bsonamu_text_array_agg\s*\(/i.test(expression)) {
594
+ helperKinds.add("text-array");
595
+ }
596
+ if (/\bsonamu_jsonb_array_agg\s*\(/i.test(expression)) {
597
+ helperKinds.add("jsonb-array");
598
+ }
599
+ }
600
+
601
+ return helperKinds;
602
+ }
603
+
604
+ function resolveSearchTextColumns(table: string, columns: MigrationColumn[]): MigrationColumn[] {
605
+ const entity = (() => {
606
+ try {
607
+ return EntityManager.getByTable(table);
608
+ } catch {
609
+ return null;
610
+ }
611
+ })();
612
+
613
+ if (!entity) {
614
+ return columns;
615
+ }
616
+
617
+ const propsByName = new Map(entity.props.map((prop) => [prop.name, prop]));
618
+
619
+ return columns.map((column) => {
620
+ const prop = propsByName.get(column.name);
621
+ if (!prop || !isSearchTextProp(prop)) {
622
+ return column;
623
+ }
624
+
625
+ return {
626
+ ...column,
627
+ generated: {
628
+ type: "STORED",
629
+ expression: buildSearchTextGeneratedExpression(prop, propsByName),
630
+ },
631
+ };
632
+ });
633
+ }
634
+
635
+ function buildSearchTextGeneratedExpression(
636
+ prop: Extract<EntityProp, { type: "searchText" }>,
637
+ propsByName: Map<string, EntityProp>,
638
+ ): string {
639
+ const tokens = prop.sourceColumns.map((source) => {
640
+ const sourceProp = propsByName.get(source.name);
641
+ if (!sourceProp) {
642
+ throw new Error(`searchText source column "${source.name}"을(를) 찾을 수 없습니다.`);
643
+ }
644
+
645
+ if (sourceProp.type === "string") {
646
+ return source.caseInsensitive
647
+ ? `lower(COALESCE(${source.name}, ''))`
648
+ : `COALESCE(${source.name}, '')`;
649
+ }
650
+
651
+ if (sourceProp.type === "string[]") {
652
+ return source.caseInsensitive
653
+ ? `COALESCE(sonamu_text_array_agg(${source.name}), '')`
654
+ : `COALESCE(sonamu_text_array_agg(${source.name}, false), '')`;
655
+ }
656
+
657
+ if (sourceProp.type === "json") {
658
+ return source.caseInsensitive
659
+ ? `COALESCE(sonamu_jsonb_array_agg(${source.name}), '')`
660
+ : `COALESCE(sonamu_jsonb_array_agg(${source.name}, false), '')`;
661
+ }
662
+
663
+ throw new Error(
664
+ `searchText source column "${source.name}"의 타입 "${sourceProp.type}"은(는) 지원되지 않습니다.`,
665
+ );
666
+ });
667
+
668
+ return `trim(${tokens.join(` || ' ' || `)})`;
669
+ }
670
+
671
+ function getSearchTextHelperDefinitions(table: string, columns: MigrationColumn[]): string[] {
672
+ const helperKinds = new Set<SearchTextHelperKind>();
673
+
674
+ columns.forEach((column) => {
675
+ if (!column.generated) {
676
+ return;
677
+ }
678
+
679
+ getSearchTextHelperKindsFromExpression(column.generated.expression).forEach((kind) => {
680
+ helperKinds.add(kind);
681
+ });
682
+ });
683
+
684
+ if (helperKinds.size > 0) {
685
+ return (["text-array", "jsonb-array"] as const)
686
+ .filter((kind) => helperKinds.has(kind))
687
+ .map((kind) => SEARCH_TEXT_HELPER_DEFINITIONS[kind]);
688
+ }
689
+
690
+ const entity = (() => {
691
+ try {
692
+ return EntityManager.getByTable(table);
693
+ } catch {
694
+ return null;
695
+ }
696
+ })();
697
+
698
+ if (!entity) {
699
+ return [];
700
+ }
701
+ const propsByName = new Map(entity.props.map((prop) => [prop.name, prop]));
702
+
703
+ columns.forEach((column) => {
704
+ const prop = propsByName.get(column.name);
705
+ if (!prop || !isSearchTextProp(prop)) {
706
+ return;
707
+ }
708
+
709
+ prop.sourceColumns.forEach((source) => {
710
+ const sourceProp = propsByName.get(source.name);
711
+ if (sourceProp?.type === "string[]") {
712
+ helperKinds.add("text-array");
713
+ } else if (sourceProp?.type === "json") {
714
+ helperKinds.add("jsonb-array");
715
+ }
716
+ });
717
+ });
718
+
719
+ return (["text-array", "jsonb-array"] as const)
720
+ .filter((kind) => helperKinds.has(kind))
721
+ .map((kind) => SEARCH_TEXT_HELPER_DEFINITIONS[kind]);
722
+ }
723
+
724
+ function getSearchTextColumnNames(table: string): Set<string> {
725
+ const entity = (() => {
726
+ try {
727
+ return EntityManager.getByTable(table);
728
+ } catch {
729
+ return null;
730
+ }
731
+ })();
732
+
733
+ if (!entity) {
734
+ return new Set();
735
+ }
736
+
737
+ return new Set(entity.props.filter(isSearchTextProp).map((prop) => prop.name));
738
+ }
739
+
27
740
  /**
28
741
  * 테이블 생성하는 케이스 - 컬럼/인덱스 생성
29
742
  */
@@ -32,13 +745,16 @@ async function generateCreateCode_ColumnAndIndexes(
32
745
  columns: MigrationColumn[],
33
746
  indexes: MigrationIndex[],
34
747
  ): Promise<GenMigrationCode> {
35
- const columnDefs = genColumnDefinitions(table, columns);
748
+ const resolvedColumns = resolveSearchTextColumns(table, columns);
749
+ const columnDefs = genColumnDefinitions(table, resolvedColumns);
750
+ const helperDefinitions = getSearchTextHelperDefinitions(table, resolvedColumns);
36
751
 
37
752
  // 컬럼, 인덱스 처리
38
753
  const lines: string[] = [
39
754
  'import { Knex } from "knex";',
40
755
  "",
41
756
  "export async function up(knex: Knex): Promise<void> {",
757
+ ...helperDefinitions,
42
758
  `await knex.schema.createTable("${table}", (table) => {`,
43
759
  ...columnDefs.builder,
44
760
  "});",
@@ -252,15 +968,20 @@ function genIndexDefinition(index: MigrationIndex, table: string): string {
252
968
  return `await knex.raw(
253
969
  \`CREATE ${methodMap[index.type]} ${index.name} ON ${table} ${usingClause}(${index.columns
254
970
  .map((col) => {
971
+ const opclassClause = (() => {
972
+ const opclass = getIndexColumnOpclass(col);
973
+ return opclass ? ` ${opclass}` : "";
974
+ })();
975
+
255
976
  // 정렬 옵션은 btree만 사용 가능
256
977
  if (index.using !== "btree" && index.using !== undefined) {
257
- return `${col.name}`;
978
+ return `${col.name}${opclassClause}`;
258
979
  }
259
980
 
260
981
  const sortOrderClause = col.sortOrder === undefined ? "" : ` ${col.sortOrder}`;
261
982
  const nullsFirstClause =
262
983
  col.nullsFirst === undefined ? "" : ` NULLS ${col.nullsFirst ? "FIRST" : "LAST"}`;
263
- return `${col.name}${sortOrderClause}${nullsFirstClause}`;
984
+ return `${col.name}${opclassClause}${sortOrderClause}${nullsFirstClause}`;
264
985
  })
265
986
  .join(", ")})${nullsNotDistinctClause};\`
266
987
  );`;
@@ -314,7 +1035,7 @@ function getPgroongaColumnOption(column: EntityProp) {
314
1035
  */
315
1036
  function genVectorIndexDefinition(index: MigrationIndex, table: string): string {
316
1037
  const column = index.columns[0];
317
- const vectorOps = column.vectorOps ?? "vector_cosine_ops";
1038
+ const vectorOps = getIndexColumnOpclass(column) ?? "vector_cosine_ops";
318
1039
 
319
1040
  // HNSW (Hierarchical Navigable Small World) - 권장: 빠른 검색, 높은 정확도
320
1041
  if (index.type === "hnsw") {
@@ -419,6 +1140,8 @@ async function generateAlterCode_ColumnAndIndexes(
419
1140
  dbForeigns: MigrationForeign[],
420
1141
  compareDB?: Knex,
421
1142
  ): Promise<GenMigrationCode[]> {
1143
+ const resolvedEntityColumns = resolveSearchTextColumns(table, entityColumns);
1144
+ const searchTextColumnNames = getSearchTextColumnNames(table);
422
1145
  /*
423
1146
  세부 비교 후 다른점 찾아서 코드 생성
424
1147
 
@@ -432,7 +1155,7 @@ async function generateAlterCode_ColumnAndIndexes(
432
1155
  */
433
1156
 
434
1157
  // PK(id) 컬럼 타입 변경 감지 및 처리
435
- const entityIdCol = entityColumns.find((col) => col.name === "id");
1158
+ const entityIdCol = resolvedEntityColumns.find((col) => col.name === "id");
436
1159
  const dbIdCol = dbColumns.find((col) => col.name === "id");
437
1160
 
438
1161
  if (entityIdCol && dbIdCol && compareDB) {
@@ -444,7 +1167,7 @@ async function generateAlterCode_ColumnAndIndexes(
444
1167
  table,
445
1168
  entityIdCol,
446
1169
  dbIdCol,
447
- entityColumns,
1170
+ resolvedEntityColumns,
448
1171
  entityIndexes,
449
1172
  dbColumns,
450
1173
  dbIndexes,
@@ -455,25 +1178,48 @@ async function generateAlterCode_ColumnAndIndexes(
455
1178
  }
456
1179
 
457
1180
  // 각 컬럼 이름 기준으로 add, drop, alter 여부 확인
458
- const alterColumnsTo = getAlterColumnsTo(entityColumns, dbColumns);
1181
+ const alterColumnsTo = getAlterColumnsTo(resolvedEntityColumns, dbColumns, searchTextColumnNames);
459
1182
 
460
1183
  // 추출된 컬럼들을 기준으로 각각 라인 생성
461
1184
  const alterColumnLinesTo = getAlterColumnLinesTo(
462
1185
  alterColumnsTo,
463
- entityColumns,
1186
+ resolvedEntityColumns,
464
1187
  table,
465
1188
  dbForeigns,
466
1189
  );
467
1190
 
468
1191
  // 인덱스의 add, drop 여부 확인
469
1192
  const alterIndexesTo = getAlterIndexesTo(entityIndexes, dbIndexes);
1193
+ const recreatedSearchTextColumnNames = new Set(
1194
+ alterColumnsTo.alter
1195
+ .filter((dbColumn) => {
1196
+ const entityColumn = resolvedEntityColumns.find((col) => col.name === dbColumn.name);
1197
+ return (
1198
+ searchTextColumnNames.has(dbColumn.name) &&
1199
+ dbColumn.generated !== undefined &&
1200
+ entityColumn?.generated !== undefined
1201
+ );
1202
+ })
1203
+ .map((column) => column.name),
1204
+ );
1205
+ const recreatedSearchTextDbIndexes = dbIndexes.filter(
1206
+ (index) =>
1207
+ index.columns.some(({ name }) => recreatedSearchTextColumnNames.has(name)) &&
1208
+ alterIndexesTo.drop.some((dropIndex) => dropIndex.name === index.name) === false,
1209
+ );
1210
+ const recreatedSearchTextEntityIndexes = entityIndexes.filter(
1211
+ (index) =>
1212
+ index.columns.some(({ name }) => recreatedSearchTextColumnNames.has(name)) &&
1213
+ alterIndexesTo.add.some((addIndex) => addIndex.name === index.name) === false,
1214
+ );
1215
+ const implicitlyDroppedDbIndexes = alterIndexesTo.drop.filter((index) =>
1216
+ index.columns.every(({ name }) => alterColumnsTo.drop.some((column) => column.name === name)),
1217
+ );
470
1218
 
471
1219
  // 인덱스가 삭제되는 경우, 컬럼과 같이 삭제된 케이스에는 drop에서 제외해야함!
472
1220
  const indexNeedsToDrop = alterIndexesTo.drop.filter(
473
1221
  (index) =>
474
- index.columns.every(({ name }) =>
475
- alterColumnsTo.drop.map((col) => col.name).includes(name),
476
- ) === false,
1222
+ implicitlyDroppedDbIndexes.some((droppedIndex) => droppedIndex.name === index.name) === false,
477
1223
  );
478
1224
 
479
1225
  // 빈 코드 생성 방지
@@ -482,8 +1228,10 @@ async function generateAlterCode_ColumnAndIndexes(
482
1228
  alterColumnLinesTo.add.up.raw.length > 0 ||
483
1229
  alterColumnLinesTo.drop.up.builder.length > 0 ||
484
1230
  alterColumnLinesTo.alter.up.builder.length > 0 ||
1231
+ alterColumnLinesTo.alter.up.raw.length > 0 ||
485
1232
  alterIndexesTo.add.length > 0 ||
486
- indexNeedsToDrop.length > 0;
1233
+ indexNeedsToDrop.length > 0 ||
1234
+ recreatedSearchTextDbIndexes.length > 0;
487
1235
  if (!hasUpChanges) {
488
1236
  // 변경사항이 없으면 빈 배열 반환
489
1237
  return [];
@@ -504,6 +1252,7 @@ async function generateAlterCode_ColumnAndIndexes(
504
1252
  const upBuilderLines = [
505
1253
  ...(alterColumnLinesTo.drop.up.builder.length > 0 ? alterColumnLinesTo.drop.up.builder : []),
506
1254
  ...(alterColumnLinesTo.add.up.builder.length > 0 ? alterColumnLinesTo.add.up.builder : []),
1255
+ ...recreatedSearchTextDbIndexes.map(genIndexDropDefinition),
507
1256
  ...(alterColumnLinesTo.alter.up.builder.length > 0 ? alterColumnLinesTo.alter.up.builder : []),
508
1257
  ...indexNeedsToDrop.map(genIndexDropDefinition),
509
1258
  ];
@@ -511,12 +1260,15 @@ async function generateAlterCode_ColumnAndIndexes(
511
1260
  // knex.raw()로 실행할 코드
512
1261
  const upRawLines = [
513
1262
  ...(alterColumnLinesTo.add.up.raw.length > 0 ? alterColumnLinesTo.add.up.raw : []),
1263
+ ...(alterColumnLinesTo.alter.up.raw.length > 0 ? alterColumnLinesTo.alter.up.raw : []),
1264
+ ...recreatedSearchTextEntityIndexes.map((index) => genIndexDefinition(index, table)),
514
1265
  ...alterIndexesTo.add.map((index) => genIndexDefinition(index, table)),
515
1266
  ];
516
1267
 
517
1268
  // down은 up의 역순 (add.down = drop rollback, drop.down = add rollback)
518
1269
  const downBuilderLines = [
519
1270
  ...(alterColumnLinesTo.add.down.builder.length > 0 ? alterColumnLinesTo.add.down.builder : []),
1271
+ ...recreatedSearchTextEntityIndexes.map(genIndexDropDefinition),
520
1272
  ...(alterColumnLinesTo.alter.down.builder.length > 0
521
1273
  ? alterColumnLinesTo.alter.down.builder
522
1274
  : []),
@@ -535,6 +1287,9 @@ async function generateAlterCode_ColumnAndIndexes(
535
1287
 
536
1288
  const downRawLines = [
537
1289
  ...(alterColumnLinesTo.drop.down.raw.length > 0 ? alterColumnLinesTo.drop.down.raw : []),
1290
+ ...(alterColumnLinesTo.alter.down.raw.length > 0 ? alterColumnLinesTo.alter.down.raw : []),
1291
+ ...recreatedSearchTextDbIndexes.map((index) => genIndexDefinition(index, table)),
1292
+ ...implicitlyDroppedDbIndexes.map((index) => genIndexDefinition(index, table)),
538
1293
  ...indexNeedsToDrop.map((index) => genIndexDefinition(index, table)),
539
1294
  ];
540
1295
 
@@ -584,23 +1339,38 @@ async function generateAlterCode_ColumnAndIndexes(
584
1339
  /**
585
1340
  * 컬럼 비교를 위해 Generated Column의 expression을 제외한 객체를 생성
586
1341
  */
587
- function normalizeColumnForComparison(col: MigrationColumn): MigrationColumn {
588
- if (col.generated) {
1342
+ function normalizeColumnForComparison(
1343
+ col: MigrationColumn,
1344
+ searchTextColumnNames: Set<string>,
1345
+ ): MigrationColumn {
1346
+ if (!col.generated) {
1347
+ return col;
1348
+ }
1349
+
1350
+ if (!searchTextColumnNames.has(col.name)) {
589
1351
  return {
590
1352
  ...col,
591
- generated: {
592
- type: col.generated.type,
593
- expression: "",
594
- },
1353
+ generated: undefined,
595
1354
  };
596
1355
  }
597
- return col;
1356
+
1357
+ return {
1358
+ ...col,
1359
+ generated: {
1360
+ ...col.generated,
1361
+ expression: canonicalizeSearchTextGeneratedExpression(col.generated.expression),
1362
+ },
1363
+ };
598
1364
  }
599
1365
 
600
1366
  /**
601
1367
  * 각 컬럼 이름 기준으로 add, drop, alter 여부 확인
602
1368
  */
603
- function getAlterColumnsTo(entityColumns: MigrationColumn[], dbColumns: MigrationColumn[]) {
1369
+ function getAlterColumnsTo(
1370
+ entityColumns: MigrationColumn[],
1371
+ dbColumns: MigrationColumn[],
1372
+ searchTextColumnNames: Set<string>,
1373
+ ) {
604
1374
  const columnsTo = {
605
1375
  add: [] as MigrationColumn[],
606
1376
  drop: [] as MigrationColumn[],
@@ -619,13 +1389,14 @@ function getAlterColumnsTo(entityColumns: MigrationColumn[], dbColumns: Migratio
619
1389
  columnsTo.drop = columnsTo.drop.concat(extraColumns.db);
620
1390
  }
621
1391
 
622
- // 동일 컬럼명의 세부 필드 비교 (Generated Column expression 제외)
1392
+ // 동일 컬럼명의 세부 필드 비교
623
1393
  const sameDbColumns = intersectionBy(dbColumns, entityColumns, (col) => col.name);
624
1394
  const sameMdColumns = intersectionBy(entityColumns, dbColumns, (col) => col.name);
625
- columnsTo.alter = differenceWith(
626
- sameDbColumns,
627
- sameMdColumns,
628
- (a, b) => equal({ ...a, generated: undefined }, { ...b, generated: undefined }), // generated 컬럼은 alter로 처리하지 않음
1395
+ columnsTo.alter = differenceWith(sameDbColumns, sameMdColumns, (a, b) =>
1396
+ equal(
1397
+ normalizeColumnForComparison(a, searchTextColumnNames),
1398
+ normalizeColumnForComparison(b, searchTextColumnNames),
1399
+ ),
629
1400
  );
630
1401
 
631
1402
  return columnsTo;
@@ -640,6 +1411,7 @@ function getAlterColumnLinesTo(
640
1411
  table: string,
641
1412
  dbForeigns: MigrationForeign[],
642
1413
  ) {
1414
+ const searchTextColumnNames = getSearchTextColumnNames(table);
643
1415
  const linesTo = {
644
1416
  add: {
645
1417
  up: { builder: [] as string[], raw: [] as string[] },
@@ -659,7 +1431,14 @@ function getAlterColumnLinesTo(
659
1431
  const addColumnDefs = genColumnDefinitions(table, columnsTo.add);
660
1432
  linesTo.add.up = {
661
1433
  builder: addColumnDefs.builder.length > 0 ? ["// add", ...addColumnDefs.builder] : [],
662
- raw: addColumnDefs.raw.length > 0 ? ["// add (generated)", ...addColumnDefs.raw] : [],
1434
+ raw:
1435
+ addColumnDefs.raw.length > 0
1436
+ ? [
1437
+ ...getSearchTextHelperDefinitions(table, columnsTo.add),
1438
+ "// add (generated)",
1439
+ ...addColumnDefs.raw,
1440
+ ]
1441
+ : [],
663
1442
  };
664
1443
  linesTo.add.down = {
665
1444
  builder:
@@ -711,7 +1490,11 @@ function getAlterColumnLinesTo(
711
1490
  ],
712
1491
  raw:
713
1492
  dropColumnDefs.raw.length > 0
714
- ? ["// rollback - drop columns (generated)", ...dropColumnDefs.raw]
1493
+ ? [
1494
+ ...getSearchTextHelperDefinitions(table, columnsTo.drop),
1495
+ "// rollback - drop columns (generated)",
1496
+ ...dropColumnDefs.raw,
1497
+ ]
715
1498
  : [],
716
1499
  },
717
1500
  };
@@ -724,6 +1507,36 @@ function getAlterColumnLinesTo(
724
1507
  return r;
725
1508
  }
726
1509
 
1510
+ if (
1511
+ searchTextColumnNames.has(dbColumn.name) &&
1512
+ dbColumn.generated !== undefined &&
1513
+ entityColumn.generated !== undefined
1514
+ ) {
1515
+ r.up.builder = [
1516
+ ...r.up.builder,
1517
+ "// alter generated column",
1518
+ `table.dropColumns('${dbColumn.name}')`,
1519
+ ];
1520
+ r.up.raw = [
1521
+ ...r.up.raw,
1522
+ ...getSearchTextHelperDefinitions(table, [entityColumn]),
1523
+ "// alter generated column",
1524
+ genGeneratedColumnDefinition(table, entityColumn),
1525
+ ];
1526
+ r.down.builder = [
1527
+ ...r.down.builder,
1528
+ "// rollback - alter generated column",
1529
+ `table.dropColumns('${dbColumn.name}')`,
1530
+ ];
1531
+ r.down.raw = [
1532
+ ...r.down.raw,
1533
+ ...getSearchTextHelperDefinitions(table, [dbColumn]),
1534
+ "// rollback - alter generated column",
1535
+ genGeneratedColumnDefinition(table, dbColumn),
1536
+ ];
1537
+ return r;
1538
+ }
1539
+
727
1540
  // 컬럼 변경사항
728
1541
  const columnDiffUp = diff(
729
1542
  genColumnDefinitions(table, [entityColumn]).builder,
@@ -818,15 +1631,15 @@ function genIndexDropDefinition(index: MigrationIndex) {
818
1631
  * DB 조회 결과와 비교하기 위한 인덱스 기본값 설정
819
1632
  */
820
1633
  export function setMigrationIndexDefaults(index: MigrationIndex): MigrationIndex {
821
- const supportsOrdering =
822
- index.type !== "hnsw" && // type이 hnsw면 벡터 인덱스
823
- index.type !== "ivfflat" && // type이 ivfflat면 벡터 인덱스
824
- (!index.using || index.using === "btree"); // using 체크
1634
+ const isVectorIndex = index.type === "hnsw" || index.type === "ivfflat";
1635
+ const supportsOrdering = !isVectorIndex && (!index.using || index.using === "btree");
1636
+ const normalizedUsing = isVectorIndex ? index.using : (index.using ?? "btree");
825
1637
 
826
1638
  return {
827
1639
  ...index,
828
1640
  columns: index.columns.map((col) => ({
829
1641
  name: col.name,
1642
+ ...(getIndexColumnOpclass(col) ? { opclass: getIndexColumnOpclass(col) } : {}),
830
1643
  ...(supportsOrdering
831
1644
  ? {
832
1645
  sortOrder: col.sortOrder ?? "ASC",
@@ -835,7 +1648,7 @@ export function setMigrationIndexDefaults(index: MigrationIndex): MigrationIndex
835
1648
  : {}),
836
1649
  })),
837
1650
  nullsNotDistinct: index.nullsNotDistinct ?? false,
838
- using: index.using ?? "btree",
1651
+ ...(normalizedUsing ? { using: normalizedUsing } : {}),
839
1652
  };
840
1653
  }
841
1654
 
@@ -1049,9 +1862,10 @@ export async function generateAlterCode(
1049
1862
  const alterCodes: (GenMigrationCode | GenMigrationCode[] | null)[] = [];
1050
1863
 
1051
1864
  // 1. columnsAndIndexes 처리
1865
+ const searchTextColumnNames = getSearchTextColumnNames(entitySet.table);
1052
1866
  const isEqualColumns = equal(
1053
- entityColumns.map(normalizeColumnForComparison),
1054
- dbColumns.map(normalizeColumnForComparison),
1867
+ entityColumns.map((column) => normalizeColumnForComparison(column, searchTextColumnNames)),
1868
+ dbColumns.map((column) => normalizeColumnForComparison(column, searchTextColumnNames)),
1055
1869
  );
1056
1870
  const isEqualIndexes = equal(
1057
1871
  entityIndexes.map(setMigrationIndexDefaults),