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 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, `now()` is inlined as `NOW()`); quote values to include dots/commas (e.g. `name.eq."last, first"`)
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
- - Supported ops: `eq`, `neq`, `like`, `ilike`, `gt`, `gte`, `lt`, `lte`, `is`.
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
- - Multi-row inserts use the first row's keys as column order.
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.
@@ -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;
@@ -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 (char === ',' && !inQuotes) {
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
- let firstDot = -1;
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
- if (firstDot === -1) {
111
- firstDot = i;
112
- }
113
- else {
114
- secondDot = i;
115
- break;
116
- }
131
+ dotPositions.push(i);
117
132
  }
118
133
  }
119
- if (firstDot === -1 || secondDot === -1) {
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
- insertColumns = Object.keys(rows[0]);
649
- const processedRowsValuesPromises = rows.map(async (row) => {
650
- const rowValues = [];
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
- const pgType = await this.client.getColumnPgType(String(this.schema), String(this.table), colName);
654
- if (typeof val === 'bigint') {
655
- rowValues.push(val.toString());
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
- return rowValues;
666
- });
667
- const processedRowsValuesArrays = await Promise.all(processedRowsValuesPromises);
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
- const valuePromises = insertColumns.map(async (colName) => {
676
- const val = insertData[colName];
677
- const pgType = await this.client.getColumnPgType(String(this.schema), String(this.table), colName);
678
- if (typeof val === 'bigint') {
679
- return val.toString();
680
- }
681
- if ((pgType === 'json' || pgType === 'jsonb') &&
682
- (Array.isArray(val) || (val !== null && typeof val === 'object' && !(val instanceof Date)))) {
683
- return this.stringifyJsonValue(val);
684
- }
685
- return val;
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
- const pgType = await this.client.getColumnPgType(String(this.schema), String(this.table), colName);
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}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "supalite",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "A lightweight TypeScript PostgreSQL client with Supabase-style API",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",