squirreling 0.16.5 → 0.16.6

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 +4 -4
  2. package/src/plan/plan.js +112 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.16.5",
3
+ "version": "0.16.6",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -41,11 +41,11 @@
41
41
  "devDependencies": {
42
42
  "@eslint/js": "10.0.1",
43
43
  "@types/node": "26.5.0",
44
- "@vitest/coverage-v8": "5.0.0",
44
+ "@vitest/coverage-v8": "5.0.1",
45
45
  "eslint": "10.10.0",
46
- "eslint-plugin-jsdoc": "64.3.6",
46
+ "eslint-plugin-jsdoc": "64.5.2",
47
47
  "globals": "17.12.0",
48
48
  "typescript": "7.0.2",
49
- "vitest": "5.0.0"
49
+ "vitest": "5.0.1"
50
50
  }
51
51
  }
package/src/plan/plan.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { dataSourceColumns } from '../backend/dataSource.js'
1
2
  import { derivedAlias } from '../expression/alias.js'
2
3
  import { parseSql } from '../parse/parse.js'
3
4
  import { findAggregate } from '../validation/aggregates.js'
@@ -238,6 +239,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
238
239
  /** @type {QueryPlan} */
239
240
  let plan = planFrom({ select, ctePlans, cteColumns, hints, tables, outerScope })
240
241
  pruneAggregateColumns(plan, subqueryNeeds)
242
+ const directFrom = plan.type === 'Scan' && plan.hints === hints
241
243
 
242
244
  // Add JOINs
