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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/query/OrNode.ts +170 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunsane",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "author": {
5
5
  "name": "yaaruu"
6
6
  },
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
- } public getNodeType(): string {
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
  }