sonamu 0.9.17 → 0.9.19

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.
@@ -0,0 +1,392 @@
1
+ import SqlParser from "node-sql-parser";
2
+
3
+ const INDEX_WHERE_SQL_PARSER = new SqlParser.Parser();
4
+
5
+ /**
6
+ * Migration DDL 출력에 사용할 index predicate를 정리한다.
7
+ * 원본 SQL 표현은 보존하고, 빈 문자열과 전체를 감싼 괄호만 제거한다.
8
+ */
9
+ export function normalizeIndexWherePredicate(where: string | undefined): string | undefined {
10
+ if (!where) {
11
+ return undefined;
12
+ }
13
+
14
+ // 출력 경로에서는 사용자가 작성한 predicate를 최대한 그대로 유지한다.
15
+ const trimmed = removeOuterSqlParentheses(where.trim());
16
+ if (trimmed.length === 0) {
17
+ return undefined;
18
+ }
19
+
20
+ return trimmed;
21
+ }
22
+
23
+ /**
24
+ * Index diff 비교에 사용할 predicate identity를 만든다.
25
+ * PostgreSQL canonical 표현 차이는 AST 기반 정규화로 흡수한다.
26
+ */
27
+ export function normalizeIndexWherePredicateForComparison(
28
+ where: string | undefined,
29
+ ): string | undefined {
30
+ const normalized = normalizeIndexWherePredicate(where);
31
+ if (!normalized) {
32
+ return undefined;
33
+ }
34
+
35
+ // 파싱 가능한 predicate만 canonical form으로 바꾸고, 실패하면 기존 strict 비교를 유지한다.
36
+ return normalizeIndexWherePredicateByAst(normalized) ?? normalized;
37
+ }
38
+
39
+ function removeOuterSqlParentheses(source: string): string {
40
+ let trimmed = source.trim();
41
+
42
+ // 전체 predicate를 감싼 괄호만 제거하고 내부 grouping은 건드리지 않는다.
43
+ while (trimmed.startsWith("(") && trimmed.endsWith(")")) {
44
+ const closeIndex = findMatchingParenthesisInSql(trimmed, 0);
45
+ if (closeIndex === trimmed.length - 1) {
46
+ trimmed = trimmed.slice(1, -1).trim();
47
+ continue;
48
+ }
49
+
50
+ break;
51
+ }
52
+
53
+ return trimmed;
54
+ }
55
+
56
+ function normalizeIndexWherePredicateByAst(where: string): string | undefined {
57
+ try {
58
+ // node-sql-parser는 raw predicate를 직접 파싱하지 않으므로 임시 SELECT WHERE로 감싼다.
59
+ const parsed = INDEX_WHERE_SQL_PARSER.astify(
60
+ `SELECT * FROM __sonamu_index_predicate_source WHERE ${where}`,
61
+ { database: "postgresql" },
62
+ );
63
+ const statement = Array.isArray(parsed) ? parsed[0] : parsed;
64
+ if (!isSqlAstRecord(statement)) {
65
+ return undefined;
66
+ }
67
+
68
+ return serializeIndexWhereAst(statement.where);
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ }
73
+
74
+ function serializeIndexWhereAst(node: unknown): string {
75
+ // PostgreSQL이 IN을 ANY(ARRAY[...])로 재작성하므로 membership은 먼저 특수 처리한다.
76
+ const membership = serializeSqlMembershipPredicate(node);
77
+ if (membership) {
78
+ return membership;
79
+ }
80
+
81
+ if (!isSqlAstRecord(node)) {
82
+ return JSON.stringify(node);
83
+ }
84
+
85
+ const type = getSqlAstString(node, "type");
86
+ switch (type) {
87
+ case "binary_expr": {
88
+ const operator = normalizeSqlOperator(getSqlAstString(node, "operator"));
89
+ const left = serializeIndexWhereAst(node.left);
90
+ const right = serializeIndexWhereAst(node.right);
91
+ if (operator === "AND" || operator === "OR") {
92
+ // AND/OR는 순서가 diff 의미를 바꾸지 않으므로 stable identity로 정렬한다.
93
+ return `(${[left, right].toSorted().join(` ${operator} `)})`;
94
+ }
95
+ return `(${left} ${operator} ${right})`;
96
+ }
97
+ case "unary_expr": {
98
+ const operator = normalizeSqlOperator(getSqlAstString(node, "operator"));
99
+ return `(${operator} ${serializeIndexWhereAst(node.expr)})`;
100
+ }
101
+ case "column_ref": {
102
+ return serializeSqlColumnRef(node);
103
+ }
104
+ case "single_quote_string": {
105
+ return `string:${JSON.stringify(getSqlAstString(node, "value"))}`;
106
+ }
107
+ case "number": {
108
+ return `number:${String(node.value)}`;
109
+ }
110
+ case "bool": {
111
+ return `bool:${String(node.value).toLowerCase()}`;
112
+ }
113
+ case "null": {
114
+ return "null";
115
+ }
116
+ case "cast": {
117
+ const targetType = getSqlCastTargetType(node);
118
+ const expr = node.expr;
119
+ if (targetType && isTextLikeSqlType(targetType) && isSqlStringLiteralLike(expr)) {
120
+ return serializeIndexWhereAst(stripTextLikeSqlCast(expr));
121
+ }
122
+ return `cast(${serializeIndexWhereAst(expr)} as ${targetType ?? "unknown"})`;
123
+ }
124
+ case "function": {
125
+ return `fn:${getSqlFunctionName(node)}(${serializeSqlExprList(node.args).join(",")})`;
126
+ }
127
+ case "array": {
128
+ return `array:[${serializeSqlArrayElements(node).map(serializeIndexWhereAst).join(",")}]`;
129
+ }
130
+ case "expr_list": {
131
+ return `list:[${serializeSqlExprList(node).map(serializeIndexWhereAst).join(",")}]`;
132
+ }
133
+ case "default": {
134
+ return `ident:${normalizeSqlIdentifier(getSqlAstString(node, "value") ?? "")}`;
135
+ }
136
+ default: {
137
+ return serializeUnknownSqlAstRecord(node);
138
+ }
139
+ }
140
+ }
141
+
142
+ function serializeSqlMembershipPredicate(node: unknown): string | undefined {
143
+ if (!isSqlAstRecord(node) || getSqlAstString(node, "type") !== "binary_expr") {
144
+ return undefined;
145
+ }
146
+
147
+ // IN (...)과 = ANY(ARRAY[...])를 동일한 unordered membership 표현으로 맞춘다.
148
+ const operator = normalizeSqlOperator(getSqlAstString(node, "operator"));
149
+ const left = serializeIndexWhereAst(stripTextLikeSqlCast(node.left));
150
+ const values = (() => {
151
+ if (operator === "IN") {
152
+ return serializeSqlMembershipValues(serializeSqlExprList(node.right));
153
+ }
154
+
155
+ if (operator === "=") {
156
+ return serializeSqlMembershipValues(getSqlAnyArrayElements(node.right));
157
+ }
158
+
159
+ return undefined;
160
+ })();
161
+
162
+ if (!values) {
163
+ return undefined;
164
+ }
165
+
166
+ return `membership:${left}:in:[${values.join(",")}]`;
167
+ }
168
+
169
+ function serializeSqlMembershipValues(values: unknown[] | undefined): string[] | undefined {
170
+ if (!values) {
171
+ return undefined;
172
+ }
173
+
174
+ // membership 값의 순서와 중복은 의미가 없으므로 안정적인 identity로 정렬한다.
175
+ const serialized = values.map((value) => serializeIndexWhereAst(stripTextLikeSqlCast(value)));
176
+ return [...new Set(serialized)].toSorted();
177
+ }
178
+
179
+ function getSqlAnyArrayElements(node: unknown): unknown[] | undefined {
180
+ if (!isSqlAstRecord(node) || getSqlAstString(node, "type") !== "function") {
181
+ return undefined;
182
+ }
183
+ if (getSqlFunctionName(node) !== "any") {
184
+ return undefined;
185
+ }
186
+
187
+ // ANY()의 첫 번째 인자만 PostgreSQL 배열 membership 대상으로 해석한다.
188
+ const [arg] = serializeSqlExprList(node.args);
189
+ if (!arg) {
190
+ return undefined;
191
+ }
192
+
193
+ return serializeSqlArrayElements(stripTextLikeSqlCast(arg));
194
+ }
195
+
196
+ function serializeSqlArrayElements(node: unknown): unknown[] {
197
+ if (!isSqlAstRecord(node)) {
198
+ return [];
199
+ }
200
+
201
+ // parser 버전에 따라 배열 원소가 array.expr_list 또는 expr_list로 들어온다.
202
+ if (getSqlAstString(node, "type") === "array") {
203
+ return serializeSqlExprList(node.expr_list);
204
+ }
205
+
206
+ return serializeSqlExprList(node);
207
+ }
208
+
209
+ function serializeSqlExprList(node: unknown): unknown[] {
210
+ if (!isSqlAstRecord(node)) {
211
+ return [];
212
+ }
213
+
214
+ // expr_list는 실제 AST 노드 배열이고, 단일 표현식은 배열 하나로 감싸 통일한다.
215
+ if (getSqlAstString(node, "type") === "expr_list" && Array.isArray(node.value)) {
216
+ return node.value;
217
+ }
218
+
219
+ return [node];
220
+ }
221
+
222
+ function stripTextLikeSqlCast(node: unknown): unknown {
223
+ if (!isSqlAstRecord(node) || getSqlAstString(node, "type") !== "cast") {
224
+ return node;
225
+ }
226
+
227
+ // PostgreSQL은 varchar/text cast를 배열 또는 원소 쪽으로 옮길 수 있어 비교에서 제거한다.
228
+ const targetType = getSqlCastTargetType(node);
229
+ if (!targetType || !isTextLikeSqlType(targetType)) {
230
+ return node;
231
+ }
232
+
233
+ return stripTextLikeSqlCast(node.expr);
234
+ }
235
+
236
+ function isSqlStringLiteralLike(node: unknown): boolean {
237
+ // 문자열 literal에 붙은 text 계열 cast는 membership 비교에서 의미가 같다.
238
+ const stripped = stripTextLikeSqlCast(node);
239
+ return isSqlAstRecord(stripped) && getSqlAstString(stripped, "type") === "single_quote_string";
240
+ }
241
+
242
+ function getSqlCastTargetType(node: Record<string, unknown>): string | undefined {
243
+ if (!Array.isArray(node.target)) {
244
+ return undefined;
245
+ }
246
+
247
+ // character varying처럼 여러 token으로 오는 타입명을 하나의 정규화 문자열로 합친다.
248
+ const dataTypes = node.target
249
+ .map((target) => (isSqlAstRecord(target) ? getSqlAstString(target, "dataType") : undefined))
250
+ .filter((dataType): dataType is string => dataType !== undefined);
251
+
252
+ if (dataTypes.length === 0) {
253
+ return undefined;
254
+ }
255
+
256
+ return normalizeSqlDataType(dataTypes.join(" "));
257
+ }
258
+
259
+ function isTextLikeSqlType(type: string): boolean {
260
+ // 배열 cast(text[])도 원소 membership 비교에서는 text 계열 cast로 취급한다.
261
+ const normalized = normalizeSqlDataType(type).replace(/\[\]$/g, "");
262
+ return normalized === "text" || normalized === "varchar";
263
+ }
264
+
265
+ function normalizeSqlDataType(type: string): string {
266
+ return type
267
+ .trim()
268
+ .toLowerCase()
269
+ .replace(/\s+/g, " ")
270
+ .replace(/^character varying$/, "varchar");
271
+ }
272
+
273
+ function serializeSqlColumnRef(node: Record<string, unknown>): string {
274
+ const table = serializeSqlIdentifierNode(node.table);
275
+ const column = serializeSqlIdentifierNode(node.column);
276
+ return `column:${table ? `${table}.` : ""}${column}`;
277
+ }
278
+
279
+ function serializeSqlIdentifierNode(node: unknown): string {
280
+ if (typeof node === "string") {
281
+ return normalizeSqlIdentifier(node);
282
+ }
283
+
284
+ // quoted identifier와 function name의 AST shape 차이를 같은 identifier 문자열로 흡수한다.
285
+ if (isSqlAstRecord(node)) {
286
+ if (typeof node.value === "string") {
287
+ return normalizeSqlIdentifier(node.value);
288
+ }
289
+
290
+ if (isSqlAstRecord(node.expr) && typeof node.expr.value === "string") {
291
+ return normalizeSqlIdentifier(node.expr.value);
292
+ }
293
+ }
294
+
295
+ return "";
296
+ }
297
+
298
+ function getSqlFunctionName(node: Record<string, unknown>): string {
299
+ const name = node.name;
300
+ if (typeof name === "string") {
301
+ return normalizeSqlIdentifier(name);
302
+ }
303
+
304
+ // ANY처럼 name.name 배열로 오는 function AST를 schema-qualified 이름까지 지원한다.
305
+ if (isSqlAstRecord(name)) {
306
+ if (Array.isArray(name.name)) {
307
+ return name.name.map(serializeSqlIdentifierNode).join(".");
308
+ }
309
+
310
+ const nameParts = serializeSqlExprList(name);
311
+ if (nameParts.length > 0) {
312
+ return nameParts.map(serializeSqlIdentifierNode).join(".");
313
+ }
314
+ }
315
+
316
+ return "";
317
+ }
318
+
319
+ function normalizeSqlIdentifier(identifier: string): string {
320
+ return identifier.trim().toLowerCase();
321
+ }
322
+
323
+ function normalizeSqlOperator(operator: string | undefined): string {
324
+ return (operator ?? "").trim().toUpperCase().replace(/\s+/g, " ");
325
+ }
326
+
327
+ function serializeUnknownSqlAstRecord(node: Record<string, unknown>): string {
328
+ // 아직 명시적으로 다루지 않는 AST 노드는 key 정렬로 최소한 안정적인 비교값을 만든다.
329
+ const entries = Object.entries(node)
330
+ .filter(([key]) => key !== "parentheses")
331
+ .toSorted(([left], [right]) => left.localeCompare(right))
332
+ .map(([key, value]) => `${key}:${serializeIndexWhereAst(value)}`);
333
+
334
+ return `record:{${entries.join(",")}}`;
335
+ }
336
+
337
+ function isSqlAstRecord(value: unknown): value is Record<string, unknown> {
338
+ return typeof value === "object" && value !== null && !Array.isArray(value);
339
+ }
340
+
341
+ function getSqlAstString(node: Record<string, unknown>, key: string): string | undefined {
342
+ const value = node[key];
343
+ return typeof value === "string" ? value : undefined;
344
+ }
345
+
346
+ function findMatchingParenthesisInSql(source: string, openIndex: number): number {
347
+ let depth = 0;
348
+ let inSingleQuote = false;
349
+ let inDoubleQuote = false;
350
+
351
+ // SQL 문자열과 quoted identifier 내부 괄호는 grouping 괄호로 세지 않는다.
352
+ for (let index = openIndex; index < source.length; index += 1) {
353
+ const char = source[index];
354
+ const nextChar = source[index + 1];
355
+
356
+ if (char === "'" && !inDoubleQuote) {
357
+ if (inSingleQuote && nextChar === "'") {
358
+ index += 1;
359
+ continue;
360
+ }
361
+ inSingleQuote = !inSingleQuote;
362
+ continue;
363
+ }
364
+
365
+ if (char === '"' && !inSingleQuote) {
366
+ if (inDoubleQuote && nextChar === '"') {
367
+ index += 1;
368
+ continue;
369
+ }
370
+ inDoubleQuote = !inDoubleQuote;
371
+ continue;
372
+ }
373
+
374
+ if (inSingleQuote || inDoubleQuote) {
375
+ continue;
376
+ }
377
+
378
+ if (char === "(") {
379
+ depth += 1;
380
+ continue;
381
+ }
382
+
383
+ if (char === ")") {
384
+ depth -= 1;
385
+ if (depth === 0) {
386
+ return index;
387
+ }
388
+ }
389
+ }
390
+
391
+ return -1;
392
+ }