bunsane 0.5.1 → 0.5.3

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.
@@ -3,6 +3,7 @@ import type { QueryResult } from "./QueryNode";
3
3
  import { QueryContext } from "./QueryContext";
4
4
  import { shouldUseLateralJoins, shouldUseDirectPartition } from "../core/Config";
5
5
  import { FilterBuilderRegistry } from "./FilterBuilderRegistry";
6
+ import { jsonbInListCast } from "./FilterBuilder";
6
7
  import { ComponentRegistry } from "../core/components";
7
8
  import { getMetadataStorage } from "../core/metadata";
8
9
  import { assertIdentifier } from "./SqlIdentifier";
@@ -95,8 +96,9 @@ export class ComponentInclusionNode extends QueryNode {
95
96
  return `${jsonPath} ${filter.operator} $${context.addParam(filter.value)}`;
96
97
  } else if (filter.operator === 'IN' || filter.operator === 'NOT IN') {
97
98
  if (Array.isArray(filter.value) && filter.value.length > 0) {
98
- const placeholders = filter.value.map((v: any) => `$${context.addParam(v)}`).join(', ');
99
- return `${jsonPath} ${filter.operator} (${placeholders})`;
99
+ const cast = jsonbInListCast(filter.value);
100
+ const placeholders = filter.value.map((v: any) => `$${context.addParam(v)}${cast.param}`).join(', ');
101
+ return `${cast.lhs(jsonPath)} ${filter.operator} (${placeholders})`;
100
102
  } else if (Array.isArray(filter.value) && filter.value.length === 0) {
101
103
  return filter.operator === 'IN' ? 'FALSE' : 'TRUE';
102
104
  }
@@ -665,8 +667,9 @@ export class ComponentInclusionNode extends QueryNode {
665
667
  condition = `(${jsonPath})::boolean ${filter.operator} $${context.addParam(filter.value)}`;
666
668
  } else if (filter.operator === 'IN' || filter.operator === 'NOT IN') {
667
669
  if (Array.isArray(filter.value) && filter.value.length > 0) {
668
- const placeholders = filter.value.map((v: any) => `$${context.addParam(v)}`).join(', ');
669
- condition = `${jsonPath} ${filter.operator} (${placeholders})`;
670
+ const cast = jsonbInListCast(filter.value);
671
+ const placeholders = filter.value.map((v: any) => `$${context.addParam(v)}${cast.param}`).join(', ');
672
+ condition = `${cast.lhs(jsonPath)} ${filter.operator} (${placeholders})`;
670
673
  } else {
671
674
  return null; // Invalid or empty array - fall back to normal path
672
675
  }
@@ -860,14 +863,17 @@ export class ComponentInclusionNode extends QueryNode {
860
863
  // String LIKE/ILIKE comparison - no casting
861
864
  condition = `${jsonPath} ${filter.operator} $${context.addParam(filter.value)}`;
862
865
  } else if (filter.operator === 'IN' || filter.operator === 'NOT IN') {
863
- // IN/NOT IN comparison - handle arrays properly
866
+ // IN/NOT IN comparison - handle arrays properly. Numeric/
867
+ // boolean lists need a cast on both the JSONB text field
868
+ // and each parameter, else `text IN (1, 2)` errors.
864
869
  if (Array.isArray(filter.value) && filter.value.length > 0) {
870
+ const cast = jsonbInListCast(filter.value);
865
871
  let placeholders = '';
866
872
  for (let i = 0; i < filter.value.length; i++) {
867
873
  if (i) placeholders += ', ';
868
- placeholders += '$' + context.addParam(filter.value[i]);
874
+ placeholders += '$' + context.addParam(filter.value[i]) + cast.param;
869
875
  }
870
- condition = `${jsonPath} ${filter.operator} (${placeholders})`;
876
+ condition = `${cast.lhs(jsonPath)} ${filter.operator} (${placeholders})`;
871
877
  } else if (Array.isArray(filter.value) && filter.value.length === 0) {
872
878
  // Empty array: IN () is always false, NOT IN () is always true
873
879
  condition = filter.operator === 'IN' ? 'FALSE' : 'TRUE';
@@ -90,6 +90,25 @@ export function buildJSONBPath(field: string, alias: string): string {
90
90
  return `${alias}.data->'${field}'`;
91
91
  }
92
92
 
93
+ /**
94
+ * Determine the type cast for an `IN` / `NOT IN` value list against a JSONB
95
+ * text-extracted field (`data->>'x'` always yields text). Without a cast a
96
+ * numeric/boolean list produces `text IN (1, 2)` → PostgreSQL "operator does
97
+ * not exist: text = integer". When every element is a number (or boolean) we
98
+ * cast both the field and each parameter, mirroring the scalar `=` path. Mixed
99
+ * or string lists stay as plain text comparison (the correct default).
100
+ *
101
+ * @returns `lhs(path)` wraps the field expression; `param` is the per-parameter
102
+ * cast suffix (e.g. `::numeric`), `''` for text.
103
+ */
104
+ export function jsonbInListCast(values: any[]): { lhs: (path: string) => string; param: string } {
105
+ const allNumbers = values.length > 0 && values.every(v => typeof v === 'number');
106
+ if (allNumbers) return { lhs: (p) => `(${p})::numeric`, param: '::numeric' };
107
+ const allBooleans = values.length > 0 && values.every(v => typeof v === 'boolean');
108
+ if (allBooleans) return { lhs: (p) => `(${p})::boolean`, param: '::boolean' };
109
+ return { lhs: (p) => p, param: '' };
110
+ }
111
+
93
112
  /**
94
113
  * Compose multiple filter builders into a single builder that applies all conditions
95
114
  *
package/query/OrNode.ts CHANGED
@@ -5,6 +5,7 @@ import { OrQuery } from "./OrQuery";
5
5
  import { ComponentRegistry } from "../core/components";
6
6
  import { shouldUseDirectPartition } from "../core/Config";
7
7
  import { getMembershipTable } from "./membershipSource";
8
+ import { jsonbInListCast } from "./FilterBuilder";
8
9
 
9
10
  export class OrNode extends QueryNode {
10
11
  private orQuery: OrQuery;
@@ -111,15 +112,17 @@ export class OrNode extends QueryNode {
111
112
  break;
112
113
  case "IN":
113
114
  if (Array.isArray(value)) {
114
- const placeholders = value.map(() => `$${paramIndex++}`).join(', ');
115
- branchSql += ` AND ${jsonPath} IN (${placeholders})`;
115
+ const cast = jsonbInListCast(value);
116
+ const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
117
+ branchSql += ` AND ${cast.lhs(jsonPath)} IN (${placeholders})`;
116
118
  context.params.push(...value);
117
119
  }
118
120
  break;
119
121
  case "NOT IN":
120
122
  if (Array.isArray(value)) {
121
- const placeholders = value.map(() => `$${paramIndex++}`).join(', ');
122
- branchSql += ` AND ${jsonPath} NOT IN (${placeholders})`;
123
+ const cast = jsonbInListCast(value);
124
+ const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
125
+ branchSql += ` AND ${cast.lhs(jsonPath)} NOT IN (${placeholders})`;
123
126
  context.params.push(...value);
124
127
  }
125
128
  break;
@@ -150,7 +153,7 @@ export class OrNode extends QueryNode {
150
153
  if (context.excludedComponentIds.size > 0) {
151
154
  const excludedTypes = Array.from(context.excludedComponentIds);
152
155
  const placeholders = excludedTypes.map(() => `$${paramIndex++}`).join(', ');
153
- conditions.push(`NOT EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_ex WHERE ec_ex.entity_id = or_results.id AND ec_ex.type_id IN (${placeholders}) AND ec_ex.deleted_at IS NULL)`);
156
+ conditions.push(`NOT EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_ex WHERE ec_ex.entity_id = or_results.entity_id AND ec_ex.type_id IN (${placeholders}) AND ec_ex.deleted_at IS NULL)`);
154
157
  context.params.push(...excludedTypes);
155
158
  }
156
159
 
@@ -232,15 +235,17 @@ export class OrNode extends QueryNode {
232
235
  break;
233
236
  case "IN":
234
237
  if (Array.isArray(value)) {
235
- const placeholders = value.map(() => `$${paramIndex++}`).join(', ');
236
- conditions.push(`${jsonPath} IN (${placeholders})`);
238
+ const cast = jsonbInListCast(value);
239
+ const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
240
+ conditions.push(`${cast.lhs(jsonPath)} IN (${placeholders})`);
237
241
  context.params.push(...value);
238
242
  }
239
243
  break;
240
244
  case "NOT IN":
241
245
  if (Array.isArray(value)) {
242
- const placeholders = value.map(() => `$${paramIndex++}`).join(', ');
243
- conditions.push(`${jsonPath} NOT IN (${placeholders})`);
246
+ const cast = jsonbInListCast(value);
247
+ const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
248
+ conditions.push(`${cast.lhs(jsonPath)} NOT IN (${placeholders})`);
244
249
  context.params.push(...value);
245
250
  }
246
251
  break;
@@ -329,10 +334,29 @@ export class OrNode extends QueryNode {
329
334
  let baseEntityQuery = "";
330
335
 
331
336
  if (hasComponentDependency) {
332
- // Get base entities from ComponentInclusionNode
337
+ // Get base entities from ComponentInclusionNode.
338
+ //
339
+ // CRITICAL: the base set must be UNBOUNDED. OrNode embeds this SQL
340
+ // as `FROM (base) WHERE EXISTS (<or filter>)` and applies LIMIT/
341
+ // OFFSET to the *final* OR-filtered result below. If the base node
342
+ // bakes the caller's LIMIT/OFFSET into its own SQL, the EXISTS OR
343
+ // filter only ever sees the first page of base entities (ordered by
344
+ // entity_id), so any match beyond that page silently vanishes —
345
+ // e.g. a search whose only hits live on page 2+ returns 0 rows
346
+ // while count() (which strips pagination) reports them. Null out
347
+ // pagination around the base build, then restore so the final
348
+ // pagination below is unaffected. cursorId is left intact: it
349
+ // constrains the candidate set (entity_id > cursor) which composes
350
+ // correctly with the final LIMIT.
333
351
  const componentNode = this.dependencies[0];
334
352
  if (componentNode) {
353
+ const savedLimit = context.limit;
354
+ const savedOffset = context.offsetValue;
355
+ context.limit = null;
356
+ context.offsetValue = 0;
335
357
  const baseResult = componentNode.execute(context);
358
+ context.limit = savedLimit;
359
+ context.offsetValue = savedOffset;
336
360
  baseEntityQuery = baseResult.sql;
337
361
  paramIndex = baseResult.context.paramIndex;
338
362
  }
@@ -408,15 +432,17 @@ export class OrNode extends QueryNode {
408
432
  break;
409
433
  case "IN":
410
434
  if (Array.isArray(value)) {
411
- const placeholders = value.map(() => `$${paramIndex++}`).join(', ');
412
- filterConditions.push(`${jsonPath} IN (${placeholders})`);
435
+ const cast = jsonbInListCast(value);
436
+ const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
437
+ filterConditions.push(`${cast.lhs(jsonPath)} IN (${placeholders})`);
413
438
  context.params.push(...value);
414
439
  }
415
440
  break;
416
441
  case "NOT IN":
417
442
  if (Array.isArray(value)) {
418
- const placeholders = value.map(() => `$${paramIndex++}`).join(', ');
419
- filterConditions.push(`${jsonPath} NOT IN (${placeholders})`);
443
+ const cast = jsonbInListCast(value);
444
+ const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
445
+ filterConditions.push(`${cast.lhs(jsonPath)} NOT IN (${placeholders})`);
420
446
  context.params.push(...value);
421
447
  }
422
448
  break;
@@ -447,7 +473,7 @@ export class OrNode extends QueryNode {
447
473
  for (const componentType of allComponentTypes) {
448
474
  const componentId = ComponentRegistry.getComponentId(componentType);
449
475
  if (componentId) {
450
- componentConditions.push(`EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_all WHERE ec_all.entity_id = or_results.id AND ec_all.type_id = $${paramIndex} AND ec_all.deleted_at IS NULL)`);
476
+ componentConditions.push(`EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_all WHERE ec_all.entity_id = or_results.entity_id AND ec_all.type_id = $${paramIndex} AND ec_all.deleted_at IS NULL)`);
451
477
  context.params.push(componentId);
452
478
  paramIndex++;
453
479
  }
@@ -469,7 +495,7 @@ export class OrNode extends QueryNode {
469
495
  if (context.excludedComponentIds.size > 0) {
470
496
  const excludedTypes = Array.from(context.excludedComponentIds);
471
497
  const placeholders = excludedTypes.map(() => `$${paramIndex++}`).join(', ');
472
- conditions.push(`NOT EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_ex WHERE ec_ex.entity_id = or_results.id AND ec_ex.type_id IN (${placeholders}) AND ec_ex.deleted_at IS NULL)`);
498
+ conditions.push(`NOT EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_ex WHERE ec_ex.entity_id = or_results.entity_id AND ec_ex.type_id IN (${placeholders}) AND ec_ex.deleted_at IS NULL)`);
473
499
  context.params.push(...excludedTypes);
474
500
  }
475
501