243
245
  if (select.joins.length) {
@@ -248,8 +250,13 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
248
250
  const isOwnScan = plan.type === 'Scan' && plan.hints === hints
249
251
 
250
252
  // Add WHERE filter when the scan didn't receive it
251
- if (select.where && !isOwnScan) {
252
- plan = { type: 'Filter', condition: select.where, child: plan }
253
+ let { where } = select
254
+ if (directFrom && select.joins.length === 1 && !outerScope?.length &&
255
+ !ctePlans?.has(select.joins[0].table?.toLowerCase())) {
256
+ where = pushJoinPredicates(plan, where, tables)
257
+ }
258
+ if (where && !isOwnScan) {
259
+ plan = { type: 'Filter', condition: where, child: plan }
253
260
  }
254
261
 
255
262
  if (useGrouping) {
@@ -826,6 +833,109 @@ function collectConjuncts(node, out) {
826
833
  out.push(node)
827
834
  }
828
835
 
836
+ /**
837
+ * Push total, table-local comparisons into fresh scans of a direct INNER
838
+ * equijoin. Scan execution already enforces hints a source cannot apply.
839
+ * Validate ON before moving anything; an unsafe ON also blocks WHERE
840
+ * pushdown. Safe ON terms can move even when WHERE must remain above the
841
+ * join, since ON already excludes those pairs before WHERE is evaluated.
842
+ *
843
+ * @param {QueryPlan} plan
844
+ * @param {ExprNode | undefined} where
845
+ * @param {Record<string, AsyncDataSource>} [tables]
846
+ * @returns {ExprNode | undefined}
847
+ */
848
+ function pushJoinPredicates(plan, where, tables) {
849
+ if (!tables || plan.type !== 'HashJoin' || plan.joinType !== 'INNER' ||
850
+ plan.left.type !== 'Scan' || plan.right.type !== 'Scan') return where
851
+ const scans = [plan.left, plan.right]
852
+ if (scans.some(scan => scan.hints.where || scan.hints.limit !== undefined || scan.hints.offset)) return where
853
+ const aliases = scans.map(scan => scan.alias ?? scan.table)
854
+ if (aliases[0] === aliases[1]) return where
855
+ const columns = scans.map(scan => dataSourceColumns(tables[scan.table]))
856
+ // A prefix that also names a struct column is not an unambiguous alias.
857
+ if (columns.some(names => aliases.some(alias => names.includes(alias)))) return where
858
+
859
+ /**
860
+ * @param {ExprNode} expr
861
+ * @returns {'left' | 'right' | 'literal' | 'unsafe'}
862
+ */
863
+ function owner(expr) {
864
+ if (expr.type === 'literal') return 'literal'
865
+ if (expr.type !== 'identifier' || !expr.prefix) return 'unsafe'
866
+ const index = aliases.indexOf(expr.prefix)
867
+ if (index < 0 || !columns[index].includes(expr.name)) return 'unsafe'
868
+ return index === 0 ? 'left' : 'right'
869
+ }
870
+
871
+ // Avoid changing the evaluation of unresolved or unqualified ON keys.
872
+ if (plan.leftKeys.some(key => owner(key) !== 'left') || plan.rightKeys.some(key => owner(key) !== 'right')) return where
873
+ /**
874
+ * @param {ExprNode[]} conjuncts
875
+ * @returns {Array<'left' | 'right' | 'residual'> | undefined}
876
+ */
877
+ function classify(conjuncts) {
878
+ /** @type {Array<'left' | 'right' | 'residual'>} */
879
+ const targets = []
880
+ for (const expr of conjuncts) {
881
+ if (expr.type !== 'binary' || !['=', '==', '!=', '<>', '<', '<=', '>', '>='].includes(expr.op)) return undefined
882
+ const left = owner(expr.left)
883
+ const right = owner(expr.right)
884
+ if (left === 'unsafe' || right === 'unsafe') return undefined
885
+ // Only column/literal terms move; cross-table comparisons remain residual.
886
+ targets.push(left !== 'literal' && right === 'literal' ? left : right !== 'literal' && left === 'literal' ? right : 'residual')
887
+ }
888
+ return targets
889
+ }
890
+
891
+ /**
892
+ * @param {ExprNode[]} conjuncts
893
+ * @param {Array<'left' | 'right' | 'residual'>} targets
894
+ * @returns {ExprNode | undefined}
895
+ */
896
+ function push(conjuncts, targets) {
897
+ /** @type {ExprNode | undefined} */
898
+ let residual
899
+ for (let i = 0; i < conjuncts.length; i++) {
900
+ const target = targets[i]
901
+ if (target === 'residual') {
902
+ residual = andPredicate(residual, conjuncts[i])
903
+ } else {
904
+ const scan = target === 'left' ? scans[0] : scans[1]
905
+ scan.hints = { ...scan.hints, where: andPredicate(scan.hints.where, conjuncts[i]) }
906
+ }
907
+ }
908
+ return residual
909
+ }
910
+
911
+ /** @type {ExprNode[]} */
912
+ const onConjuncts = []
913
+ if (plan.residual) collectConjuncts(plan.residual, onConjuncts)
914
+ const onTargets = classify(onConjuncts)
915
+ if (!onTargets) return where
916
+ const onResidual = push(onConjuncts, onTargets)
917
+ if (onResidual) plan.residual = onResidual
918
+ else delete plan.residual
919
+
920
+ if (!where) return undefined
921
+ /** @type {ExprNode[]} */
922
+ const whereConjuncts = []
923
+ collectConjuncts(where, whereConjuncts)
924
+ const whereTargets = classify(whereConjuncts)
925
+ return whereTargets ? push(whereConjuncts, whereTargets) : where
926
+ }
927
+
928
+ /**
929
+ * @param {ExprNode | undefined} left
930
+ * @param {ExprNode} right
931
+ * @returns {ExprNode}
932
+ */
933
+ function andPredicate(left, right) {
934
+ return left
935
+ ? { type: 'binary', op: 'AND', left, right, positionStart: left.positionStart, positionEnd: right.positionEnd }
936
+ : right
937
+ }
938
+
829
939
  /**
830
940
  * Returns the (leftKey, rightKey) pair for an equi conjunct, oriented so
831
941
  * leftKey references the left input and rightKey references the right input.