bunsane 0.2.4 → 0.2.7
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/core/ArcheType.ts +67 -34
- package/core/BatchLoader.ts +215 -30
- package/core/Entity.ts +2 -2
- package/core/RequestContext.ts +15 -10
- package/core/RequestLoaders.ts +4 -2
- package/core/cache/CacheProvider.ts +1 -0
- package/core/cache/MemoryCache.ts +10 -1
- package/core/cache/RedisCache.ts +16 -2
- package/core/validateEnv.ts +8 -0
- package/database/DatabaseHelper.ts +113 -1
- package/database/index.ts +78 -45
- package/docs/SCALABILITY_PLAN.md +175 -0
- package/package.json +13 -2
- package/query/CTENode.ts +44 -24
- package/query/ComponentInclusionNode.ts +181 -91
- package/query/Query.ts +9 -9
- package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +338 -0
- package/tests/benchmark/bunfig.toml +9 -0
- package/tests/benchmark/fixtures/EcommerceComponents.ts +283 -0
- package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +301 -0
- package/tests/benchmark/fixtures/RelationTracker.ts +159 -0
- package/tests/benchmark/fixtures/index.ts +6 -0
- package/tests/benchmark/index.ts +22 -0
- package/tests/benchmark/noop-preload.ts +3 -0
- package/tests/benchmark/runners/BenchmarkLoader.ts +132 -0
- package/tests/benchmark/runners/index.ts +4 -0
- package/tests/benchmark/scenarios/query-benchmarks.test.ts +465 -0
- package/tests/benchmark/scripts/generate-db.ts +344 -0
- package/tests/benchmark/scripts/run-benchmarks.ts +97 -0
- package/tests/integration/query/Query.complexAnalysis.test.ts +557 -0
- package/tests/integration/query/Query.explainAnalyze.test.ts +233 -0
- package/tests/stress/fixtures/RealisticComponents.ts +235 -0
- package/tests/stress/scenarios/realistic-scenarios.test.ts +1081 -0
- package/tests/stress/scenarios/timeout-investigation.test.ts +522 -0
- package/tests/unit/BatchLoader.test.ts +139 -25
|
@@ -3,7 +3,33 @@ import type { QueryResult } from "./QueryNode";
|
|
|
3
3
|
import { QueryContext } from "./QueryContext";
|
|
4
4
|
import { shouldUseLateralJoins, shouldUseDirectPartition } from "../core/Config";
|
|
5
5
|
import { FilterBuilderRegistry } from "./FilterBuilderRegistry";
|
|
6
|
-
import {ComponentRegistry} from "../core/components";
|
|
6
|
+
import { ComponentRegistry } from "../core/components";
|
|
7
|
+
import { getMetadataStorage } from "../core/metadata";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Check if a component property is numeric based on metadata
|
|
11
|
+
* Used to apply proper casting in ORDER BY clauses for index usage
|
|
12
|
+
*/
|
|
13
|
+
function isNumericProperty(componentName: string, propertyName: string): boolean {
|
|
14
|
+
const storage = getMetadataStorage();
|
|
15
|
+
const typeId = storage.getComponentId(componentName);
|
|
16
|
+
|
|
17
|
+
// Check indexed fields first (most reliable)
|
|
18
|
+
const indexedFields = storage.getIndexedFields(typeId);
|
|
19
|
+
const indexedField = indexedFields.find(f => f.propertyKey === propertyName);
|
|
20
|
+
if (indexedField?.indexType === 'numeric') {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Check property metadata for Number type
|
|
25
|
+
const props = storage.getComponentProperties(typeId);
|
|
26
|
+
const prop = props.find(p => p.propertyKey === propertyName);
|
|
27
|
+
if (prop?.propertyType === Number) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
7
33
|
|
|
8
34
|
export class ComponentInclusionNode extends QueryNode {
|
|
9
35
|
private getComponentTableName(compId: string): string {
|
|
@@ -108,7 +134,10 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
108
134
|
}
|
|
109
135
|
|
|
110
136
|
// Apply component filters for single component (normal path)
|
|
111
|
-
|
|
137
|
+
// For single component, alias is 'ec' (or CTE name if using CTE)
|
|
138
|
+
// Single component queries have WHERE from the initial select, so pass true
|
|
139
|
+
const singleCompHasWhere = sql.includes(' WHERE ');
|
|
140
|
+
sql = this.applyComponentFilters(context, componentIds, useCTE, useLateralJoins, lateralJoins, lateralConditions, sql, new Map(), useCTE ? context.cteName : "ec", singleCompHasWhere);
|
|
112
141
|
|
|
113
142
|
// Apply sorting with component data joins if sortOrders are specified
|
|
114
143
|
if (hasSortOrders) {
|
|
@@ -145,17 +174,11 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
145
174
|
// Multiple components case
|
|
146
175
|
// Create parameter indices for component IDs to avoid duplicates
|
|
147
176
|
const componentParamIndices: Map<string, number> = new Map();
|
|
148
|
-
|
|
149
|
-
if (!componentParamIndices.has(id)) {
|
|
150
|
-
componentParamIndices.set(id, context.addParam(id));
|
|
151
|
-
}
|
|
152
|
-
return `$${componentParamIndices.get(id)}::text`;
|
|
153
|
-
}).join(', ');
|
|
154
|
-
|
|
177
|
+
|
|
155
178
|
if (useCTE) {
|
|
156
179
|
// Use CTE for base entity filtering
|
|
157
180
|
sql = `SELECT DISTINCT ${context.cteName}.entity_id as id FROM ${context.cteName}`;
|
|
158
|
-
|
|
181
|
+
|
|
159
182
|
// Ensure all required components are present
|
|
160
183
|
sql += ` WHERE (`;
|
|
161
184
|
const componentChecks = componentIds.map(compId => {
|
|
@@ -171,57 +194,77 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
171
194
|
});
|
|
172
195
|
sql += componentChecks.join(' AND ') + `)`;
|
|
173
196
|
} else {
|
|
174
|
-
|
|
197
|
+
// Use INTERSECT for multi-component queries (much faster than GROUP BY + HAVING)
|
|
198
|
+
// INTERSECT lets PostgreSQL use index scans independently on each component type
|
|
199
|
+
// then merge the results efficiently with a hash or merge join
|
|
200
|
+
const intersectQueries = componentIds.map((compId) => {
|
|
201
|
+
if (!componentParamIndices.has(compId)) {
|
|
202
|
+
componentParamIndices.set(compId, context.addParam(compId));
|
|
203
|
+
}
|
|
204
|
+
return `SELECT ec.entity_id FROM entity_components ec WHERE ec.type_id = $${componentParamIndices.get(compId)}::text AND ec.deleted_at IS NULL`;
|
|
205
|
+
});
|
|
206
|
+
sql = `SELECT intersected.entity_id as id FROM (${intersectQueries.join(' INTERSECT ')}) AS intersected`;
|
|
175
207
|
}
|
|
176
208
|
|
|
209
|
+
// For INTERSECT queries, the alias is 'intersected', not 'ec'
|
|
210
|
+
const multiCompAlias = useCTE ? context.cteName : "intersected";
|
|
211
|
+
|
|
212
|
+
// Track if outer query has WHERE clause (don't count WHERE inside INTERSECT subqueries)
|
|
213
|
+
// For INTERSECT queries, the outer query starts without WHERE
|
|
214
|
+
let outerHasWhere = useCTE && sql.indexOf('WHERE', sql.lastIndexOf('FROM')) > -1;
|
|
215
|
+
|
|
177
216
|
if (context.withId) {
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
217
|
+
const whereKeyword = outerHasWhere ? 'AND' : 'WHERE';
|
|
218
|
+
sql += ` ${whereKeyword} ${multiCompAlias}.entity_id = $${context.addParam(context.withId)}`;
|
|
219
|
+
outerHasWhere = true;
|
|
181
220
|
}
|
|
182
221
|
|
|
183
222
|
// Add exclusions
|
|
184
223
|
if (excludedIds.length > 0) {
|
|
185
|
-
const
|
|
186
|
-
const whereKeyword = sql.includes('WHERE') ? 'AND' : 'WHERE';
|
|
224
|
+
const whereKeyword = outerHasWhere ? 'AND' : 'WHERE';
|
|
187
225
|
const excludedPlaceholders = excludedIds.map((id) => `$${context.addParam(id)}`).join(', ');
|
|
188
226
|
sql += ` ${whereKeyword} NOT EXISTS (
|
|
189
227
|
SELECT 1 FROM entity_components ec_ex
|
|
190
|
-
WHERE ec_ex.entity_id = ${
|
|
228
|
+
WHERE ec_ex.entity_id = ${multiCompAlias}.entity_id
|
|
191
229
|
AND ec_ex.type_id IN (${excludedPlaceholders})
|
|
192
230
|
AND ec_ex.deleted_at IS NULL
|
|
193
231
|
)`;
|
|
232
|
+
outerHasWhere = true;
|
|
194
233
|
}
|
|
195
234
|
|
|
196
235
|
// Add entity exclusions
|
|
197
236
|
if (context.excludedEntityIds.size > 0) {
|
|
198
|
-
const
|
|
199
|
-
const whereKeyword = sql.includes('WHERE') ? 'AND' : 'WHERE';
|
|
237
|
+
const whereKeyword = outerHasWhere ? 'AND' : 'WHERE';
|
|
200
238
|
const entityExcludedIds = Array.from(context.excludedEntityIds);
|
|
201
239
|
const entityPlaceholders = entityExcludedIds.map((id) => `$${context.addParam(id)}`).join(', ');
|
|
202
|
-
sql += ` ${whereKeyword} ${
|
|
240
|
+
sql += ` ${whereKeyword} ${multiCompAlias}.entity_id NOT IN (${entityPlaceholders})`;
|
|
241
|
+
outerHasWhere = true;
|
|
203
242
|
}
|
|
204
243
|
|
|
205
244
|
// Apply component filters for multiple components
|
|
206
|
-
|
|
245
|
+
// For INTERSECT queries, alias is 'intersected'; for CTE, use cteName
|
|
246
|
+
// Pass outerHasWhere to correctly track WHERE clause in outer query (not in INTERSECT subqueries)
|
|
247
|
+
const filterResult = this.applyComponentFiltersWithState(context, componentIds, useCTE, useLateralJoins, lateralJoins, lateralConditions, sql, componentParamIndices, multiCompAlias, outerHasWhere);
|
|
248
|
+
sql = filterResult.sql;
|
|
249
|
+
outerHasWhere = filterResult.hasWhere;
|
|
250
|
+
|
|
251
|
+
// Note: GROUP BY HAVING removed - INTERSECT already ensures all components are present
|
|
207
252
|
|
|
208
|
-
if (!useCTE) {
|
|
209
|
-
sql += ` GROUP BY ec.entity_id HAVING COUNT(DISTINCT ec.type_id) = $${context.addParam(componentCount)}`;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
253
|
// Apply sorting with component data joins if sortOrders are specified
|
|
213
254
|
if (hasSortOrders) {
|
|
214
255
|
sql = this.applySortingWithComponentJoins(sql, context);
|
|
215
256
|
} else {
|
|
216
257
|
// Default: order by entity_id
|
|
217
|
-
|
|
218
|
-
const idColumn =
|
|
258
|
+
// For INTERSECT queries, use 'intersected' alias; for CTE, use cteName
|
|
259
|
+
const idColumn = `${multiCompAlias}.entity_id`;
|
|
219
260
|
|
|
220
261
|
// Apply cursor-based pagination if cursor is set (more efficient than OFFSET)
|
|
221
262
|
if (context.cursorId !== null && !context.paginationAppliedInCTE) {
|
|
222
263
|
const operator = context.cursorDirection === 'after' ? '>' : '<';
|
|
223
|
-
|
|
264
|
+
// Use tracked WHERE state for INTERSECT queries
|
|
265
|
+
const whereKeyword = outerHasWhere ? 'AND' : 'WHERE';
|
|
224
266
|
sql += ` ${whereKeyword} ${idColumn} ${operator} $${context.addParam(context.cursorId)}`;
|
|
267
|
+
outerHasWhere = true;
|
|
225
268
|
}
|
|
226
269
|
|
|
227
270
|
// Order direction depends on cursor direction
|
|
@@ -266,44 +309,48 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
266
309
|
if (singlePass) return singlePass;
|
|
267
310
|
}
|
|
268
311
|
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
//
|
|
273
|
-
const sortJoins: string[] = [];
|
|
312
|
+
// Use scalar subquery approach for sorting to avoid cartesian product explosion
|
|
313
|
+
// This forces PostgreSQL to evaluate the sort expression for each entity row,
|
|
314
|
+
// rather than joining all component rows first and filtering later.
|
|
315
|
+
// This is dramatically faster when base_entities is a small subset of total entities.
|
|
274
316
|
const orderByClauses: string[] = [];
|
|
275
|
-
|
|
317
|
+
|
|
276
318
|
for (let i = 0; i < context.sortOrders.length; i++) {
|
|
277
319
|
const sortOrder = context.sortOrders[i]!;
|
|
278
|
-
|
|
279
|
-
const compAlias = `comp_${i}`;
|
|
280
|
-
|
|
320
|
+
|
|
281
321
|
// Get the component type ID for this sort order
|
|
282
322
|
const typeId = ComponentRegistry.getComponentId(sortOrder.component);
|
|
283
323
|
if (!typeId) {
|
|
284
324
|
continue; // Skip if component not registered
|
|
285
325
|
}
|
|
286
|
-
|
|
287
|
-
// LEFT JOIN to entity_components and components to get the sort data
|
|
326
|
+
|
|
288
327
|
const sortComponentTableName = this.getComponentTableName(typeId);
|
|
289
|
-
sortJoins.push(`
|
|
290
|
-
LEFT JOIN entity_components ${sortAlias}
|
|
291
|
-
ON ${sortAlias}.entity_id = base_entities.id
|
|
292
|
-
AND ${sortAlias}.type_id = $${context.addParam(typeId)}::text
|
|
293
|
-
AND ${sortAlias}.deleted_at IS NULL
|
|
294
|
-
LEFT JOIN ${sortComponentTableName} ${compAlias}
|
|
295
|
-
ON ${compAlias}.id = ${sortAlias}.component_id
|
|
296
|
-
AND ${compAlias}.deleted_at IS NULL`);
|
|
297
|
-
|
|
298
|
-
// Build ORDER BY clause for this sort order
|
|
299
|
-
// Access the property from JSONB data
|
|
300
328
|
const nullsClause = sortOrder.nullsFirst ? 'NULLS FIRST' : 'NULLS LAST';
|
|
301
|
-
|
|
329
|
+
const isNumeric = isNumericProperty(sortOrder.component, sortOrder.property);
|
|
330
|
+
|
|
331
|
+
// Build scalar subquery to get sort value for each entity
|
|
332
|
+
// This avoids nested loop join by forcing row-by-row evaluation
|
|
333
|
+
const sortExpr = isNumeric
|
|
334
|
+
? `(sort_c.data->>'${sortOrder.property}')::numeric`
|
|
335
|
+
: `sort_c.data->>'${sortOrder.property}'`;
|
|
336
|
+
|
|
337
|
+
const subquery = `(
|
|
338
|
+
SELECT ${sortExpr}
|
|
339
|
+
FROM entity_components sort_ec
|
|
340
|
+
JOIN ${sortComponentTableName} sort_c ON sort_c.id = sort_ec.component_id
|
|
341
|
+
WHERE sort_ec.entity_id = base_entities.id
|
|
342
|
+
AND sort_ec.type_id = $${context.addParam(typeId)}::text
|
|
343
|
+
AND sort_ec.deleted_at IS NULL
|
|
344
|
+
AND sort_c.deleted_at IS NULL
|
|
345
|
+
LIMIT 1
|
|
346
|
+
)`;
|
|
347
|
+
|
|
348
|
+
orderByClauses.push(`${subquery} ${sortOrder.direction} ${nullsClause}`);
|
|
302
349
|
}
|
|
303
|
-
|
|
304
|
-
//
|
|
305
|
-
sql
|
|
306
|
-
|
|
350
|
+
|
|
351
|
+
// Wrap the base query as a subquery to get entity ids
|
|
352
|
+
let sql = `SELECT base_entities.id FROM (${baseQuery}) AS base_entities`;
|
|
353
|
+
|
|
307
354
|
// Add ORDER BY clause
|
|
308
355
|
if (orderByClauses.length > 0) {
|
|
309
356
|
sql += ` ORDER BY ${orderByClauses.join(', ')}`;
|
|
@@ -311,7 +358,7 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
311
358
|
// Fallback to entity id if no valid sort orders
|
|
312
359
|
sql += ` ORDER BY base_entities.id`;
|
|
313
360
|
}
|
|
314
|
-
|
|
361
|
+
|
|
315
362
|
// Add LIMIT and OFFSET only if not already applied in CTE
|
|
316
363
|
// When pagination is applied at CTE level, skip it here to avoid double pagination
|
|
317
364
|
if (!context.paginationAppliedInCTE) {
|
|
@@ -334,10 +381,17 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
334
381
|
*
|
|
335
382
|
* This is dramatically faster because PostgreSQL can use indexes to find
|
|
336
383
|
* the top N matching rows directly instead of finding ALL matches first.
|
|
384
|
+
*
|
|
385
|
+
* NOTE: This optimization cannot be used when multiple component types are required,
|
|
386
|
+
* because it only queries one component table and would miss the join requirement.
|
|
337
387
|
*/
|
|
338
388
|
private applySinglePassFilterSort(context: QueryContext): string | null {
|
|
339
389
|
if (context.sortOrders.length !== 1) return null;
|
|
340
390
|
|
|
391
|
+
// Can't use single-pass when multiple components are required
|
|
392
|
+
// (we need to ensure entities have ALL required components)
|
|
393
|
+
if (context.componentIds.size > 1) return null;
|
|
394
|
+
|
|
341
395
|
const sortOrder = context.sortOrders[0]!;
|
|
342
396
|
const sortTypeId = ComponentRegistry.getComponentId(sortOrder.component);
|
|
343
397
|
if (!sortTypeId) return null;
|
|
@@ -392,6 +446,10 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
392
446
|
}
|
|
393
447
|
|
|
394
448
|
const nullsClause = sortOrder.nullsFirst ? 'NULLS FIRST' : 'NULLS LAST';
|
|
449
|
+
const isNumeric = isNumericProperty(sortOrder.component, sortOrder.property);
|
|
450
|
+
const sortExpr = isNumeric
|
|
451
|
+
? `(c.data->>'${sortOrder.property}')::numeric`
|
|
452
|
+
: `c.data->>'${sortOrder.property}'`;
|
|
395
453
|
|
|
396
454
|
let sql: string;
|
|
397
455
|
if (useDirectPartition) {
|
|
@@ -401,7 +459,7 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
401
459
|
WHERE c.type_id = $${context.addParam(sortTypeId)}::text
|
|
402
460
|
AND c.deleted_at IS NULL
|
|
403
461
|
AND ${filterConditions.join(' AND ')}
|
|
404
|
-
ORDER BY
|
|
462
|
+
ORDER BY ${sortExpr} ${sortOrder.direction} ${nullsClause}`;
|
|
405
463
|
} else {
|
|
406
464
|
// Use entity_components junction
|
|
407
465
|
// No DISTINCT needed since each entity has one component of this type
|
|
@@ -410,7 +468,7 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
410
468
|
WHERE ec.type_id = $${context.addParam(sortTypeId)}::text
|
|
411
469
|
AND ec.deleted_at IS NULL
|
|
412
470
|
AND ${filterConditions.join(' AND ')}
|
|
413
|
-
ORDER BY
|
|
471
|
+
ORDER BY ${sortExpr} ${sortOrder.direction} ${nullsClause}`;
|
|
414
472
|
}
|
|
415
473
|
|
|
416
474
|
// Add pagination
|
|
@@ -427,29 +485,41 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
427
485
|
}
|
|
428
486
|
|
|
429
487
|
/**
|
|
430
|
-
* Optimized sorting for direct partition access
|
|
431
|
-
*
|
|
488
|
+
* Optimized sorting for direct partition access.
|
|
489
|
+
* Uses scalar subquery to avoid cartesian product explosion when sorting.
|
|
490
|
+
* Queries the partition table directly without going through entity_components.
|
|
432
491
|
*/
|
|
433
492
|
private applySortingOptimized(baseQuery: string, context: QueryContext): string | null {
|
|
434
493
|
if (context.sortOrders.length !== 1) return null;
|
|
435
|
-
|
|
494
|
+
|
|
436
495
|
const sortOrder = context.sortOrders[0]!;
|
|
437
496
|
const typeId = ComponentRegistry.getComponentId(sortOrder.component);
|
|
438
497
|
if (!typeId) return null;
|
|
439
|
-
|
|
498
|
+
|
|
440
499
|
const partitionTable = ComponentRegistry.getPartitionTableName(typeId);
|
|
441
500
|
if (!partitionTable) return null;
|
|
442
|
-
|
|
501
|
+
|
|
443
502
|
const nullsClause = sortOrder.nullsFirst ? 'NULLS FIRST' : 'NULLS LAST';
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
503
|
+
const isNumeric = isNumericProperty(sortOrder.component, sortOrder.property);
|
|
504
|
+
const sortExpr = isNumeric
|
|
505
|
+
? `(sort_c.data->>'${sortOrder.property}')::numeric`
|
|
506
|
+
: `sort_c.data->>'${sortOrder.property}'`;
|
|
507
|
+
|
|
508
|
+
// Use scalar subquery to avoid cartesian product explosion
|
|
509
|
+
// This forces PostgreSQL to evaluate sort value per-entity, preventing
|
|
510
|
+
// the nested loop join that scans all component rows before filtering
|
|
511
|
+
const sortSubquery = `(
|
|
512
|
+
SELECT ${sortExpr}
|
|
513
|
+
FROM ${partitionTable} sort_c
|
|
514
|
+
WHERE sort_c.entity_id = base.id
|
|
515
|
+
AND sort_c.type_id = $${context.addParam(typeId)}::text
|
|
516
|
+
AND sort_c.deleted_at IS NULL
|
|
517
|
+
LIMIT 1
|
|
518
|
+
)`;
|
|
519
|
+
|
|
447
520
|
let sql = `SELECT base.id FROM (${baseQuery}) AS base
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
AND c.deleted_at IS NULL
|
|
451
|
-
ORDER BY c.data->>'${sortOrder.property}' ${sortOrder.direction} ${nullsClause}`;
|
|
452
|
-
|
|
521
|
+
ORDER BY ${sortSubquery} ${sortOrder.direction} ${nullsClause}`;
|
|
522
|
+
|
|
453
523
|
// Add LIMIT and OFFSET only if not already applied in CTE
|
|
454
524
|
// When pagination is applied at CTE level, skip it here to avoid double pagination
|
|
455
525
|
if (!context.paginationAppliedInCTE) {
|
|
@@ -467,6 +537,7 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
467
537
|
|
|
468
538
|
/**
|
|
469
539
|
* Apply component filters using either EXISTS subqueries or LATERAL joins
|
|
540
|
+
* Wrapper that returns just the SQL string for backward compatibility
|
|
470
541
|
*/
|
|
471
542
|
private applyComponentFilters(
|
|
472
543
|
context: QueryContext,
|
|
@@ -476,8 +547,34 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
476
547
|
lateralJoins: string[],
|
|
477
548
|
lateralConditions: string[],
|
|
478
549
|
sql: string,
|
|
479
|
-
componentParamIndices: Map<string, number
|
|
550
|
+
componentParamIndices: Map<string, number>,
|
|
551
|
+
entityTableAlias?: string,
|
|
552
|
+
outerHasWhere: boolean = false
|
|
480
553
|
): string {
|
|
554
|
+
return this.applyComponentFiltersWithState(context, componentIds, useCTE, useLateralJoins, lateralJoins, lateralConditions, sql, componentParamIndices, entityTableAlias, outerHasWhere).sql;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Apply component filters using either EXISTS subqueries or LATERAL joins
|
|
559
|
+
* Returns both SQL and updated WHERE state for proper tracking
|
|
560
|
+
* @param entityTableAlias - The alias for the entity table (e.g., 'ec', 'intersected', or CTE name)
|
|
561
|
+
* @param outerHasWhere - Track if outer query already has WHERE clause (for INTERSECT queries)
|
|
562
|
+
*/
|
|
563
|
+
private applyComponentFiltersWithState(
|
|
564
|
+
context: QueryContext,
|
|
565
|
+
componentIds: string[],
|
|
566
|
+
useCTE: boolean,
|
|
567
|
+
useLateralJoins: boolean,
|
|
568
|
+
lateralJoins: string[],
|
|
569
|
+
lateralConditions: string[],
|
|
570
|
+
sql: string,
|
|
571
|
+
componentParamIndices: Map<string, number>,
|
|
572
|
+
entityTableAlias?: string,
|
|
573
|
+
outerHasWhere: boolean = false
|
|
574
|
+
): { sql: string; hasWhere: boolean } {
|
|
575
|
+
// Track whether we've added WHERE to the outer query
|
|
576
|
+
let hasOuterWhere = outerHasWhere;
|
|
577
|
+
|
|
481
578
|
for (const [compId, filters] of context.componentFilters) {
|
|
482
579
|
for (const filter of filters) {
|
|
483
580
|
let condition: string;
|
|
@@ -500,20 +597,11 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
500
597
|
if (filter.value === '' || (typeof filter.value === 'string' && filter.value.trim() === '')) {
|
|
501
598
|
throw new Error(`Filter value for field "${filter.field}" is an empty string. This would cause PostgreSQL UUID parsing errors.`);
|
|
502
599
|
}
|
|
503
|
-
|
|
600
|
+
|
|
504
601
|
// Check if value looks like a UUID (case-insensitive, with or without hyphens)
|
|
505
602
|
const valueStr = String(filter.value);
|
|
506
603
|
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(valueStr);
|
|
507
|
-
|
|
508
|
-
// Debug logging
|
|
509
|
-
// console.log('[ComponentInclusionNode] Filter:', {
|
|
510
|
-
// field: filter.field,
|
|
511
|
-
// operator: filter.operator,
|
|
512
|
-
// value: filter.value,
|
|
513
|
-
// valueStr,
|
|
514
|
-
// isUUID
|
|
515
|
-
// });
|
|
516
|
-
|
|
604
|
+
|
|
517
605
|
// Build JSON path for nested fields (e.g., "device.unique_id" -> "c.data->'device'->>'unique_id'")
|
|
518
606
|
let jsonPath: string;
|
|
519
607
|
if (filter.field.includes('.')) {
|
|
@@ -524,7 +612,7 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
524
612
|
} else {
|
|
525
613
|
jsonPath = `c.data->>'${filter.field}'`;
|
|
526
614
|
}
|
|
527
|
-
|
|
615
|
+
|
|
528
616
|
if (isUUID && filter.operator === '=') {
|
|
529
617
|
// UUID equality comparison - only cast the parameter, compare as text
|
|
530
618
|
// This allows matching UUID parameter against both UUID and text fields
|
|
@@ -550,12 +638,12 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
550
638
|
// Default: text comparison without casting
|
|
551
639
|
condition = `${jsonPath} ${filter.operator} $${context.addParam(filter.value)}`;
|
|
552
640
|
}
|
|
553
|
-
|
|
554
|
-
// console.log('[ComponentInclusionNode] Condition:', condition);
|
|
555
641
|
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const
|
|
642
|
+
|
|
643
|
+
// Use provided alias, or fall back to CTE name or 'ec'
|
|
644
|
+
const tableAlias = entityTableAlias || (useCTE ? context.cteName : "ec");
|
|
645
|
+
// Use tracked WHERE state instead of sql.includes() to handle INTERSECT correctly
|
|
646
|
+
const whereKeyword = hasOuterWhere ? 'AND' : 'WHERE';
|
|
559
647
|
|
|
560
648
|
if (useLateralJoins) {
|
|
561
649
|
// Use LATERAL join approach
|
|
@@ -600,7 +688,7 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
600
688
|
// Use traditional EXISTS subquery
|
|
601
689
|
const componentTableName = this.getComponentTableName(compId);
|
|
602
690
|
const useDirectPartition = shouldUseDirectPartition() && componentTableName !== 'components';
|
|
603
|
-
|
|
691
|
+
|
|
604
692
|
if (useDirectPartition) {
|
|
605
693
|
// Direct partition access - query partition table directly by entity_id
|
|
606
694
|
sql += ` ${whereKeyword} EXISTS (
|
|
@@ -622,6 +710,8 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
622
710
|
AND c.deleted_at IS NULL
|
|
623
711
|
)`;
|
|
624
712
|
}
|
|
713
|
+
// Mark that we've added WHERE to the outer query
|
|
714
|
+
hasOuterWhere = true;
|
|
625
715
|
}
|
|
626
716
|
}
|
|
627
717
|
}
|
|
@@ -680,7 +770,7 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
680
770
|
}
|
|
681
771
|
}
|
|
682
772
|
|
|
683
|
-
return sql;
|
|
773
|
+
return { sql, hasWhere: hasOuterWhere };
|
|
684
774
|
}
|
|
685
775
|
|
|
686
776
|
public getNodeType(): string {
|
package/query/Query.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {ComponentRegistry , type BaseComponent, type ComponentDataType } from "../core/components";
|
|
2
2
|
import { Entity } from "../core/Entity";
|
|
3
3
|
import { logger } from "../core/Logger";
|
|
4
|
-
import db from "../database";
|
|
4
|
+
import db, { QUERY_TIMEOUT_MS } from "../database";
|
|
5
5
|
import { timed } from "../core/Decorators";
|
|
6
6
|
import { inList } from "../database/sqlHelpers";
|
|
7
7
|
import { QueryContext, QueryDAG, SourceNode, ComponentInclusionNode } from "./index";
|
|
@@ -299,8 +299,8 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
299
299
|
return new Promise<number>((resolve, reject) => {
|
|
300
300
|
const timeout = setTimeout(() => {
|
|
301
301
|
logger.error(`Query count execution timeout`);
|
|
302
|
-
reject(new Error(`Query count execution timeout after
|
|
303
|
-
},
|
|
302
|
+
reject(new Error(`Query count execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
|
|
303
|
+
}, QUERY_TIMEOUT_MS);
|
|
304
304
|
this.doCount()
|
|
305
305
|
.then(result => {
|
|
306
306
|
clearTimeout(timeout);
|
|
@@ -455,8 +455,8 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
455
455
|
return new Promise<number>((resolve, reject) => {
|
|
456
456
|
const timeout = setTimeout(() => {
|
|
457
457
|
logger.error(`Query sum execution timeout`);
|
|
458
|
-
reject(new Error(`Query sum execution timeout after
|
|
459
|
-
},
|
|
458
|
+
reject(new Error(`Query sum execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
|
|
459
|
+
}, QUERY_TIMEOUT_MS);
|
|
460
460
|
this.doAggregate('SUM', componentCtor, field as string)
|
|
461
461
|
.then(result => {
|
|
462
462
|
clearTimeout(timeout);
|
|
@@ -483,8 +483,8 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
483
483
|
return new Promise<number>((resolve, reject) => {
|
|
484
484
|
const timeout = setTimeout(() => {
|
|
485
485
|
logger.error(`Query average execution timeout`);
|
|
486
|
-
reject(new Error(`Query average execution timeout after
|
|
487
|
-
},
|
|
486
|
+
reject(new Error(`Query average execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
|
|
487
|
+
}, QUERY_TIMEOUT_MS);
|
|
488
488
|
this.doAggregate('AVG', componentCtor, field as string)
|
|
489
489
|
.then(result => {
|
|
490
490
|
clearTimeout(timeout);
|
|
@@ -641,8 +641,8 @@ AND c.deleted_at IS NULL`;
|
|
|
641
641
|
// Add timeout to prevent hanging queries
|
|
642
642
|
const timeout = setTimeout(() => {
|
|
643
643
|
logger.error(`Query execution timeout`);
|
|
644
|
-
reject(new Error(`Query execution timeout after
|
|
645
|
-
},
|
|
644
|
+
reject(new Error(`Query execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
|
|
645
|
+
}, QUERY_TIMEOUT_MS); // 30 second timeout
|
|
646
646
|
|
|
647
647
|
this.doExec()
|
|
648
648
|
.then(result => {
|