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 +26 -0
- package/docs/SCALABILITY_PLAN.md +3 -3
- package/package.json +1 -1
- package/query/FilterBuilder.ts +25 -0
- package/query/Query.ts +5 -1
- package/query/builders/JsonbArrayBuilder.ts +116 -0
- package/query/index.ts +28 -2
- package/tests/integration/query/Query.exec.test.ts +67 -14
- package/tests/integration/query/Query.jsonbArray.test.ts +214 -0
- package/tests/pglite-setup.ts +1 -0
- package/tests/unit/query/JsonbArrayBuilder.test.ts +178 -0
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
|
```
|
package/docs/SCALABILITY_PLAN.md
CHANGED
|
@@ -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
|
|
164
|
-
3.
|
|
165
|
-
4.
|
|
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
package/query/FilterBuilder.ts
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
expect(
|
|
300
|
-
expect(
|
|
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
|
+
});
|
package/tests/pglite-setup.ts
CHANGED
|
@@ -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
|
+
});
|