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 +26 -0
- package/docs/SCALABILITY_PLAN.md +3 -3
- package/package.json +1 -1
- package/query/ComponentInclusionNode.ts +14 -4
- 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/benchmark/query-lateral-benchmark.test.ts +372 -0
- package/tests/integration/query/Query.edgeCases.test.ts +595 -0
- 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
|
@@ -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
|
}
|
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");
|
|
@@ -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
|
+
});
|