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
|
@@ -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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
1181
|
+
const alterColumnsTo = getAlterColumnsTo(resolvedEntityColumns, dbColumns, searchTextColumnNames);
|
|
459
1182
|
|
|
460
1183
|
// 추출된 컬럼들을 기준으로 각각 라인 생성
|
|
461
1184
|
const alterColumnLinesTo = getAlterColumnLinesTo(
|
|
462
1185
|
alterColumnsTo,
|
|
463
|
-
|
|
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
|
-
|
|
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(
|
|
588
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
// 동일 컬럼명의 세부 필드 비교
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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:
|
|
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
|
-
? [
|
|
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
|
|
822
|
-
|
|
823
|
-
|
|
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:
|
|
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),
|