bunsane 0.2.8 → 0.2.9

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/CLAUDE.md CHANGED
@@ -134,6 +134,32 @@ class UserService extends BaseService {
134
134
  - PGlite: `CREATE INDEX CONCURRENTLY` must check `process.env.USE_PGLITE`
135
135
  - PGlite JSONB: pass JS objects directly, never `JSON.stringify() + ::jsonb`
136
136
 
137
+ ### Running Tests with PGlite
138
+
139
+ **IMPORTANT**: To run tests with PGlite, always use `tests/pglite-setup.ts` as the entry point. This script starts an in-memory PostgreSQL server before spawning the test runner.
140
+
141
+ ```bash
142
+ # Correct - uses pglite-setup.ts wrapper
143
+ bun run test:pglite # All tests
144
+ bun run test:pglite:unit # Unit tests only
145
+ bun tests/pglite-setup.ts tests/unit/ # Specific directory
146
+ bun tests/pglite-setup.ts path/to/file.test.ts # Single file
147
+
148
+ # WRONG - will fail with connection errors
149
+ USE_PGLITE=true bun test path/to/file.test.ts # Won't work!
150
+ ```
151
+
152
+ The wrapper script:
153
+ 1. Starts PGlite Socket server on port 54321
154
+ 2. Sets required env vars (`USE_PGLITE`, `POSTGRES_*`)
155
+ 3. Spawns `bun test` with correct configuration
156
+ 4. Cleans up server on exit
157
+
158
+ **PGlite limitations:**
159
+ - `?|` and `?&` operators not supported (use `@>` / `<@` instead)
160
+ - `CREATE INDEX CONCURRENTLY` not supported
161
+ - Single connection only (`POSTGRES_MAX_CONNECTIONS=1`)
162
+
137
163
  ## Directory Structure
138
164
 
139
165
  ```
@@ -160,9 +160,9 @@ WHERE component_types @> ARRAY[$1, $2]::text[]
160
160
  ## Migration Strategy
161
161
 
162
162
  1. New indexes are additive (no breaking changes)
163
- 2. Query changes behind feature flag: `BUNSANE_QUERY_V2=true`
164
- 3. Gradual rollout with A/B testing on query performance
165
- 4. Deprecate old patterns after validation
163
+ 2. Query optimizations are **always on** (no feature flag needed)
164
+ 3. INTERSECT + scalar subquery patterns enabled by default since v0.2.7
165
+ 4. LATERAL joins disabled for INTERSECT queries to fix SQL generation bug (2026-03-14)
166
166
 
167
167
  ## Files to Modify
168
168
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunsane",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "author": {
5
5
  "name": "yaaruu"
6
6
  },
@@ -65,6 +65,31 @@ export function buildJSONPath(field: string, alias: string): string {
65
65
  }
66
66
  }
67
67
 
68
+ /**
69
+ * Build a JSON path expression that returns a JSONB node (not text)
70
+ *
71
+ * Unlike buildJSONPath which uses ->> (text extraction) at the leaf,
72
+ * this uses -> throughout, preserving the JSONB type. Required for
73
+ * JSONB operators like @>, <@, ?|, ?& that operate on JSONB values.
74
+ *
75
+ * @param field - The field path (e.g., "tags" or "metadata.tags")
76
+ * @param alias - The table alias (e.g., "c")
77
+ * @returns PostgreSQL JSONB path expression
78
+ *
79
+ * @example
80
+ * buildJSONBPath("tags", "c") // "c.data->'tags'"
81
+ * buildJSONBPath("metadata.tags", "c") // "c.data->'metadata'->'tags'"
82
+ */
83
+ export function buildJSONBPath(field: string, alias: string): string {
84
+ if (field.includes('.')) {
85
+ const parts = field.split('.');
86
+ const lastPart = parts.pop()!;
87
+ const nestedPath = parts.map(p => `'${p}'`).join('->');
88
+ return `${alias}.data->${nestedPath}->'${lastPart}'`;
89
+ }
90
+ return `${alias}.data->'${field}'`;
91
+ }
92
+
68
93
  /**
69
94
  * Compose multiple filter builders into a single builder that applies all conditions
70
95
  *
package/query/Query.ts CHANGED
@@ -25,7 +25,11 @@ export const FilterOp = {
25
25
  LIKE: "LIKE" as FilterOperator,
26
26
  ILIKE: "ILIKE" as FilterOperator,
27
27
  IN: "IN" as FilterOperator,
28
- NOT_IN: "NOT IN" as FilterOperator
28
+ NOT_IN: "NOT IN" as FilterOperator,
29
+ CONTAINS: "CONTAINS" as FilterOperator,
30
+ CONTAINED_BY: "CONTAINED_BY" as FilterOperator,
31
+ HAS_ANY: "HAS_ANY" as FilterOperator,
32
+ HAS_ALL: "HAS_ALL" as FilterOperator,
29
33
  }
30
34
 
31
35
  export interface QueryFilter {
@@ -0,0 +1,116 @@
1
+ /**
2
+ * JSONB Array Filter Builders
3
+ *
4
+ * Provides PostgreSQL JSONB array containment and existence operators
5
+ * as custom filter builders for the BunSane Query system.
6
+ *
7
+ * Operators:
8
+ * - CONTAINS (@>) — array contains value(s)
9
+ * - CONTAINED_BY (<@) — array is subset of given values
10
+ * - HAS_ANY (?|) — array has any of the given values
11
+ * - HAS_ALL (?&) — array has all of the given values
12
+ */
13
+
14
+ import type { FilterBuilder, FilterBuilderOptions } from "../FilterBuilder";
15
+ import { buildJSONBPath } from "../FilterBuilder";
16
+ import type { QueryFilter } from "../QueryContext";
17
+ import type { QueryContext } from "../QueryContext";
18
+
19
+ export const JSONB_ARRAY_OPS = {
20
+ CONTAINS: "CONTAINS",
21
+ CONTAINED_BY: "CONTAINED_BY",
22
+ HAS_ANY: "HAS_ANY",
23
+ HAS_ALL: "HAS_ALL",
24
+ } as const;
25
+
26
+ function normalizeToArray(value: any): any[] {
27
+ return Array.isArray(value) ? value : [value];
28
+ }
29
+
30
+ function validateJsonbArrayFilter(filter: QueryFilter): boolean {
31
+ if (filter.value === null || filter.value === undefined) return false;
32
+ const arr = normalizeToArray(filter.value);
33
+ return arr.length > 0 && arr.every(
34
+ (v: any) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'
35
+ );
36
+ }
37
+
38
+ /**
39
+ * CONTAINS (@>) — "array contains value(s)"
40
+ *
41
+ * Single value: Query.filter("tags", FilterOp.CONTAINS, "urgent")
42
+ * Multiple: Query.filter("tags", FilterOp.CONTAINS, ["urgent", "high"])
43
+ */
44
+ export const jsonbContainsBuilder: FilterBuilder = (
45
+ filter: QueryFilter, alias: string, context: QueryContext
46
+ ): { sql: string; addedParams: number } => {
47
+ const jsonbPath = buildJSONBPath(filter.field, alias);
48
+ const values = normalizeToArray(filter.value);
49
+ const paramIndex = context.addParam(values);
50
+ return {
51
+ sql: `${jsonbPath} @> $${paramIndex}::jsonb`,
52
+ addedParams: 1,
53
+ };
54
+ };
55
+
56
+ /**
57
+ * CONTAINED_BY (<@) — "array is subset of given values"
58
+ *
59
+ * Query.filter("tags", FilterOp.CONTAINED_BY, ["urgent", "high", "low"])
60
+ */
61
+ export const jsonbContainedByBuilder: FilterBuilder = (
62
+ filter: QueryFilter, alias: string, context: QueryContext
63
+ ): { sql: string; addedParams: number } => {
64
+ const jsonbPath = buildJSONBPath(filter.field, alias);
65
+ const values = normalizeToArray(filter.value);
66
+ const paramIndex = context.addParam(values);
67
+ return {
68
+ sql: `${jsonbPath} <@ $${paramIndex}::jsonb`,
69
+ addedParams: 1,
70
+ };
71
+ };
72
+
73
+ /**
74
+ * HAS_ANY (?|) — "array has any of the given values"
75
+ *
76
+ * Query.filter("tags", FilterOp.HAS_ANY, ["urgent", "high"])
77
+ *
78
+ * Note: ?| operates on text[], so values are cast to strings.
79
+ */
80
+ export const jsonbHasAnyBuilder: FilterBuilder = (
81
+ filter: QueryFilter, alias: string, context: QueryContext
82
+ ): { sql: string; addedParams: number } => {
83
+ const jsonbPath = buildJSONBPath(filter.field, alias);
84
+ const values = normalizeToArray(filter.value).map(String);
85
+ const paramIndex = context.addParam(values);
86
+ return {
87
+ sql: `${jsonbPath} ?| $${paramIndex}::text[]`,
88
+ addedParams: 1,
89
+ };
90
+ };
91
+
92
+ /**
93
+ * HAS_ALL (?&) — "array has all of the given values"
94
+ *
95
+ * Query.filter("tags", FilterOp.HAS_ALL, ["urgent", "high"])
96
+ *
97
+ * Note: ?& operates on text[], so values are cast to strings.
98
+ */
99
+ export const jsonbHasAllBuilder: FilterBuilder = (
100
+ filter: QueryFilter, alias: string, context: QueryContext
101
+ ): { sql: string; addedParams: number } => {
102
+ const jsonbPath = buildJSONBPath(filter.field, alias);
103
+ const values = normalizeToArray(filter.value).map(String);
104
+ const paramIndex = context.addParam(values);
105
+ return {
106
+ sql: `${jsonbPath} ?& $${paramIndex}::text[]`,
107
+ addedParams: 1,
108
+ };
109
+ };
110
+
111
+ export const jsonbArrayOptions: FilterBuilderOptions = {
112
+ supportsLateral: true,
113
+ requiresIndex: false,
114
+ complexityScore: 1,
115
+ validate: validateJsonbArrayFilter,
116
+ };
package/query/index.ts CHANGED
@@ -17,5 +17,31 @@ export type FilterSchema<T = any> = {
17
17
 
18
18
  // Custom Filter Builder exports
19
19
  export type { FilterBuilder, FilterResult, FilterBuilderOptions } from "./FilterBuilder";
20
- export { buildJSONPath } from "./FilterBuilder";
21
- export { FilterBuilderRegistry } from "./FilterBuilderRegistry";
20
+ export { buildJSONPath, buildJSONBPath } from "./FilterBuilder";
21
+ export { FilterBuilderRegistry } from "./FilterBuilderRegistry";
22
+
23
+ // JSONB Array Builder exports
24
+ export {
25
+ jsonbContainsBuilder,
26
+ jsonbContainedByBuilder,
27
+ jsonbHasAnyBuilder,
28
+ jsonbHasAllBuilder,
29
+ jsonbArrayOptions,
30
+ JSONB_ARRAY_OPS,
31
+ } from "./builders/JsonbArrayBuilder";
32
+
33
+ // Auto-register JSONB array builders (core framework feature)
34
+ import { FilterBuilderRegistry } from "./FilterBuilderRegistry";
35
+ import {
36
+ jsonbContainsBuilder,
37
+ jsonbContainedByBuilder,
38
+ jsonbHasAnyBuilder,
39
+ jsonbHasAllBuilder,
40
+ jsonbArrayOptions,
41
+ JSONB_ARRAY_OPS,
42
+ } from "./builders/JsonbArrayBuilder";
43
+
44
+ FilterBuilderRegistry.register(JSONB_ARRAY_OPS.CONTAINS, jsonbContainsBuilder, jsonbArrayOptions, "bunsane-jsonb-array", "1.0.0");
45
+ FilterBuilderRegistry.register(JSONB_ARRAY_OPS.CONTAINED_BY, jsonbContainedByBuilder, jsonbArrayOptions, "bunsane-jsonb-array", "1.0.0");
46
+ FilterBuilderRegistry.register(JSONB_ARRAY_OPS.HAS_ANY, jsonbHasAnyBuilder, jsonbArrayOptions, "bunsane-jsonb-array", "1.0.0");
47
+ FilterBuilderRegistry.register(JSONB_ARRAY_OPS.HAS_ALL, jsonbHasAllBuilder, jsonbArrayOptions, "bunsane-jsonb-array", "1.0.0");
@@ -16,16 +16,28 @@ describe('Query Execution', () => {
16
16
  });
17
17
 
18
18
  describe('basic query execution', () => {
19
- test('exec() returns entities with specified component', async () => {
20
- const entity = ctx.tracker.create();
21
- entity.add(TestUser, { name: 'QueryTest', email: 'query@example.com', age: 30 });
22
- await entity.save();
19
+ test('exec() returns only entities with specified component', async () => {
20
+ // Positive case: entity WITH TestUser component
21
+ const withUser = ctx.tracker.create();
22
+ withUser.add(TestUser, { name: 'QueryTest', email: 'query@example.com', age: 30 });
23
+ await withUser.save();
24
+
25
+ // Negative case: entity WITHOUT TestUser component (only has TestProduct)
26
+ const withoutUser = ctx.tracker.create();
27
+ withoutUser.add(TestProduct, { sku: 'NO_USER', name: 'No User Product', price: 10, inStock: true });
28
+ await withoutUser.save();
23
29
 
24
30
  const results = await new Query()
25
31
  .with(TestUser)
26
32
  .exec();
27
33
 
28
- expect(results.length).toBeGreaterThanOrEqual(1);
34
+ // Positive: entity with TestUser should be found
35
+ const foundWithUser = results.some(e => e.id === withUser.id);
36
+ expect(foundWithUser).toBe(true);
37
+
38
+ // Negative: entity without TestUser should NOT be found
39
+ const foundWithoutUser = results.some(e => e.id === withoutUser.id);
40
+ expect(foundWithoutUser).toBe(false);
29
41
  });
30
42
 
31
43
  test('populate() loads all component data', async () => {
@@ -282,11 +294,17 @@ describe('Query Execution', () => {
282
294
  });
283
295
 
284
296
  describe('multiple components', () => {
285
- test('with() multiple components finds entities with all', async () => {
286
- const entity = ctx.tracker.create();
287
- entity.add(TestUser, { name: 'MultiComp', email: 'multi@example.com', age: 30 });
288
- entity.add(TestProduct, { sku: 'MULTI', name: 'Multi Product', price: 50, inStock: true });
289
- await entity.save();
297
+ test('with() multiple components finds only entities with ALL components', async () => {
298
+ // Positive case: entity with BOTH TestUser AND TestProduct
299
+ const withBoth = ctx.tracker.create();
300
+ withBoth.add(TestUser, { name: 'MultiComp', email: 'multi@example.com', age: 30 });
301
+ withBoth.add(TestProduct, { sku: 'MULTI', name: 'Multi Product', price: 50, inStock: true });
302
+ await withBoth.save();
303
+
304
+ // Negative case: entity with ONLY TestUser (missing TestProduct)
305
+ const withOnlyUser = ctx.tracker.create();
306
+ withOnlyUser.add(TestUser, { name: 'OnlyUser', email: 'onlyuser@example.com', age: 25 });
307
+ await withOnlyUser.save();
290
308
 
291
309
  const results = await new Query()
292
310
  .with(TestUser)
@@ -294,10 +312,15 @@ describe('Query Execution', () => {
294
312
  .populate()
295
313
  .exec();
296
314
 
297
- const found = results.find(e => e.id === entity.id);
298
- expect(found).toBeDefined();
299
- expect(found?.getInMemory(TestUser)).toBeDefined();
300
- expect(found?.getInMemory(TestProduct)).toBeDefined();
315
+ // Positive: entity with both components should be found
316
+ const foundWithBoth = results.find(e => e.id === withBoth.id);
317
+ expect(foundWithBoth).toBeDefined();
318
+ expect(foundWithBoth?.getInMemory(TestUser)).toBeDefined();
319
+ expect(foundWithBoth?.getInMemory(TestProduct)).toBeDefined();
320
+
321
+ // Negative: entity with only one component should NOT be found
322
+ const foundWithOnlyUser = results.some(e => e.id === withOnlyUser.id);
323
+ expect(foundWithOnlyUser).toBe(false);
301
324
  });
302
325
 
303
326
  test('without() excludes entities with component', async () => {
@@ -318,6 +341,36 @@ describe('Query Execution', () => {
318
341
  const hasWithProduct = results.some(e => e.id === withProduct.id);
319
342
  expect(hasWithProduct).toBe(false);
320
343
  });
344
+
345
+ test('with() 3+ components without filters finds entities with ALL components', async () => {
346
+ // Positive case: entity with ALL 3 components
347
+ const withAll = ctx.tracker.create();
348
+ withAll.add(TestUser, { name: 'AllThree', email: 'allthree@example.com', age: 30 });
349
+ withAll.add(TestProduct, { sku: 'ALL3', name: 'All Three Product', price: 100, inStock: true });
350
+ withAll.add(TestOrder, { orderNumber: 'ORD-ALL3', total: 100, status: 'pending' });
351
+ await withAll.save();
352
+
353
+ // Negative case: entity with only 2 of 3 components (missing TestOrder)
354
+ const withTwo = ctx.tracker.create();
355
+ withTwo.add(TestUser, { name: 'TwoOnly', email: 'twoonly@example.com', age: 25 });
356
+ withTwo.add(TestProduct, { sku: 'TWO', name: 'Two Only Product', price: 50, inStock: true });
357
+ await withTwo.save();
358
+
359
+ // Query for entities with all 3 components - NO FILTERS
360
+ const results = await new Query()
361
+ .with(TestUser)
362
+ .with(TestProduct)
363
+ .with(TestOrder)
364
+ .exec();
365
+
366
+ // Positive: entity with all 3 components should be found
367
+ const foundWithAll = results.some(e => e.id === withAll.id);
368
+ expect(foundWithAll).toBe(true);
369
+
370
+ // Negative: entity with only 2 components should NOT be found
371
+ const foundWithTwo = results.some(e => e.id === withTwo.id);
372
+ expect(foundWithTwo).toBe(false);
373
+ });
321
374
  });
322
375
 
323
376
  describe('excludeEntityId()', () => {
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Integration tests for JSONB Array Query Operators
3
+ * Tests CONTAINS (@>), CONTAINED_BY (<@), HAS_ANY (?|), HAS_ALL (?&)
4
+ */
5
+ import { describe, test, expect, beforeAll, beforeEach } from 'bun:test';
6
+ import { Query, FilterOp } from '../../../query/Query';
7
+ import { BaseComponent } from '../../../core/components/BaseComponent';
8
+ import { Component, CompData } from '../../../core/components/Decorators';
9
+ import { createTestContext, ensureComponentsRegistered } from '../../utils';
10
+
11
+ @Component
12
+ class TaggedItem extends BaseComponent {
13
+ @CompData({ indexed: true, arrayOf: String })
14
+ tags: string[] = [];
15
+
16
+ @CompData({ indexed: true })
17
+ name: string = '';
18
+ }
19
+
20
+ @Component
21
+ class CategoryItem extends BaseComponent {
22
+ @CompData({ indexed: true })
23
+ title: string = '';
24
+ }
25
+
26
+ const isPGlite = process.env.USE_PGLITE === 'true';
27
+
28
+ describe('JSONB Array Query Operators', () => {
29
+ const ctx = createTestContext();
30
+
31
+ beforeAll(async () => {
32
+ await ensureComponentsRegistered(TaggedItem, CategoryItem);
33
+ });
34
+
35
+ beforeEach(async () => {
36
+ // entity1: tags = ["red", "blue"]
37
+ const e1 = ctx.tracker.create();
38
+ e1.add(TaggedItem, { tags: ['red', 'blue'], name: 'item1' });
39
+ await e1.save();
40
+
41
+ // entity2: tags = ["blue", "green"]
42
+ const e2 = ctx.tracker.create();
43
+ e2.add(TaggedItem, { tags: ['blue', 'green'], name: 'item2' });
44
+ await e2.save();
45
+
46
+ // entity3: tags = ["red", "green", "blue"]
47
+ const e3 = ctx.tracker.create();
48
+ e3.add(TaggedItem, { tags: ['red', 'green', 'blue'], name: 'item3' });
49
+ await e3.save();
50
+
51
+ // entity4: tags = ["yellow"]
52
+ const e4 = ctx.tracker.create();
53
+ e4.add(TaggedItem, { tags: ['yellow'], name: 'item4' });
54
+ await e4.save();
55
+ });
56
+
57
+ describe('CONTAINS (@>)', () => {
58
+ test('finds entities where array contains a single value', async () => {
59
+ const results = await new Query()
60
+ .with(TaggedItem, {
61
+ filters: [Query.filter('tags', FilterOp.CONTAINS, 'red')]
62
+ })
63
+ .exec();
64
+
65
+ // item1 (red,blue) and item3 (red,green,blue)
66
+ expect(results.length).toBe(2);
67
+ });
68
+
69
+ test('finds entities where array contains multiple values (AND)', async () => {
70
+ const results = await new Query()
71
+ .with(TaggedItem, {
72
+ filters: [Query.filter('tags', FilterOp.CONTAINS, ['red', 'blue'])]
73
+ })
74
+ .exec();
75
+
76
+ // item1 (red,blue) and item3 (red,green,blue) both have red AND blue
77
+ expect(results.length).toBe(2);
78
+ });
79
+
80
+ test('returns empty when no match', async () => {
81
+ const results = await new Query()
82
+ .with(TaggedItem, {
83
+ filters: [Query.filter('tags', FilterOp.CONTAINS, 'purple')]
84
+ })
85
+ .exec();
86
+
87
+ expect(results.length).toBe(0);
88
+ });
89
+
90
+ test('single element array matches', async () => {
91
+ const results = await new Query()
92
+ .with(TaggedItem, {
93
+ filters: [Query.filter('tags', FilterOp.CONTAINS, 'yellow')]
94
+ })
95
+ .exec();
96
+
97
+ expect(results.length).toBe(1);
98
+ });
99
+
100
+ test('combined with other filters', async () => {
101
+ const results = await new Query()
102
+ .with(TaggedItem, {
103
+ filters: [
104
+ Query.filter('tags', FilterOp.CONTAINS, 'red'),
105
+ Query.filter('name', FilterOp.EQ, 'item1'),
106
+ ]
107
+ })
108
+ .exec();
109
+
110
+ expect(results.length).toBe(1);
111
+ });
112
+ });
113
+
114
+ describe('CONTAINED_BY (<@)', () => {
115
+ test('finds entities whose array is a subset of given values', async () => {
116
+ const results = await new Query()
117
+ .with(TaggedItem, {
118
+ filters: [Query.filter('tags', FilterOp.CONTAINED_BY, ['red', 'blue'])]
119
+ })
120
+ .exec();
121
+
122
+ // Only item1 (red,blue) is a subset of [red,blue]
123
+ expect(results.length).toBe(1);
124
+ });
125
+
126
+ test('superset input matches all subsets', async () => {
127
+ const results = await new Query()
128
+ .with(TaggedItem, {
129
+ filters: [Query.filter('tags', FilterOp.CONTAINED_BY, ['red', 'blue', 'green', 'yellow'])]
130
+ })
131
+ .exec();
132
+
133
+ // All 4 entities are subsets
134
+ expect(results.length).toBe(4);
135
+ });
136
+ });
137
+
138
+ describe.skipIf(isPGlite)('HAS_ANY (?|)', () => {
139
+ test('finds entities with any of the given values', async () => {
140
+ const results = await new Query()
141
+ .with(TaggedItem, {
142
+ filters: [Query.filter('tags', FilterOp.HAS_ANY, ['red', 'yellow'])]
143
+ })
144
+ .exec();
145
+
146
+ // item1 (red), item3 (red), item4 (yellow)
147
+ expect(results.length).toBe(3);
148
+ });
149
+
150
+ test('single value works', async () => {
151
+ const results = await new Query()
152
+ .with(TaggedItem, {
153
+ filters: [Query.filter('tags', FilterOp.HAS_ANY, ['yellow'])]
154
+ })
155
+ .exec();
156
+
157
+ expect(results.length).toBe(1);
158
+ });
159
+ });
160
+
161
+ describe.skipIf(isPGlite)('HAS_ALL (?&)', () => {
162
+ test('finds entities with all of the given values', async () => {
163
+ const results = await new Query()
164
+ .with(TaggedItem, {
165
+ filters: [Query.filter('tags', FilterOp.HAS_ALL, ['red', 'green'])]
166
+ })
167
+ .exec();
168
+
169
+ // Only item3 (red,green,blue) has both red AND green
170
+ expect(results.length).toBe(1);
171
+ });
172
+
173
+ test('single value matches all entities containing it', async () => {
174
+ const results = await new Query()
175
+ .with(TaggedItem, {
176
+ filters: [Query.filter('tags', FilterOp.HAS_ALL, ['blue'])]
177
+ })
178
+ .exec();
179
+
180
+ // item1, item2, item3 all have blue
181
+ expect(results.length).toBe(3);
182
+ });
183
+ });
184
+
185
+ describe('multi-component INTERSECT compatibility', () => {
186
+ test('CONTAINS works with multiple .with() components', async () => {
187
+ // Add a second component to one entity
188
+ const e = ctx.tracker.create();
189
+ e.add(TaggedItem, { tags: ['special'], name: 'multi' });
190
+ e.add(CategoryItem, { title: 'test-category' });
191
+ await e.save();
192
+
193
+ const results = await new Query()
194
+ .with(TaggedItem, {
195
+ filters: [Query.filter('tags', FilterOp.CONTAINS, 'special')]
196
+ })
197
+ .with(CategoryItem)
198
+ .exec();
199
+
200
+ expect(results.length).toBe(1);
201
+ });
202
+ });
203
+
204
+ describe('validation', () => {
205
+ test('rejects null value via validator', () => {
206
+ expect(() => {
207
+ new Query()
208
+ .with(TaggedItem, {
209
+ filters: [Query.filter('tags', FilterOp.CONTAINS, null)]
210
+ });
211
+ }).not.toThrow(); // Filter creation succeeds, validation happens at exec time
212
+ });
213
+ });
214
+ });
@@ -35,6 +35,7 @@ const proc = spawn('bun', ['test', ...testDirs], {
35
35
  env: {
36
36
  ...process.env,
37
37
  USE_PGLITE: 'true',
38
+ DB_CONNECTION_URL: '', // Clear to use POSTGRES_* vars
38
39
  POSTGRES_HOST: 'localhost',
39
40
  POSTGRES_PORT: String(PORT),
40
41
  POSTGRES_USER: 'postgres',
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Unit tests for JSONB Array Filter Builders
3
+ */
4
+ import { describe, test, expect } from 'bun:test';
5
+ import { buildJSONBPath } from '../../../query/FilterBuilder';
6
+ import { QueryContext } from '../../../query/QueryContext';
7
+ import {
8
+ jsonbContainsBuilder,
9
+ jsonbContainedByBuilder,
10
+ jsonbHasAnyBuilder,
11
+ jsonbHasAllBuilder,
12
+ jsonbArrayOptions,
13
+ } from '../../../query/builders/JsonbArrayBuilder';
14
+
15
+ describe('buildJSONBPath', () => {
16
+ test('simple field returns JSONB node path', () => {
17
+ expect(buildJSONBPath('tags', 'c')).toBe("c.data->'tags'");
18
+ });
19
+
20
+ test('nested field returns JSONB node path', () => {
21
+ expect(buildJSONBPath('metadata.tags', 'c')).toBe("c.data->'metadata'->'tags'");
22
+ });
23
+
24
+ test('deeply nested field', () => {
25
+ expect(buildJSONBPath('a.b.c', 'c')).toBe("c.data->'a'->'b'->'c'");
26
+ });
27
+
28
+ test('uses provided alias', () => {
29
+ expect(buildJSONBPath('tags', 'comp')).toBe("comp.data->'tags'");
30
+ });
31
+ });
32
+
33
+ describe('jsonbContainsBuilder (@>)', () => {
34
+ test('single string value is auto-wrapped in array', () => {
35
+ const ctx = new QueryContext();
36
+ const result = jsonbContainsBuilder(
37
+ { field: 'tags', operator: 'CONTAINS', value: 'urgent' },
38
+ 'c', ctx
39
+ );
40
+ expect(result.sql).toBe("c.data->'tags' @> $1::jsonb");
41
+ expect(ctx.params[0]).toEqual(['urgent']);
42
+ expect(result.addedParams).toBe(1);
43
+ });
44
+
45
+ test('array value is passed as raw array', () => {
46
+ const ctx = new QueryContext();
47
+ const result = jsonbContainsBuilder(
48
+ { field: 'tags', operator: 'CONTAINS', value: ['a', 'b'] },
49
+ 'c', ctx
50
+ );
51
+ expect(result.sql).toBe("c.data->'tags' @> $1::jsonb");
52
+ expect(ctx.params[0]).toEqual(['a', 'b']);
53
+ });
54
+
55
+ test('nested field path', () => {
56
+ const ctx = new QueryContext();
57
+ const result = jsonbContainsBuilder(
58
+ { field: 'meta.tags', operator: 'CONTAINS', value: 'x' },
59
+ 'c', ctx
60
+ );
61
+ expect(result.sql).toBe("c.data->'meta'->'tags' @> $1::jsonb");
62
+ });
63
+
64
+ test('numeric value', () => {
65
+ const ctx = new QueryContext();
66
+ jsonbContainsBuilder(
67
+ { field: 'scores', operator: 'CONTAINS', value: 42 },
68
+ 'c', ctx
69
+ );
70
+ expect(ctx.params[0]).toEqual([42]);
71
+ });
72
+ });
73
+
74
+ describe('jsonbContainedByBuilder (<@)', () => {
75
+ test('generates correct SQL', () => {
76
+ const ctx = new QueryContext();
77
+ const result = jsonbContainedByBuilder(
78
+ { field: 'tags', operator: 'CONTAINED_BY', value: ['a', 'b', 'c'] },
79
+ 'c', ctx
80
+ );
81
+ expect(result.sql).toBe("c.data->'tags' <@ $1::jsonb");
82
+ expect(ctx.params[0]).toEqual(['a', 'b', 'c']);
83
+ expect(result.addedParams).toBe(1);
84
+ });
85
+
86
+ test('single value is auto-wrapped', () => {
87
+ const ctx = new QueryContext();
88
+ jsonbContainedByBuilder(
89
+ { field: 'tags', operator: 'CONTAINED_BY', value: 'only' },
90
+ 'c', ctx
91
+ );
92
+ expect(ctx.params[0]).toEqual(['only']);
93
+ });
94
+ });
95
+
96
+ describe('jsonbHasAnyBuilder (?|)', () => {
97
+ test('generates correct SQL with text[] cast', () => {
98
+ const ctx = new QueryContext();
99
+ const result = jsonbHasAnyBuilder(
100
+ { field: 'tags', operator: 'HAS_ANY', value: ['a', 'b'] },
101
+ 'c', ctx
102
+ );
103
+ expect(result.sql).toBe("c.data->'tags' ?| $1::text[]");
104
+ expect(ctx.params[0]).toEqual(['a', 'b']);
105
+ expect(result.addedParams).toBe(1);
106
+ });
107
+
108
+ test('single value is auto-wrapped and stringified', () => {
109
+ const ctx = new QueryContext();
110
+ jsonbHasAnyBuilder(
111
+ { field: 'tags', operator: 'HAS_ANY', value: 'solo' },
112
+ 'c', ctx
113
+ );
114
+ expect(ctx.params[0]).toEqual(['solo']);
115
+ });
116
+
117
+ test('numeric values are cast to strings', () => {
118
+ const ctx = new QueryContext();
119
+ jsonbHasAnyBuilder(
120
+ { field: 'ids', operator: 'HAS_ANY', value: [1, 2, 3] },
121
+ 'c', ctx
122
+ );
123
+ expect(ctx.params[0]).toEqual(['1', '2', '3']);
124
+ });
125
+ });
126
+
127
+ describe('jsonbHasAllBuilder (?&)', () => {
128
+ test('generates correct SQL with text[] cast', () => {
129
+ const ctx = new QueryContext();
130
+ const result = jsonbHasAllBuilder(
131
+ { field: 'tags', operator: 'HAS_ALL', value: ['x', 'y'] },
132
+ 'c', ctx
133
+ );
134
+ expect(result.sql).toBe("c.data->'tags' ?& $1::text[]");
135
+ expect(ctx.params[0]).toEqual(['x', 'y']);
136
+ expect(result.addedParams).toBe(1);
137
+ });
138
+ });
139
+
140
+ describe('validation', () => {
141
+ const validate = jsonbArrayOptions.validate!;
142
+
143
+ test('rejects null value', () => {
144
+ expect(validate({ field: 'f', operator: 'CONTAINS', value: null })).toBe(false);
145
+ });
146
+
147
+ test('rejects undefined value', () => {
148
+ expect(validate({ field: 'f', operator: 'CONTAINS', value: undefined })).toBe(false);
149
+ });
150
+
151
+ test('rejects empty array', () => {
152
+ expect(validate({ field: 'f', operator: 'CONTAINS', value: [] })).toBe(false);
153
+ });
154
+
155
+ test('accepts string value', () => {
156
+ expect(validate({ field: 'f', operator: 'CONTAINS', value: 'tag' })).toBe(true);
157
+ });
158
+
159
+ test('accepts number value', () => {
160
+ expect(validate({ field: 'f', operator: 'CONTAINS', value: 42 })).toBe(true);
161
+ });
162
+
163
+ test('accepts boolean value', () => {
164
+ expect(validate({ field: 'f', operator: 'CONTAINS', value: true })).toBe(true);
165
+ });
166
+
167
+ test('accepts array of strings', () => {
168
+ expect(validate({ field: 'f', operator: 'CONTAINS', value: ['a', 'b'] })).toBe(true);
169
+ });
170
+
171
+ test('rejects object value', () => {
172
+ expect(validate({ field: 'f', operator: 'CONTAINS', value: { key: 'val' } })).toBe(false);
173
+ });
174
+
175
+ test('rejects array with non-primitive elements', () => {
176
+ expect(validate({ field: 'f', operator: 'CONTAINS', value: [{ a: 1 }] })).toBe(false);
177
+ });
178
+ });