bunsane 0.5.3 → 0.5.4
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/package.json +1 -1
- package/query/OrNode.ts +170 -1
package/package.json
CHANGED
package/query/OrNode.ts
CHANGED
|
@@ -7,6 +7,23 @@ import { shouldUseDirectPartition } from "../core/Config";
|
|
|
7
7
|
import { getMembershipTable } from "./membershipSource";
|
|
8
8
|
import { jsonbInListCast } from "./FilterBuilder";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Gate for the base-dependency single-pass OR rewrite (base scanned once,
|
|
12
|
+
* branches combined as OR-of-EXISTS instead of N× base + UNION + DISTINCT).
|
|
13
|
+
*
|
|
14
|
+
* Default ON — the single-pass shape is parity-proven against the legacy UNION
|
|
15
|
+
* path (identical exec/paginate/count results) and ~20× faster (no per-branch
|
|
16
|
+
* cartesian nested-loop; base anti-join computed once). Read at call time.
|
|
17
|
+
*
|
|
18
|
+
* Kill-switch: set BUNSANE_ORNODE_SINGLE_PASS=0 (or "false") to revert to the
|
|
19
|
+
* legacy UNION shape instantly, no redeploy — for the unlikely case a real
|
|
20
|
+
* Postgres planner regresses on a specific OR shape.
|
|
21
|
+
*/
|
|
22
|
+
function shouldUseOrSinglePass(): boolean {
|
|
23
|
+
const v = process.env.BUNSANE_ORNODE_SINGLE_PASS;
|
|
24
|
+
return v !== '0' && v !== 'false';
|
|
25
|
+
}
|
|
26
|
+
|
|
10
27
|
export class OrNode extends QueryNode {
|
|
11
28
|
private orQuery: OrQuery;
|
|
12
29
|
|
|
@@ -362,6 +379,15 @@ export class OrNode extends QueryNode {
|
|
|
362
379
|
}
|
|
363
380
|
}
|
|
364
381
|
|
|
382
|
+
// Gated single-pass rewrite: scan the base set ONCE and combine the OR
|
|
383
|
+
// branches as a disjunction of EXISTS predicates, instead of embedding
|
|
384
|
+
// the base SQL inside every branch and UNION-ing (N× base scan +
|
|
385
|
+
// UNION dedup + redundant outer DISTINCT). Same param push order, same
|
|
386
|
+
// result set, same ORDER BY entity_id ASC + pagination semantics.
|
|
387
|
+
if (hasComponentDependency && baseEntityQuery && shouldUseOrSinglePass()) {
|
|
388
|
+
return this.executeBaseSinglePass(context, baseEntityQuery, paramIndex);
|
|
389
|
+
}
|
|
390
|
+
|
|
365
391
|
// Build SQL for each branch
|
|
366
392
|
for (const branch of this.orQuery.branches) {
|
|
367
393
|
const componentId = ComponentRegistry.getComponentId(branch.component.name);
|
|
@@ -524,7 +550,150 @@ export class OrNode extends QueryNode {
|
|
|
524
550
|
params: context.params,
|
|
525
551
|
context
|
|
526
552
|
};
|
|
527
|
-
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Build a single OR branch as an `EXISTS (...)` predicate against the
|
|
557
|
+
* branch component's table, correlated to `idExpr` (e.g. `base.id`).
|
|
558
|
+
* Mirrors the filter switch of the legacy dependency branch exactly —
|
|
559
|
+
* same SQL fragments, same param push order — so the single-pass shape is
|
|
560
|
+
* result- and parameter-identical to the UNION shape it replaces.
|
|
561
|
+
*/
|
|
562
|
+
private buildBranchExists(
|
|
563
|
+
branch: OrQuery['branches'][number],
|
|
564
|
+
idExpr: string,
|
|
565
|
+
context: QueryContext,
|
|
566
|
+
paramIndex: number
|
|
567
|
+
): { sql: string; paramIndex: number } {
|
|
568
|
+
const componentId = ComponentRegistry.getComponentId(branch.component.name);
|
|
569
|
+
if (!componentId) {
|
|
570
|
+
throw new Error(`Component ${branch.component.name} is not registered`);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const componentTableName = this.getComponentTableName(componentId);
|
|
574
|
+
const componentIdParamIndex = paramIndex;
|
|
575
|
+
|
|
576
|
+
let sql = `EXISTS (
|
|
577
|
+
SELECT 1 FROM ${componentTableName} c
|
|
578
|
+
WHERE c.entity_id = ${idExpr}
|
|
579
|
+
AND c.type_id = $${componentIdParamIndex}
|
|
580
|
+
AND c.deleted_at IS NULL`;
|
|
581
|
+
|
|
582
|
+
context.params.push(componentId);
|
|
583
|
+
paramIndex++;
|
|
584
|
+
|
|
585
|
+
if (branch.filters && branch.filters.length > 0) {
|
|
586
|
+
for (const filter of branch.filters) {
|
|
587
|
+
const { field, operator, value } = filter;
|
|
588
|
+
const jsonPath = `c.data->>'${field}'`;
|
|
589
|
+
|
|
590
|
+
switch (operator) {
|
|
591
|
+
case "=":
|
|
592
|
+
case ">":
|
|
593
|
+
case "<":
|
|
594
|
+
case ">=":
|
|
595
|
+
case "<=":
|
|
596
|
+
case "!=":
|
|
597
|
+
if (typeof value === "string") {
|
|
598
|
+
sql += ` AND ${jsonPath} ${operator} $${paramIndex}`;
|
|
599
|
+
} else {
|
|
600
|
+
sql += ` AND (${jsonPath})::numeric ${operator} $${paramIndex}`;
|
|
601
|
+
}
|
|
602
|
+
context.params.push(value);
|
|
603
|
+
paramIndex++;
|
|
604
|
+
break;
|
|
605
|
+
case "LIKE":
|
|
606
|
+
case "ILIKE":
|
|
607
|
+
sql += ` AND ${jsonPath} ${operator} $${paramIndex}`;
|
|
608
|
+
context.params.push(value);
|
|
609
|
+
paramIndex++;
|
|
610
|
+
break;
|
|
611
|
+
case "IN":
|
|
612
|
+
if (Array.isArray(value)) {
|
|
613
|
+
const cast = jsonbInListCast(value);
|
|
614
|
+
const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
|
|
615
|
+
sql += ` AND ${cast.lhs(jsonPath)} IN (${placeholders})`;
|
|
616
|
+
context.params.push(...value);
|
|
617
|
+
}
|
|
618
|
+
break;
|
|
619
|
+
case "NOT IN":
|
|
620
|
+
if (Array.isArray(value)) {
|
|
621
|
+
const cast = jsonbInListCast(value);
|
|
622
|
+
const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
|
|
623
|
+
sql += ` AND ${cast.lhs(jsonPath)} NOT IN (${placeholders})`;
|
|
624
|
+
context.params.push(...value);
|
|
625
|
+
}
|
|
626
|
+
break;
|
|
627
|
+
default:
|
|
628
|
+
throw new Error(`Unsupported operator: ${operator}`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
sql += ")";
|
|
634
|
+
return { sql, paramIndex };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Single-pass execution for OR-on-base queries. The base set (the embedded
|
|
639
|
+
* ComponentInclusionNode SQL) is referenced once; each OR branch becomes an
|
|
640
|
+
* EXISTS predicate OR-ed together. Exclusions, ordering and pagination
|
|
641
|
+
* match the UNION path exactly (the base node already applied exclusions —
|
|
642
|
+
* re-applying here is the same idempotent no-op the UNION path performed).
|
|
643
|
+
*/
|
|
644
|
+
private executeBaseSinglePass(
|
|
645
|
+
context: QueryContext,
|
|
646
|
+
baseEntityQuery: string,
|
|
647
|
+
paramIndexStart: number
|
|
648
|
+
): QueryResult {
|
|
649
|
+
let paramIndex = paramIndexStart;
|
|
650
|
+
|
|
651
|
+
const existsClauses: string[] = [];
|
|
652
|
+
for (const branch of this.orQuery.branches) {
|
|
653
|
+
const built = this.buildBranchExists(branch, 'base.id', context, paramIndex);
|
|
654
|
+
existsClauses.push(built.sql);
|
|
655
|
+
paramIndex = built.paramIndex;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
let sql = `SELECT base.id as id FROM (${baseEntityQuery}) AS base WHERE (${existsClauses.join(' OR ')})`;
|
|
659
|
+
|
|
660
|
+
// Entity exclusions (idempotent — base already excluded them).
|
|
661
|
+
if (context.excludedEntityIds.size > 0) {
|
|
662
|
+
const excludedIds = Array.from(context.excludedEntityIds);
|
|
663
|
+
const placeholders = excludedIds.map(() => `$${paramIndex++}`).join(', ');
|
|
664
|
+
sql += ` AND base.id NOT IN (${placeholders})`;
|
|
665
|
+
context.params.push(...excludedIds);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Component exclusions (idempotent — base already excluded them).
|
|
669
|
+
if (context.excludedComponentIds.size > 0) {
|
|
670
|
+
const excludedTypes = Array.from(context.excludedComponentIds);
|
|
671
|
+
const placeholders = excludedTypes.map(() => `$${paramIndex++}`).join(', ');
|
|
672
|
+
sql += ` AND NOT EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_ex WHERE ec_ex.entity_id = base.id AND ec_ex.type_id IN (${placeholders}) AND ec_ex.deleted_at IS NULL)`;
|
|
673
|
+
context.params.push(...excludedTypes);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
sql += " ORDER BY base.id";
|
|
677
|
+
|
|
678
|
+
if (context.limit !== null) {
|
|
679
|
+
sql += ` LIMIT $${paramIndex++}`;
|
|
680
|
+
context.params.push(context.limit);
|
|
681
|
+
}
|
|
682
|
+
if (context.offsetValue > 0) {
|
|
683
|
+
sql += ` OFFSET $${paramIndex++}`;
|
|
684
|
+
context.params.push(context.offsetValue);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
context.paramIndex = paramIndex;
|
|
688
|
+
|
|
689
|
+
return {
|
|
690
|
+
sql,
|
|
691
|
+
params: context.params,
|
|
692
|
+
context
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
public getNodeType(): string {
|
|
528
697
|
return "OrNode";
|
|
529
698
|
}
|
|
530
699
|
}
|