supalite 0.8.1 → 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,17 @@
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
+
3
15
  ## [0.8.1] - 2026-02-03
4
16
 
5
17
  ### 🐞 Fixed
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
@@ -1032,7 +1044,11 @@ await client.close();
1032
1044
  - `is(column, value)`: IS
1033
1045
  - `not(column, operator, value)`: currently only `not('column', 'is', null)` is supported
1034
1046
  - `contains(column, value)`: array/JSON contains
1035
- - `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()`
1036
1052
 
1037
1053
  ### Other methods
1038
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`.
@@ -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;
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "supalite",
3
- "version": "0.8.1",
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",