supalite 0.8.0 → 0.8.2
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/CHANGELOG.md +19 -0
- package/README.ko.md +1 -0
- package/README.md +18 -1
- package/SPEC.md +9 -2
- package/dist/query-builder.d.ts +6 -0
- package/dist/query-builder.js +306 -127
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.2] - 2026-02-23
|
|
4
|
+
|
|
5
|
+
### ✨ Added
|
|
6
|
+
- `or()`에서 중첩 `and(...)` / `or(...)` 그룹 파싱을 지원합니다. (PostgREST-style)
|
|
7
|
+
- `or()`에서 `in.(...)` 연산자를 지원합니다.
|
|
8
|
+
- `or()`에서 `not.*` 연산자(`not.eq`, `not.ilike`, `not.is`, `not.in` 등)를 지원합니다.
|
|
9
|
+
- 중첩 `or()` 전용 회귀 테스트 파일을 추가해 복합 케이스를 대폭 확장했습니다.
|
|
10
|
+
|
|
11
|
+
### 🐞 Fixed
|
|
12
|
+
- `or('...,and(...)')` 구문이 `and(created_at`를 컬럼으로 오해해 SQL 에러를 내던 문제를 수정했습니다.
|
|
13
|
+
- 괄호/따옴표가 깨진 `or()` 입력에서 명확한 파서 에러를 반환하도록 개선했습니다.
|
|
14
|
+
|
|
15
|
+
## [0.8.1] - 2026-02-03
|
|
16
|
+
|
|
17
|
+
### 🐞 Fixed
|
|
18
|
+
- insert/update/upsert에서 `undefined` 필드를 제외하도록 수정했습니다.
|
|
19
|
+
- multi-row insert에서 누락/undefined 값을 `DEFAULT`로 처리합니다.
|
|
20
|
+
- 단일 insert에 정의된 필드가 없으면 `DEFAULT VALUES`를 사용합니다.
|
|
21
|
+
|
|
3
22
|
## [0.8.0] - 2026-01-19
|
|
4
23
|
|
|
5
24
|
### ✨ Added
|
package/README.ko.md
CHANGED
|
@@ -814,6 +814,7 @@ await client
|
|
|
814
814
|
```
|
|
815
815
|
|
|
816
816
|
빈 배열 `insert([])`는 `Empty array provided for insert` 에러가 발생합니다.
|
|
817
|
+
`undefined` 필드는 insert/update/upsert에서 제외됩니다. multi-row insert에서 누락/undefined는 `DEFAULT`로 처리되며, 단일 insert에서 정의된 필드가 없으면 `DEFAULT VALUES`가 사용됩니다.
|
|
817
818
|
|
|
818
819
|
### 데이터 타입 (JSONB/배열/BigInt)
|
|
819
820
|
|
package/README.md
CHANGED
|
@@ -533,6 +533,18 @@ const { data: credits } = await client
|
|
|
533
533
|
.eq('wallet_id', 123)
|
|
534
534
|
.gt('amount', 0)
|
|
535
535
|
.or('valid_until.is.null,valid_until.gt.now()');
|
|
536
|
+
|
|
537
|
+
// Nested and(...) inside or(...) (PostgREST-style keyset pattern)
|
|
538
|
+
const { data: images } = await client
|
|
539
|
+
.from('priv_images')
|
|
540
|
+
.select('id,created_at')
|
|
541
|
+
.or('created_at.lt.2026-02-13T09:09:32.000Z,and(created_at.eq.2026-02-13T09:09:32.000Z,id.lt.1462)');
|
|
542
|
+
|
|
543
|
+
// in / not.* inside or()
|
|
544
|
+
const { data: users } = await client
|
|
545
|
+
.from('users')
|
|
546
|
+
.select('*')
|
|
547
|
+
.or('and(id.in.(1,2,3),status.not.eq.inactive)');
|
|
536
548
|
```
|
|
537
549
|
|
|
538
550
|
### Sorting/Pagination
|
|
@@ -814,6 +826,7 @@ await client
|
|
|
814
826
|
```
|
|
815
827
|
|
|
816
828
|
Empty array `insert([])` throws `Empty array provided for insert`.
|
|
829
|
+
`undefined` fields in insert/update/upsert payloads are omitted. For multi-row inserts, missing/undefined values emit `DEFAULT`. Single-row inserts with no defined fields emit `DEFAULT VALUES`.
|
|
817
830
|
|
|
818
831
|
### Data Types (JSONB/Arrays/BigInt)
|
|
819
832
|
|
|
@@ -1031,7 +1044,11 @@ await client.close();
|
|
|
1031
1044
|
- `is(column, value)`: IS
|
|
1032
1045
|
- `not(column, operator, value)`: currently only `not('column', 'is', null)` is supported
|
|
1033
1046
|
- `contains(column, value)`: array/JSON contains
|
|
1034
|
-
- `or(conditions)`: OR condition string (ops: eq/neq/like/ilike/gt/gte/lt/lte/is
|
|
1047
|
+
- `or(conditions)`: OR condition string (ops: eq/neq/like/ilike/gt/gte/lt/lte/is/in + `not.*`, `now()` is inlined as `NOW()`)
|
|
1048
|
+
- Supports nested `and(...)` and `or(...)` groups (PostgREST-style), e.g. `created_at.lt.ts,and(created_at.eq.ts,id.lt.1462)`
|
|
1049
|
+
- Supports `in.(...)` and negated ops such as `status.not.eq.inactive`, `id.not.in.(1,2,3)`, `deleted_at.not.is.null`
|
|
1050
|
+
- Quote values to include dots/commas (e.g. `name.eq."last, first"`)
|
|
1051
|
+
- Related table filters are not supported inside `or()`
|
|
1035
1052
|
|
|
1036
1053
|
### Other methods
|
|
1037
1054
|
|
package/SPEC.md
CHANGED
|
@@ -79,11 +79,15 @@ Notes:
|
|
|
79
79
|
- `in(col, [..., null])` emits `("col" IN (...) OR "col" IS NULL)`; if only NULLs are provided, it emits `IS NULL`.
|
|
80
80
|
- `contains` uses `@>`.
|
|
81
81
|
- `or()` expects `col.op.value` segments separated by commas.
|
|
82
|
-
-
|
|
82
|
+
- Nested `and(...)` and `or(...)` groups are supported inside `or()` (e.g. `created_at.lt.ts,and(created_at.eq.ts,id.lt.1462)`).
|
|
83
|
+
- Supported ops: `eq`, `neq`, `like`, `ilike`, `gt`, `gte`, `lt`, `lte`, `is`, `in`.
|
|
84
|
+
- Negated forms are supported via `not.*` (e.g. `status.not.eq.inactive`, `deleted_at.not.is.null`, `id.not.in.(1,2)`).
|
|
83
85
|
- `value` is treated as a literal string; `null` maps to SQL NULL; numeric strings are kept as strings.
|
|
84
86
|
- `is.null` uses `IS NULL` without a placeholder.
|
|
87
|
+
- `in.(...)` values are parsed as a parenthesized list; `null` entries are handled as `IS NULL`/`IS NOT NULL` branches.
|
|
85
88
|
- `now()` is inlined as `NOW()` for comparison operators (no placeholder).
|
|
86
89
|
- Quote values to include dots/commas (e.g. `name.eq."last, first"`).
|
|
90
|
+
- Malformed nested expressions throw clear parser errors (for example, unbalanced parentheses).
|
|
87
91
|
|
|
88
92
|
### 4.3 Ordering and pagination
|
|
89
93
|
- `order('col')` defaults to `ASC`.
|
|
@@ -100,7 +104,10 @@ Notes:
|
|
|
100
104
|
- `select()` appends `RETURNING` with the selected columns.
|
|
101
105
|
- Without `select()`, no `RETURNING` is added.
|
|
102
106
|
- `insert([])` throws `Empty array provided for insert`.
|
|
103
|
-
-
|
|
107
|
+
- `undefined` fields are omitted from insert/update/upsert payloads.
|
|
108
|
+
- Single-row inserts with no defined fields emit `DEFAULT VALUES`.
|
|
109
|
+
- Multi-row inserts use the union of defined keys (first-seen order).
|
|
110
|
+
- Missing/undefined values in multi-row inserts emit `DEFAULT`.
|
|
104
111
|
- `upsert`:
|
|
105
112
|
- `onConflict` can be a string or array.
|
|
106
113
|
- String targets are quoted unless already quoted or parenthesized.
|
package/dist/query-builder.d.ts
CHANGED
|
@@ -32,6 +32,10 @@ export declare class QueryBuilder<T extends DatabaseSchema, S extends SchemaName
|
|
|
32
32
|
private splitOrConditions;
|
|
33
33
|
private splitOrCondition;
|
|
34
34
|
private unescapeOrValue;
|
|
35
|
+
private normalizeOrLiteralValue;
|
|
36
|
+
private parseOrInValues;
|
|
37
|
+
private buildOrLeafClause;
|
|
38
|
+
private parseOrSegment;
|
|
35
39
|
private parseSelection;
|
|
36
40
|
private quoteIdentifier;
|
|
37
41
|
private quoteColumn;
|
|
@@ -79,6 +83,8 @@ export declare class QueryBuilder<T extends DatabaseSchema, S extends SchemaName
|
|
|
79
83
|
private quoteConflictTargetColumn;
|
|
80
84
|
private shouldReturnData;
|
|
81
85
|
private stringifyJsonValue;
|
|
86
|
+
private filterUndefinedEntries;
|
|
87
|
+
private normalizeColumnValue;
|
|
82
88
|
private buildWhereClause;
|
|
83
89
|
private buildQuery;
|
|
84
90
|
private parseExplainPlanRows;
|
package/dist/query-builder.js
CHANGED
|
@@ -56,6 +56,7 @@ class QueryBuilder {
|
|
|
56
56
|
let current = '';
|
|
57
57
|
let inQuotes = false;
|
|
58
58
|
let escaped = false;
|
|
59
|
+
let depth = 0;
|
|
59
60
|
for (let i = 0; i < input.length; i += 1) {
|
|
60
61
|
const char = input[i];
|
|
61
62
|
if (escaped) {
|
|
@@ -73,7 +74,22 @@ class QueryBuilder {
|
|
|
73
74
|
current += char;
|
|
74
75
|
continue;
|
|
75
76
|
}
|
|
76
|
-
if (
|
|
77
|
+
if (!inQuotes) {
|
|
78
|
+
if (char === '(') {
|
|
79
|
+
depth += 1;
|
|
80
|
+
current += char;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (char === ')') {
|
|
84
|
+
if (depth === 0) {
|
|
85
|
+
throw new Error('Malformed or() condition: unexpected closing parenthesis.');
|
|
86
|
+
}
|
|
87
|
+
depth -= 1;
|
|
88
|
+
current += char;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (char === ',' && !inQuotes && depth === 0) {
|
|
77
93
|
if (current.trim()) {
|
|
78
94
|
parts.push(current.trim());
|
|
79
95
|
}
|
|
@@ -82,6 +98,12 @@ class QueryBuilder {
|
|
|
82
98
|
}
|
|
83
99
|
current += char;
|
|
84
100
|
}
|
|
101
|
+
if (inQuotes) {
|
|
102
|
+
throw new Error('Malformed or() condition: unterminated double quote.');
|
|
103
|
+
}
|
|
104
|
+
if (depth !== 0) {
|
|
105
|
+
throw new Error('Malformed or() condition: unbalanced parentheses.');
|
|
106
|
+
}
|
|
85
107
|
if (current.trim()) {
|
|
86
108
|
parts.push(current.trim());
|
|
87
109
|
}
|
|
@@ -90,8 +112,7 @@ class QueryBuilder {
|
|
|
90
112
|
splitOrCondition(condition) {
|
|
91
113
|
let inQuotes = false;
|
|
92
114
|
let escaped = false;
|
|
93
|
-
|
|
94
|
-
let secondDot = -1;
|
|
115
|
+
const dotPositions = [];
|
|
95
116
|
for (let i = 0; i < condition.length; i += 1) {
|
|
96
117
|
const char = condition[i];
|
|
97
118
|
if (escaped) {
|
|
@@ -107,18 +128,37 @@ class QueryBuilder {
|
|
|
107
128
|
continue;
|
|
108
129
|
}
|
|
109
130
|
if (char === '.' && !inQuotes) {
|
|
110
|
-
|
|
111
|
-
firstDot = i;
|
|
112
|
-
}
|
|
113
|
-
else {
|
|
114
|
-
secondDot = i;
|
|
115
|
-
break;
|
|
116
|
-
}
|
|
131
|
+
dotPositions.push(i);
|
|
117
132
|
}
|
|
118
133
|
}
|
|
119
|
-
if (
|
|
134
|
+
if (dotPositions.length < 2) {
|
|
120
135
|
return null;
|
|
121
136
|
}
|
|
137
|
+
const validOperators = new Set(['eq', 'neq', 'ilike', 'like', 'gt', 'gte', 'lt', 'lte', 'is', 'in']);
|
|
138
|
+
for (let left = 0; left < dotPositions.length - 1; left += 1) {
|
|
139
|
+
for (let right = left + 1; right < dotPositions.length; right += 1) {
|
|
140
|
+
const firstDot = dotPositions[left];
|
|
141
|
+
const secondDot = dotPositions[right];
|
|
142
|
+
const field = condition.slice(0, firstDot).trim();
|
|
143
|
+
const op = condition.slice(firstDot + 1, secondDot).trim();
|
|
144
|
+
const value = condition.slice(secondDot + 1).trim();
|
|
145
|
+
if (field && op && value && validOperators.has(op)) {
|
|
146
|
+
return { field, op, value };
|
|
147
|
+
}
|
|
148
|
+
if (op === 'not') {
|
|
149
|
+
for (let third = right + 1; third < dotPositions.length; third += 1) {
|
|
150
|
+
const thirdDot = dotPositions[third];
|
|
151
|
+
const notOp = condition.slice(secondDot + 1, thirdDot).trim();
|
|
152
|
+
const notValue = condition.slice(thirdDot + 1).trim();
|
|
153
|
+
if (field && notOp && notValue && validOperators.has(notOp)) {
|
|
154
|
+
return { field, op: `not.${notOp}`, value: notValue };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const firstDot = dotPositions[0];
|
|
161
|
+
const secondDot = dotPositions[1];
|
|
122
162
|
const field = condition.slice(0, firstDot).trim();
|
|
123
163
|
const op = condition.slice(firstDot + 1, secondDot).trim();
|
|
124
164
|
const value = condition.slice(secondDot + 1).trim();
|
|
@@ -127,6 +167,180 @@ class QueryBuilder {
|
|
|
127
167
|
unescapeOrValue(value) {
|
|
128
168
|
return value.replace(/\\([\\\".,])/g, '$1');
|
|
129
169
|
}
|
|
170
|
+
normalizeOrLiteralValue(value) {
|
|
171
|
+
if (value === 'null') {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
if (!isNaN(Number(value))) {
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
if (value.match(/^\d{4}-\d{2}-\d{2}/)) {
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
parseOrInValues(value) {
|
|
183
|
+
if (!value.startsWith('(') || !value.endsWith(')')) {
|
|
184
|
+
throw new Error(`Invalid or() IN value: "${value}". Expected parenthesized list like "(a,b)".`);
|
|
185
|
+
}
|
|
186
|
+
const inner = value.slice(1, -1).trim();
|
|
187
|
+
if (!inner) {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
return this.splitOrConditions(inner).map((raw) => {
|
|
191
|
+
let item = raw.trim();
|
|
192
|
+
if (item.startsWith('"') && item.endsWith('"')) {
|
|
193
|
+
item = item.slice(1, -1);
|
|
194
|
+
}
|
|
195
|
+
return this.unescapeOrValue(item);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
buildOrLeafClause(condition) {
|
|
199
|
+
const parsed = this.splitOrCondition(condition);
|
|
200
|
+
if (!parsed) {
|
|
201
|
+
throw new Error(`Invalid or() condition segment: "${condition}". Expected "column.operator.value".`);
|
|
202
|
+
}
|
|
203
|
+
const { field, op, value } = parsed;
|
|
204
|
+
if (!field || !op) {
|
|
205
|
+
throw new Error(`Invalid or() condition segment: "${condition}". Expected "column.operator.value".`);
|
|
206
|
+
}
|
|
207
|
+
if (field.includes('.')) {
|
|
208
|
+
throw new Error('or() does not support related table filters.');
|
|
209
|
+
}
|
|
210
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(field)) {
|
|
211
|
+
throw new Error(`Invalid or() column: "${field}". Use "column.operator.value" or nested and(...)/or(...).`);
|
|
212
|
+
}
|
|
213
|
+
const isNegated = op.startsWith('not.');
|
|
214
|
+
const baseOp = isNegated ? op.slice(4) : op;
|
|
215
|
+
const validOperators = ['eq', 'neq', 'ilike', 'like', 'gt', 'gte', 'lt', 'lte', 'is', 'in'];
|
|
216
|
+
if (!validOperators.includes(baseOp)) {
|
|
217
|
+
throw new Error(`Invalid operator: ${baseOp}`);
|
|
218
|
+
}
|
|
219
|
+
let normalizedValue = value;
|
|
220
|
+
if (normalizedValue.startsWith('"') && normalizedValue.endsWith('"')) {
|
|
221
|
+
normalizedValue = normalizedValue.slice(1, -1);
|
|
222
|
+
}
|
|
223
|
+
normalizedValue = this.unescapeOrValue(normalizedValue);
|
|
224
|
+
const isNullValue = normalizedValue === 'null';
|
|
225
|
+
const isNowValue = typeof normalizedValue === 'string' && normalizedValue.toLowerCase() === 'now()';
|
|
226
|
+
const quotedField = this.quoteColumn(field);
|
|
227
|
+
const buildPlaceholderClause = (sqlOp) => {
|
|
228
|
+
this.whereValues.push(this.normalizeOrLiteralValue(normalizedValue));
|
|
229
|
+
const paramIndex = this.whereValues.length;
|
|
230
|
+
return `${quotedField} ${sqlOp} $${paramIndex}`;
|
|
231
|
+
};
|
|
232
|
+
const buildClause = (sqlOp) => {
|
|
233
|
+
if (isNowValue) {
|
|
234
|
+
return `${quotedField} ${sqlOp} NOW()`;
|
|
235
|
+
}
|
|
236
|
+
return buildPlaceholderClause(sqlOp);
|
|
237
|
+
};
|
|
238
|
+
if (baseOp === 'in') {
|
|
239
|
+
const parsedValues = this.parseOrInValues(normalizedValue);
|
|
240
|
+
const hasNull = parsedValues.includes('null');
|
|
241
|
+
const nonNullValues = parsedValues.filter((item) => item !== 'null');
|
|
242
|
+
const pushPlaceholders = (items) => {
|
|
243
|
+
const placeholders = items.map((item) => {
|
|
244
|
+
this.whereValues.push(this.normalizeOrLiteralValue(item));
|
|
245
|
+
return `$${this.whereValues.length}`;
|
|
246
|
+
});
|
|
247
|
+
return placeholders.join(',');
|
|
248
|
+
};
|
|
249
|
+
if (!isNegated) {
|
|
250
|
+
if (nonNullValues.length === 0 && hasNull) {
|
|
251
|
+
return `${quotedField} IS NULL`;
|
|
252
|
+
}
|
|
253
|
+
if (nonNullValues.length === 0) {
|
|
254
|
+
return 'FALSE';
|
|
255
|
+
}
|
|
256
|
+
const inList = pushPlaceholders(nonNullValues);
|
|
257
|
+
if (hasNull) {
|
|
258
|
+
return `(${quotedField} IN (${inList}) OR ${quotedField} IS NULL)`;
|
|
259
|
+
}
|
|
260
|
+
return `${quotedField} IN (${inList})`;
|
|
261
|
+
}
|
|
262
|
+
if (nonNullValues.length === 0 && hasNull) {
|
|
263
|
+
return `${quotedField} IS NOT NULL`;
|
|
264
|
+
}
|
|
265
|
+
if (nonNullValues.length === 0) {
|
|
266
|
+
return 'TRUE';
|
|
267
|
+
}
|
|
268
|
+
const notInList = pushPlaceholders(nonNullValues);
|
|
269
|
+
if (hasNull) {
|
|
270
|
+
return `(${quotedField} NOT IN (${notInList}) AND ${quotedField} IS NOT NULL)`;
|
|
271
|
+
}
|
|
272
|
+
return `${quotedField} NOT IN (${notInList})`;
|
|
273
|
+
}
|
|
274
|
+
const resolveSqlOperator = (operator) => {
|
|
275
|
+
if (!isNegated) {
|
|
276
|
+
return operator;
|
|
277
|
+
}
|
|
278
|
+
switch (operator) {
|
|
279
|
+
case '=':
|
|
280
|
+
return '!=';
|
|
281
|
+
case '!=':
|
|
282
|
+
return '=';
|
|
283
|
+
case 'LIKE':
|
|
284
|
+
return 'NOT LIKE';
|
|
285
|
+
case 'ILIKE':
|
|
286
|
+
return 'NOT ILIKE';
|
|
287
|
+
case '>':
|
|
288
|
+
return '<=';
|
|
289
|
+
case '>=':
|
|
290
|
+
return '<';
|
|
291
|
+
case '<':
|
|
292
|
+
return '>=';
|
|
293
|
+
case '<=':
|
|
294
|
+
return '>';
|
|
295
|
+
case 'IS':
|
|
296
|
+
return 'IS NOT';
|
|
297
|
+
default:
|
|
298
|
+
return operator;
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
switch (baseOp) {
|
|
302
|
+
case 'eq':
|
|
303
|
+
return buildClause(resolveSqlOperator('='));
|
|
304
|
+
case 'neq':
|
|
305
|
+
return buildClause(resolveSqlOperator('!='));
|
|
306
|
+
case 'ilike':
|
|
307
|
+
return buildClause(resolveSqlOperator('ILIKE'));
|
|
308
|
+
case 'like':
|
|
309
|
+
return buildClause(resolveSqlOperator('LIKE'));
|
|
310
|
+
case 'gt':
|
|
311
|
+
return buildClause(resolveSqlOperator('>'));
|
|
312
|
+
case 'gte':
|
|
313
|
+
return buildClause(resolveSqlOperator('>='));
|
|
314
|
+
case 'lt':
|
|
315
|
+
return buildClause(resolveSqlOperator('<'));
|
|
316
|
+
case 'lte':
|
|
317
|
+
return buildClause(resolveSqlOperator('<='));
|
|
318
|
+
case 'is':
|
|
319
|
+
if (isNullValue) {
|
|
320
|
+
return isNegated ? `${quotedField} IS NOT NULL` : `${quotedField} IS NULL`;
|
|
321
|
+
}
|
|
322
|
+
return buildPlaceholderClause(resolveSqlOperator('IS'));
|
|
323
|
+
default:
|
|
324
|
+
throw new Error(`Invalid operator: ${baseOp}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
parseOrSegment(condition) {
|
|
328
|
+
const trimmed = condition.trim();
|
|
329
|
+
if (!trimmed) {
|
|
330
|
+
throw new Error('Invalid or() condition: empty segment.');
|
|
331
|
+
}
|
|
332
|
+
const lower = trimmed.toLowerCase();
|
|
333
|
+
if ((lower.startsWith('and(') || lower.startsWith('or(')) && trimmed.endsWith(')')) {
|
|
334
|
+
const useAnd = lower.startsWith('and(');
|
|
335
|
+
const inner = trimmed.slice(trimmed.indexOf('(') + 1, -1).trim();
|
|
336
|
+
const parts = this.splitOrConditions(inner).map((part) => this.parseOrSegment(part));
|
|
337
|
+
if (parts.length === 0) {
|
|
338
|
+
throw new Error(`Invalid or() condition: ${useAnd ? 'and' : 'or'}(...) requires at least one segment.`);
|
|
339
|
+
}
|
|
340
|
+
return `(${parts.join(useAnd ? ' AND ' : ' OR ')})`;
|
|
341
|
+
}
|
|
342
|
+
return this.buildOrLeafClause(trimmed);
|
|
343
|
+
}
|
|
130
344
|
parseSelection(input) {
|
|
131
345
|
const tokens = this.splitTopLevel(input);
|
|
132
346
|
const items = [];
|
|
@@ -447,77 +661,7 @@ class QueryBuilder {
|
|
|
447
661
|
return this;
|
|
448
662
|
}
|
|
449
663
|
or(conditions) {
|
|
450
|
-
const orParts = this.splitOrConditions(conditions).map(condition =>
|
|
451
|
-
const parsed = this.splitOrCondition(condition);
|
|
452
|
-
if (!parsed) {
|
|
453
|
-
return '';
|
|
454
|
-
}
|
|
455
|
-
const { field, op, value } = parsed;
|
|
456
|
-
if (!field || !op) {
|
|
457
|
-
return '';
|
|
458
|
-
}
|
|
459
|
-
if (field.includes('.')) {
|
|
460
|
-
throw new Error('or() does not support related table filters.');
|
|
461
|
-
}
|
|
462
|
-
const validOperators = ['eq', 'neq', 'ilike', 'like', 'gt', 'gte', 'lt', 'lte', 'is'];
|
|
463
|
-
if (!validOperators.includes(op)) {
|
|
464
|
-
throw new Error(`Invalid operator: ${op}`);
|
|
465
|
-
}
|
|
466
|
-
let normalizedValue = value;
|
|
467
|
-
if (normalizedValue.startsWith('"') && normalizedValue.endsWith('"')) {
|
|
468
|
-
normalizedValue = normalizedValue.slice(1, -1);
|
|
469
|
-
}
|
|
470
|
-
normalizedValue = this.unescapeOrValue(normalizedValue);
|
|
471
|
-
const isNullValue = normalizedValue === 'null';
|
|
472
|
-
const isNowValue = typeof normalizedValue === 'string' && normalizedValue.toLowerCase() === 'now()';
|
|
473
|
-
const quotedField = this.quoteColumn(field);
|
|
474
|
-
const buildPlaceholderClause = (sqlOp) => {
|
|
475
|
-
let processedValue = normalizedValue;
|
|
476
|
-
if (isNullValue) {
|
|
477
|
-
processedValue = null;
|
|
478
|
-
}
|
|
479
|
-
else if (typeof normalizedValue === 'string' && !isNaN(Number(normalizedValue))) {
|
|
480
|
-
processedValue = normalizedValue;
|
|
481
|
-
}
|
|
482
|
-
else if (typeof normalizedValue === 'string' && normalizedValue.match(/^\d{4}-\d{2}-\d{2}/)) {
|
|
483
|
-
processedValue = normalizedValue;
|
|
484
|
-
}
|
|
485
|
-
this.whereValues.push(processedValue);
|
|
486
|
-
const paramIndex = this.whereValues.length;
|
|
487
|
-
return `${quotedField} ${sqlOp} $${paramIndex}`;
|
|
488
|
-
};
|
|
489
|
-
const buildClause = (sqlOp) => {
|
|
490
|
-
if (isNowValue) {
|
|
491
|
-
return `${quotedField} ${sqlOp} NOW()`;
|
|
492
|
-
}
|
|
493
|
-
return buildPlaceholderClause(sqlOp);
|
|
494
|
-
};
|
|
495
|
-
switch (op) {
|
|
496
|
-
case 'eq':
|
|
497
|
-
return buildClause('=');
|
|
498
|
-
case 'neq':
|
|
499
|
-
return buildClause('!=');
|
|
500
|
-
case 'ilike':
|
|
501
|
-
return buildClause('ILIKE');
|
|
502
|
-
case 'like':
|
|
503
|
-
return buildClause('LIKE');
|
|
504
|
-
case 'gt':
|
|
505
|
-
return buildClause('>');
|
|
506
|
-
case 'gte':
|
|
507
|
-
return buildClause('>=');
|
|
508
|
-
case 'lt':
|
|
509
|
-
return buildClause('<');
|
|
510
|
-
case 'lte':
|
|
511
|
-
return buildClause('<=');
|
|
512
|
-
case 'is':
|
|
513
|
-
if (isNullValue) {
|
|
514
|
-
return `${quotedField} IS NULL`;
|
|
515
|
-
}
|
|
516
|
-
return buildPlaceholderClause('IS');
|
|
517
|
-
default:
|
|
518
|
-
return '';
|
|
519
|
-
}
|
|
520
|
-
}).filter(Boolean);
|
|
664
|
+
const orParts = this.splitOrConditions(conditions).map((condition) => this.parseOrSegment(condition));
|
|
521
665
|
if (orParts.length > 0) {
|
|
522
666
|
this.whereConditions.push(`(${orParts.join(' OR ')})`);
|
|
523
667
|
}
|
|
@@ -582,6 +726,40 @@ class QueryBuilder {
|
|
|
582
726
|
return val;
|
|
583
727
|
});
|
|
584
728
|
}
|
|
729
|
+
filterUndefinedEntries(data) {
|
|
730
|
+
const filtered = {};
|
|
731
|
+
for (const [key, value] of Object.entries(data)) {
|
|
732
|
+
if (value !== undefined) {
|
|
733
|
+
filtered[key] = value;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return filtered;
|
|
737
|
+
}
|
|
738
|
+
async normalizeColumnValue(column, value, pgTypeCache) {
|
|
739
|
+
if (typeof value === 'bigint') {
|
|
740
|
+
return value.toString();
|
|
741
|
+
}
|
|
742
|
+
let pgType;
|
|
743
|
+
if (pgTypeCache) {
|
|
744
|
+
if (pgTypeCache.has(column)) {
|
|
745
|
+
const cached = pgTypeCache.get(column);
|
|
746
|
+
pgType = cached || undefined;
|
|
747
|
+
}
|
|
748
|
+
else {
|
|
749
|
+
const fetched = await this.client.getColumnPgType(String(this.schema), String(this.table), column);
|
|
750
|
+
pgTypeCache.set(column, fetched ?? '');
|
|
751
|
+
pgType = fetched;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
else {
|
|
755
|
+
pgType = await this.client.getColumnPgType(String(this.schema), String(this.table), column);
|
|
756
|
+
}
|
|
757
|
+
if ((pgType === 'json' || pgType === 'jsonb') &&
|
|
758
|
+
(Array.isArray(value) || (value !== null && typeof value === 'object' && !(value instanceof Date)))) {
|
|
759
|
+
return this.stringifyJsonValue(value);
|
|
760
|
+
}
|
|
761
|
+
return value;
|
|
762
|
+
}
|
|
585
763
|
buildWhereClause(updateValues, conditionsOverride) {
|
|
586
764
|
const baseConditions = conditionsOverride ? [...conditionsOverride] : [...this.whereConditions];
|
|
587
765
|
if (baseConditions.length === 0 && this.orConditions.length === 0) {
|
|
@@ -645,48 +823,50 @@ class QueryBuilder {
|
|
|
645
823
|
const rows = this.insertData;
|
|
646
824
|
if (rows.length === 0)
|
|
647
825
|
throw new Error('Empty array provided for insert');
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
826
|
+
const sanitizedRows = rows.map((row) => this.filterUndefinedEntries(row));
|
|
827
|
+
insertColumns = [];
|
|
828
|
+
for (const row of sanitizedRows) {
|
|
829
|
+
for (const colName of Object.keys(row)) {
|
|
830
|
+
if (!insertColumns.includes(colName)) {
|
|
831
|
+
insertColumns.push(colName);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
if (insertColumns.length === 0) {
|
|
836
|
+
throw new Error('No data provided for insert/upsert');
|
|
837
|
+
}
|
|
838
|
+
const pgTypeCache = new Map();
|
|
839
|
+
const rowPlaceholders = [];
|
|
840
|
+
for (const row of sanitizedRows) {
|
|
841
|
+
const placeholders = [];
|
|
651
842
|
for (const colName of insertColumns) { // Ensure order of values matches order of columns
|
|
652
843
|
const val = row[colName];
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
}
|
|
657
|
-
else if ((pgType === 'json' || pgType === 'jsonb') &&
|
|
658
|
-
(Array.isArray(val) || (val !== null && typeof val === 'object' && !(val instanceof Date)))) {
|
|
659
|
-
rowValues.push(this.stringifyJsonValue(val));
|
|
660
|
-
}
|
|
661
|
-
else {
|
|
662
|
-
rowValues.push(val);
|
|
844
|
+
if (val === undefined) {
|
|
845
|
+
placeholders.push('DEFAULT');
|
|
846
|
+
continue;
|
|
663
847
|
}
|
|
848
|
+
const normalizedValue = await this.normalizeColumnValue(colName, val, pgTypeCache);
|
|
849
|
+
values.push(normalizedValue);
|
|
850
|
+
placeholders.push(`$${values.length}`);
|
|
664
851
|
}
|
|
665
|
-
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
values = processedRowsValuesArrays.flat();
|
|
669
|
-
const placeholders = rows.map((_, i) => `(${insertColumns.map((_, j) => `$${i * insertColumns.length + j + 1}`).join(',')})`).join(',');
|
|
670
|
-
query = `INSERT INTO ${schemaTable} ("${insertColumns.join('","')}") VALUES ${placeholders}`;
|
|
852
|
+
rowPlaceholders.push(`(${placeholders.join(',')})`);
|
|
853
|
+
}
|
|
854
|
+
query = `INSERT INTO ${schemaTable} ("${insertColumns.join('","')}") VALUES ${rowPlaceholders.join(',')}`;
|
|
671
855
|
}
|
|
672
856
|
else {
|
|
673
|
-
const insertData = this.insertData;
|
|
857
|
+
const insertData = this.filterUndefinedEntries(this.insertData);
|
|
674
858
|
insertColumns = Object.keys(insertData);
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
});
|
|
687
|
-
values = await Promise.all(valuePromises);
|
|
688
|
-
const insertPlaceholders = values.map((_, i) => `$${i + 1}`).join(',');
|
|
689
|
-
query = `INSERT INTO ${schemaTable} ("${insertColumns.join('","')}") VALUES (${insertPlaceholders})`;
|
|
859
|
+
if (insertColumns.length === 0) {
|
|
860
|
+
query = `INSERT INTO ${schemaTable} DEFAULT VALUES`;
|
|
861
|
+
}
|
|
862
|
+
else {
|
|
863
|
+
const pgTypeCache = new Map();
|
|
864
|
+
const valuePromises = insertColumns.map((colName) => // Iterate by column name to get pgType
|
|
865
|
+
this.normalizeColumnValue(colName, insertData[colName], pgTypeCache));
|
|
866
|
+
values = await Promise.all(valuePromises);
|
|
867
|
+
const insertPlaceholders = values.map((_, i) => `$${i + 1}`).join(',');
|
|
868
|
+
query = `INSERT INTO ${schemaTable} ("${insertColumns.join('","')}") VALUES (${insertPlaceholders})`;
|
|
869
|
+
}
|
|
690
870
|
}
|
|
691
871
|
if ((this.queryType === 'UPSERT' || this.queryType === 'INSERT') && (this.conflictTarget || this.ignoreDuplicates)) {
|
|
692
872
|
const conflictTargetSQL = this.conflictTarget
|
|
@@ -697,6 +877,9 @@ class QueryBuilder {
|
|
|
697
877
|
if (!isUpsert && this.conflictTarget && !this.ignoreDuplicates) {
|
|
698
878
|
throw new Error('insert() only supports onConflict with ignoreDuplicates: true; use upsert() for updates.');
|
|
699
879
|
}
|
|
880
|
+
if (isUpsert && !this.ignoreDuplicates && insertColumns.length === 0) {
|
|
881
|
+
throw new Error('upsert() requires at least one defined column to update.');
|
|
882
|
+
}
|
|
700
883
|
query += ' ON CONFLICT';
|
|
701
884
|
if (hasTarget) {
|
|
702
885
|
query += ` (${conflictTargetSQL})`;
|
|
@@ -717,7 +900,10 @@ class QueryBuilder {
|
|
|
717
900
|
case 'UPDATE': {
|
|
718
901
|
if (!this.updateData)
|
|
719
902
|
throw new Error('No data provided for update');
|
|
720
|
-
const updateData = { ...this.updateData };
|
|
903
|
+
const updateData = this.filterUndefinedEntries({ ...this.updateData });
|
|
904
|
+
if (Object.keys(updateData).length === 0) {
|
|
905
|
+
throw new Error('No data provided for update');
|
|
906
|
+
}
|
|
721
907
|
const now = new Date().toISOString();
|
|
722
908
|
if ('modified_at' in updateData && !updateData.modified_at) {
|
|
723
909
|
updateData.modified_at = now;
|
|
@@ -726,17 +912,10 @@ class QueryBuilder {
|
|
|
726
912
|
updateData.updated_at = now;
|
|
727
913
|
}
|
|
728
914
|
const updateColumns = Object.keys(updateData);
|
|
915
|
+
const pgTypeCache = new Map();
|
|
729
916
|
const processedUpdateValuesPromises = updateColumns.map(async (colName) => {
|
|
730
917
|
const val = updateData[colName];
|
|
731
|
-
|
|
732
|
-
if (typeof val === 'bigint') {
|
|
733
|
-
return val.toString();
|
|
734
|
-
}
|
|
735
|
-
if ((pgType === 'json' || pgType === 'jsonb') &&
|
|
736
|
-
(Array.isArray(val) || (val !== null && typeof val === 'object' && !(val instanceof Date)))) {
|
|
737
|
-
return this.stringifyJsonValue(val);
|
|
738
|
-
}
|
|
739
|
-
return val;
|
|
918
|
+
return this.normalizeColumnValue(colName, val, pgTypeCache);
|
|
740
919
|
});
|
|
741
920
|
const processedUpdateValues = await Promise.all(processedUpdateValuesPromises);
|
|
742
921
|
const setColumns = updateColumns.map((key, index) => `"${String(key)}" = $${index + 1}`);
|