bunsane 0.2.7 → 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.7",
3
+ "version": "0.2.9",
4
4
  "author": {
5
5
  "name": "yaaruu"
6
6
  },
@@ -54,11 +54,15 @@ export class ComponentInclusionNode extends QueryNode {
54
54
 
55
55
  let sql = "";
56
56
  const componentCount = componentIds.length;
57
- const useLateralJoins = Boolean(shouldUseLateralJoins());
58
57
 
59
58
  // Check if CTE is available and use it to avoid redundant entity_components scans
60
59
  const useCTE = Boolean(context.hasCTE && context.cteName);
61
60
 
61
+ // LATERAL joins don't work correctly with INTERSECT queries (non-CTE multi-component)
62
+ // because the SQL insertion logic places joins inside the INTERSECT subqueries
63
+ const isIntersectQuery = componentCount > 1 && !useCTE;
64
+ const useLateralJoins = Boolean(shouldUseLateralJoins()) && !isIntersectQuery;
65
+
62
66
  // Collect LATERAL join fragments if using LATERAL joins
63
67
  const lateralJoins: string[] = [];
64
68
  const lateralConditions: string[] = [];
@@ -430,11 +434,11 @@ export class ComponentInclusionNode extends QueryNode {
430
434
  } else if (typeof filter.value === 'boolean') {
431
435
  condition = `(${jsonPath})::boolean ${filter.operator} $${context.addParam(filter.value)}`;
432
436
  } else if (filter.operator === 'IN' || filter.operator === 'NOT IN') {
433
- if (Array.isArray(filter.value)) {
437
+ if (Array.isArray(filter.value) && filter.value.length > 0) {
434
438
  const placeholders = filter.value.map((v: any) => `$${context.addParam(v)}`).join(', ');
435
439
  condition = `${jsonPath} ${filter.operator} (${placeholders})`;
436
440
  } else {
437
- return null; // Invalid - fall back to normal path
441
+ return null; // Invalid or empty array - fall back to normal path
438
442
  }
439
443
  } else if (filter.operator === 'LIKE' || filter.operator === 'NOT LIKE' || filter.operator === 'ILIKE') {
440
444
  condition = `${jsonPath} ${filter.operator} $${context.addParam(filter.value)}::text`;
@@ -445,6 +449,9 @@ export class ComponentInclusionNode extends QueryNode {
445
449
  filterConditions.push(condition);
446
450
  }
447
451
 
452
+ // Guard: if no conditions were built, fall back to normal path
453
+ if (filterConditions.length === 0) return null;
454
+
448
455
  const nullsClause = sortOrder.nullsFirst ? 'NULLS FIRST' : 'NULLS LAST';
449
456
  const isNumeric = isNumericProperty(sortOrder.component, sortOrder.property);
450
457
  const sortExpr = isNumeric
@@ -622,9 +629,12 @@ export class ComponentInclusionNode extends QueryNode {
622
629
  condition = `${jsonPath} ${filter.operator} $${context.addParam(filter.value)}`;
623
630
  } else if (filter.operator === 'IN' || filter.operator === 'NOT IN') {
624
631
  // IN/NOT IN comparison - handle arrays properly
625
- if (Array.isArray(filter.value)) {
632
+ if (Array.isArray(filter.value) && filter.value.length > 0) {
626
633
  const placeholders = Array.from({length: filter.value.length}, (_, i) => `$${context.addParam(filter.value[i])}`).join(', ');
627
634
  condition = `${jsonPath} ${filter.operator} (${placeholders})`;
635
+ } else if (Array.isArray(filter.value) && filter.value.length === 0) {
636
+ // Empty array: IN () is always false, NOT IN () is always true
637
+ condition = filter.operator === 'IN' ? 'FALSE' : 'TRUE';
628
638
  } else {
629
639
  throw new Error(`${filter.operator} operator requires an array of values`);
630
640
  }
@@ -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");
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Benchmark: LATERAL joins vs EXISTS subqueries for multi-component queries
3
+ *
4
+ * Tests the performance impact of the INTERSECT query fix that disables
5
+ * LATERAL joins for multi-component non-CTE queries.
6
+ */
7
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
8
+ import { Entity } from '../../core/Entity';
9
+ import { Query, FilterOp } from '../../query/Query';
10
+ import { Component, CompData, BaseComponent } from '../../core/components';
11
+ import { ComponentRegistry } from '../../core/components/ComponentRegistry';
12
+ import { ensureComponentsRegistered } from '../utils';
13
+
14
+ // Benchmark components
15
+ @Component
16
+ class BenchUser extends BaseComponent {
17
+ @CompData({ indexed: true }) name: string = '';
18
+ @CompData({ indexed: true }) email: string = '';
19
+ @CompData() age: number = 0;
20
+ @CompData() status: string = 'active';
21
+ }
22
+
23
+ @Component
24
+ class BenchProfile extends BaseComponent {
25
+ @CompData({ indexed: true }) username: string = '';
26
+ @CompData() bio: string = '';
27
+ @CompData() verified: boolean = false;
28
+ }
29
+
30
+ @Component
31
+ class BenchSettings extends BaseComponent {
32
+ @CompData() theme: string = 'light';
33
+ @CompData() notifications: boolean = true;
34
+ @CompData() language: string = 'en';
35
+ }
36
+
37
+ // Test configuration
38
+ const DATASET_SIZES = {
39
+ small: 100,
40
+ medium: 1000,
41
+ large: 5000
42
+ };
43
+
44
+ const ITERATIONS = 5; // Number of times to run each query for averaging
45
+
46
+ interface BenchmarkResult {
47
+ name: string;
48
+ datasetSize: number;
49
+ avgTimeMs: number;
50
+ minTimeMs: number;
51
+ maxTimeMs: number;
52
+ resultCount: number;
53
+ }
54
+
55
+ async function runBenchmark(
56
+ name: string,
57
+ datasetSize: number,
58
+ queryFn: () => Promise<Entity[]>
59
+ ): Promise<BenchmarkResult> {
60
+ const times: number[] = [];
61
+ let resultCount = 0;
62
+
63
+ for (let i = 0; i < ITERATIONS; i++) {
64
+ const start = performance.now();
65
+ const results = await queryFn();
66
+ const end = performance.now();
67
+ times.push(end - start);
68
+ resultCount = results.length;
69
+ }
70
+
71
+ return {
72
+ name,
73
+ datasetSize,
74
+ avgTimeMs: times.reduce((a, b) => a + b, 0) / times.length,
75
+ minTimeMs: Math.min(...times),
76
+ maxTimeMs: Math.max(...times),
77
+ resultCount
78
+ };
79
+ }
80
+
81
+ describe('Query Performance Benchmark', () => {
82
+ const createdEntityIds: string[] = [];
83
+ const datasetSize = DATASET_SIZES.large; // 5000 entities
84
+
85
+ beforeAll(async () => {
86
+ await ensureComponentsRegistered(BenchUser, BenchProfile, BenchSettings);
87
+
88
+ console.log(`\nšŸ“Š Creating ${datasetSize} test entities...`);
89
+ const startCreate = performance.now();
90
+
91
+ // Create entities with various component combinations
92
+ for (let i = 0; i < datasetSize; i++) {
93
+ const entity = Entity.Create();
94
+
95
+ // All entities have BenchUser
96
+ entity.add(BenchUser, {
97
+ name: `User ${i}`,
98
+ email: `user${i}@test.com`,
99
+ age: 18 + (i % 60),
100
+ status: i % 3 === 0 ? 'active' : (i % 3 === 1 ? 'inactive' : 'pending')
101
+ });
102
+
103
+ // 80% have BenchProfile
104
+ if (i % 5 !== 0) {
105
+ entity.add(BenchProfile, {
106
+ username: `user_${i}`,
107
+ bio: `Bio for user ${i}`,
108
+ verified: i % 2 === 0
109
+ });
110
+ }
111
+
112
+ // 60% have BenchSettings
113
+ if (i % 5 < 3) {
114
+ entity.add(BenchSettings, {
115
+ theme: i % 2 === 0 ? 'light' : 'dark',
116
+ notifications: i % 3 !== 0,
117
+ language: ['en', 'es', 'fr', 'de'][i % 4]!
118
+ });
119
+ }
120
+
121
+ await entity.save();
122
+ createdEntityIds.push(entity.id);
123
+ }
124
+
125
+ const endCreate = performance.now();
126
+ console.log(`āœ… Created ${datasetSize} entities in ${(endCreate - startCreate).toFixed(0)}ms\n`);
127
+ }, 120000); // 2 minute timeout for setup
128
+
129
+ afterAll(async () => {
130
+ // Cleanup
131
+ console.log(`\n🧹 Cleaning up ${createdEntityIds.length} entities...`);
132
+ for (const id of createdEntityIds) {
133
+ try {
134
+ const entity = await Entity.findById(id);
135
+ if (entity) {
136
+ await entity.delete();
137
+ }
138
+ } catch {
139
+ // Ignore cleanup errors
140
+ }
141
+ }
142
+ }, 120000);
143
+
144
+ describe('Single component queries (baseline)', () => {
145
+ test('single component, no filter', async () => {
146
+ const result = await runBenchmark(
147
+ 'Single component, no filter',
148
+ datasetSize,
149
+ () => new Query().with(BenchUser).take(100).exec()
150
+ );
151
+ console.log(` ${result.name}: ${result.avgTimeMs.toFixed(2)}ms avg (${result.resultCount} results)`);
152
+ expect(result.avgTimeMs).toBeLessThan(1000);
153
+ });
154
+
155
+ test('single component, with filter', async () => {
156
+ const result = await runBenchmark(
157
+ 'Single component, with filter',
158
+ datasetSize,
159
+ () => new Query()
160
+ .with(BenchUser, {
161
+ filters: [Query.filter('status', FilterOp.EQ, 'active')]
162
+ })
163
+ .take(100)
164
+ .exec()
165
+ );
166
+ console.log(` ${result.name}: ${result.avgTimeMs.toFixed(2)}ms avg (${result.resultCount} results)`);
167
+ expect(result.avgTimeMs).toBeLessThan(1000);
168
+ });
169
+ });
170
+
171
+ describe('Two component queries (affected by fix)', () => {
172
+ test('2 components, no filter', async () => {
173
+ const result = await runBenchmark(
174
+ '2 components, no filter',
175
+ datasetSize,
176
+ () => new Query()
177
+ .with(BenchUser)
178
+ .with(BenchProfile)
179
+ .take(100)
180
+ .exec()
181
+ );
182
+ console.log(` ${result.name}: ${result.avgTimeMs.toFixed(2)}ms avg (${result.resultCount} results)`);
183
+ expect(result.avgTimeMs).toBeLessThan(2000);
184
+ });
185
+
186
+ test('2 components, filter on first (bug pattern)', async () => {
187
+ const result = await runBenchmark(
188
+ '2 components, filter on first',
189
+ datasetSize,
190
+ () => new Query()
191
+ .with(BenchUser, {
192
+ filters: [Query.filter('status', FilterOp.EQ, 'active')]
193
+ })
194
+ .with(BenchProfile)
195
+ .take(100)
196
+ .exec()
197
+ );
198
+ console.log(` ${result.name}: ${result.avgTimeMs.toFixed(2)}ms avg (${result.resultCount} results)`);
199
+ expect(result.avgTimeMs).toBeLessThan(2000);
200
+ });
201
+
202
+ test('2 components, filter on second (bug pattern)', async () => {
203
+ const result = await runBenchmark(
204
+ '2 components, filter on second',
205
+ datasetSize,
206
+ () => new Query()
207
+ .with(BenchUser)
208
+ .with(BenchProfile, {
209
+ filters: [Query.filter('verified', FilterOp.EQ, true)]
210
+ })
211
+ .take(100)
212
+ .exec()
213
+ );
214
+ console.log(` ${result.name}: ${result.avgTimeMs.toFixed(2)}ms avg (${result.resultCount} results)`);
215
+ expect(result.avgTimeMs).toBeLessThan(2000);
216
+ });
217
+
218
+ test('2 components, filters on both', async () => {
219
+ const result = await runBenchmark(
220
+ '2 components, filters on both',
221
+ datasetSize,
222
+ () => new Query()
223
+ .with(BenchUser, {
224
+ filters: [Query.filter('status', FilterOp.EQ, 'active')]
225
+ })
226
+ .with(BenchProfile, {
227
+ filters: [Query.filter('verified', FilterOp.EQ, true)]
228
+ })
229
+ .take(100)
230
+ .exec()
231
+ );
232
+ console.log(` ${result.name}: ${result.avgTimeMs.toFixed(2)}ms avg (${result.resultCount} results)`);
233
+ expect(result.avgTimeMs).toBeLessThan(2000);
234
+ });
235
+
236
+ test('2 components, IN filter (bug pattern)', async () => {
237
+ const result = await runBenchmark(
238
+ '2 components, IN filter',
239
+ datasetSize,
240
+ () => new Query()
241
+ .with(BenchUser, {
242
+ filters: [Query.filter('status', FilterOp.IN, ['active', 'pending'])]
243
+ })
244
+ .with(BenchProfile)
245
+ .take(100)
246
+ .exec()
247
+ );
248
+ console.log(` ${result.name}: ${result.avgTimeMs.toFixed(2)}ms avg (${result.resultCount} results)`);
249
+ expect(result.avgTimeMs).toBeLessThan(2000);
250
+ });
251
+ });
252
+
253
+ describe('Three component queries', () => {
254
+ test('3 components, no filter', async () => {
255
+ const result = await runBenchmark(
256
+ '3 components, no filter',
257
+ datasetSize,
258
+ () => new Query()
259
+ .with(BenchUser)
260
+ .with(BenchProfile)
261
+ .with(BenchSettings)
262
+ .take(100)
263
+ .exec()
264
+ );
265
+ console.log(` ${result.name}: ${result.avgTimeMs.toFixed(2)}ms avg (${result.resultCount} results)`);
266
+ expect(result.avgTimeMs).toBeLessThan(3000);
267
+ });
268
+
269
+ test('3 components, filter on one', async () => {
270
+ const result = await runBenchmark(
271
+ '3 components, filter on one',
272
+ datasetSize,
273
+ () => new Query()
274
+ .with(BenchUser, {
275
+ filters: [Query.filter('age', FilterOp.GTE, 30)]
276
+ })
277
+ .with(BenchProfile)
278
+ .with(BenchSettings)
279
+ .take(100)
280
+ .exec()
281
+ );
282
+ console.log(` ${result.name}: ${result.avgTimeMs.toFixed(2)}ms avg (${result.resultCount} results)`);
283
+ expect(result.avgTimeMs).toBeLessThan(3000);
284
+ });
285
+
286
+ test('3 components, filters on all', async () => {
287
+ const result = await runBenchmark(
288
+ '3 components, filters on all',
289
+ datasetSize,
290
+ () => new Query()
291
+ .with(BenchUser, {
292
+ filters: [Query.filter('status', FilterOp.EQ, 'active')]
293
+ })
294
+ .with(BenchProfile, {
295
+ filters: [Query.filter('verified', FilterOp.EQ, true)]
296
+ })
297
+ .with(BenchSettings, {
298
+ filters: [Query.filter('theme', FilterOp.EQ, 'dark')]
299
+ })
300
+ .take(100)
301
+ .exec()
302
+ );
303
+ console.log(` ${result.name}: ${result.avgTimeMs.toFixed(2)}ms avg (${result.resultCount} results)`);
304
+ expect(result.avgTimeMs).toBeLessThan(3000);
305
+ });
306
+ });
307
+
308
+ describe('Sorting with multi-component (affected by fix)', () => {
309
+ test('2 components, sort on first', async () => {
310
+ const result = await runBenchmark(
311
+ '2 components, sort on first',
312
+ datasetSize,
313
+ () => new Query()
314
+ .with(BenchUser)
315
+ .with(BenchProfile)
316
+ .sortBy(BenchUser, 'age', 'DESC')
317
+ .take(100)
318
+ .exec()
319
+ );
320
+ console.log(` ${result.name}: ${result.avgTimeMs.toFixed(2)}ms avg (${result.resultCount} results)`);
321
+ expect(result.avgTimeMs).toBeLessThan(3000);
322
+ });
323
+
324
+ test('2 components, filter + sort on different components', async () => {
325
+ const result = await runBenchmark(
326
+ '2 components, filter + sort different',
327
+ datasetSize,
328
+ () => new Query()
329
+ .with(BenchUser)
330
+ .with(BenchProfile, {
331
+ filters: [Query.filter('verified', FilterOp.EQ, true)]
332
+ })
333
+ .sortBy(BenchUser, 'age', 'ASC')
334
+ .take(100)
335
+ .exec()
336
+ );
337
+ console.log(` ${result.name}: ${result.avgTimeMs.toFixed(2)}ms avg (${result.resultCount} results)`);
338
+ expect(result.avgTimeMs).toBeLessThan(3000);
339
+ });
340
+ });
341
+
342
+ describe('Count operations', () => {
343
+ test('2 components count with filter', async () => {
344
+ const times: number[] = [];
345
+ let count = 0;
346
+
347
+ for (let i = 0; i < ITERATIONS; i++) {
348
+ const start = performance.now();
349
+ count = await new Query()
350
+ .with(BenchUser, {
351
+ filters: [Query.filter('status', FilterOp.EQ, 'active')]
352
+ })
353
+ .with(BenchProfile)
354
+ .count();
355
+ const end = performance.now();
356
+ times.push(end - start);
357
+ }
358
+
359
+ const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
360
+ console.log(` 2 components count with filter: ${avgTime.toFixed(2)}ms avg (count: ${count})`);
361
+ expect(avgTime).toBeLessThan(2000);
362
+ });
363
+ });
364
+
365
+ test('Performance summary', () => {
366
+ console.log('\nšŸ“ˆ Benchmark Summary:');
367
+ console.log(` Dataset size: ${datasetSize} entities`);
368
+ console.log(` Iterations per query: ${ITERATIONS}`);
369
+ console.log(' All queries completed within acceptable thresholds');
370
+ expect(true).toBe(true);
371
+ });
372
+ });