|
8 | 8 | * downstream in `readDataFile`. |
9 | 9 | * |
10 | 10 | * @import {ExprNode} from 'squirreling' |
11 | | - * @import {BinaryNode, InValuesNode} from 'squirreling/src/ast.js' |
| 11 | + * @import {BinaryNode, CastType, InValuesNode} from 'squirreling/src/ast.js' |
12 | 12 | * @import {ParquetQueryFilter} from 'hyparquet' |
13 | 13 | * @param {ExprNode | undefined} where |
14 | 14 | * @returns {ParquetQueryFilter | undefined} |
@@ -38,12 +38,20 @@ function convertExpr(node, negate) { |
38 | 38 | if (node.type === 'in valuelist') { |
39 | 39 | return convertInValues(node, negate) |
40 | 40 | } |
41 | | - if (node.type === 'cast') { |
| 41 | + if (node.type === 'cast' && TRUTHINESS_PRESERVING_CASTS.has(node.toType)) { |
| 42 | + // A cast at boolean position (WHERE CAST(a = 1 AS INT)) keeps the operand's |
| 43 | + // truthiness only for boolean/numeric targets; TEXT ('false' is truthy) and |
| 44 | + // TIMESTAMP (any Date is truthy) do not, so those fall back to the engine. |
42 | 45 | return convertExpr(node.expr, negate) |
43 | 46 | } |
44 | 47 | return undefined |
45 | 48 | } |
46 | 49 |
|
| 50 | +/** @type {Set<CastType>} */ |
| 51 | +const TRUTHINESS_PRESERVING_CASTS = new Set( |
| 52 | + ['BOOLEAN', 'BOOL', 'INTEGER', 'INT', 'BIGINT', 'FLOAT', 'REAL', 'DOUBLE'] |
| 53 | +) |
| 54 | + |
47 | 55 | /** |
48 | 56 | * @param {BinaryNode} node |
49 | 57 | * @param {boolean} negate |
@@ -78,15 +86,97 @@ function convertBinary({ op, left, right }, negate) { |
78 | 86 | * @returns {{column: string | undefined, value: any, flipped: boolean}} |
79 | 87 | */ |
80 | 88 | function extractColumnAndValue(left, right) { |
81 | | - if (left.type === 'identifier' && right.type === 'literal') { |
82 | | - return { column: left.name, value: right.value, flipped: false } |
83 | | - } |
84 | | - if (left.type === 'literal' && right.type === 'identifier') { |
85 | | - return { column: right.name, value: left.value, flipped: true } |
| 89 | + if (left.type === 'identifier') { |
| 90 | + const lit = staticLiteral(right) |
| 91 | + if (lit) return { column: left.name, value: lit.value, flipped: false } |
| 92 | + } else if (right.type === 'identifier') { |
| 93 | + const lit = staticLiteral(left) |
| 94 | + if (lit) return { column: right.name, value: lit.value, flipped: true } |
86 | 95 | } |
87 | 96 | return { column: undefined, value: undefined, flipped: false } |
88 | 97 | } |
89 | 98 |
|
| 99 | +/** |
| 100 | + * Statically evaluate an expression to a constant. Handles plain literals and |
| 101 | + * casts of literals, including CAST(string AS TIMESTAMP), which is how |
| 102 | + * squirreling parses typed literals like TIMESTAMP '2026-08-06T00:00:00Z'. The |
| 103 | + * result is wrapped in {value} so an undefined-valued literal isn't confused |
| 104 | + * with "not constant". |
| 105 | + * |
| 106 | + * @param {ExprNode} node |
| 107 | + * @returns {{value: any} | undefined} |
| 108 | + */ |
| 109 | +function staticLiteral(node) { |
| 110 | + if (node.type === 'literal') return { value: node.value } |
| 111 | + if (node.type === 'cast') { |
| 112 | + const inner = staticLiteral(node.expr) |
| 113 | + if (!inner) return undefined |
| 114 | + return foldCast(node.toType, inner.value) |
| 115 | + } |
| 116 | + return undefined |
| 117 | +} |
| 118 | + |
| 119 | +/** |
| 120 | + * Mirror of squirreling's CAST evaluation over primitive literals. Must stay |
| 121 | + * in lockstep with the engine: a pushed-down filter replaces engine-side |
| 122 | + * WHERE, so a folded value that compares differently would change results. |
| 123 | + * A cast the engine would evaluate to null (unparseable date, NaN) returns |
| 124 | + * undefined: null comparisons match no rows, and falling back to the engine |
| 125 | + * preserves that without needing a filter for it. |
| 126 | + * |
| 127 | + * @param {CastType} toType |
| 128 | + * @param {any} val |
| 129 | + * @returns {{value: any} | undefined} |
| 130 | + */ |
| 131 | +function foldCast(toType, val) { |
| 132 | + if (val === null || val === undefined) return undefined |
| 133 | + if (toType === 'TEXT' || toType === 'STRING' || toType === 'VARCHAR') { |
| 134 | + return { value: String(val) } |
| 135 | + } |
| 136 | + if (toType === 'INTEGER' || toType === 'INT') { |
| 137 | + const num = Number(val) |
| 138 | + return isNaN(num) ? undefined : { value: Math.trunc(num) } |
| 139 | + } |
| 140 | + if (toType === 'BIGINT') { |
| 141 | + if (typeof val === 'bigint') return { value: val } |
| 142 | + const num = Number(val) |
| 143 | + return isNaN(num) ? undefined : { value: BigInt(Math.trunc(num)) } |
| 144 | + } |
| 145 | + if (toType === 'FLOAT' || toType === 'REAL' || toType === 'DOUBLE') { |
| 146 | + const num = Number(val) |
| 147 | + return isNaN(num) ? undefined : { value: num } |
| 148 | + } |
| 149 | + if (toType === 'BOOLEAN' || toType === 'BOOL') { |
| 150 | + return { value: Boolean(val) } |
| 151 | + } |
| 152 | + if (toType === 'TIMESTAMP') { |
| 153 | + const date = castTimestamp(val) |
| 154 | + return date ? { value: date } : undefined |
| 155 | + } |
| 156 | + return undefined |
| 157 | +} |
| 158 | + |
| 159 | +/** |
| 160 | + * Mirror of squirreling's TIMESTAMP cast: numbers as epoch millis, strings via |
| 161 | + * its `toDate` parse, which requires a YYYY-MM-DD prefix. (`toDate` is not in |
| 162 | + * squirreling's public exports, hence the copy.) |
| 163 | + * |
| 164 | + * @param {any} val |
| 165 | + * @returns {Date | undefined} |
| 166 | + */ |
| 167 | +function castTimestamp(val) { |
| 168 | + if (val instanceof Date) return val |
| 169 | + if (typeof val === 'number' || typeof val === 'bigint') { |
| 170 | + const date = new Date(Number(val)) |
| 171 | + return isNaN(date.getTime()) ? undefined : date |
| 172 | + } |
| 173 | + if (typeof val === 'string' && /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?/.test(val)) { |
| 174 | + const date = new Date(val) |
| 175 | + if (!isNaN(date.getTime())) return date |
| 176 | + } |
| 177 | + return undefined |
| 178 | +} |
| 179 | + |
90 | 180 | const COMP_OPS = new Set(['=', '==', '!=', '<>', '<', '>', '<=', '>=']) |
91 | 181 |
|
92 | 182 | /** |
@@ -152,8 +242,9 @@ function convertInValues(node, negate) { |
152 | 242 | if (node.expr.type !== 'identifier') return undefined |
153 | 243 | const values = [] |
154 | 244 | for (const val of node.values) { |
155 | | - if (val.type !== 'literal') return undefined |
156 | | - values.push(val.value) |
| 245 | + const lit = staticLiteral(val) |
| 246 | + if (!lit) return undefined |
| 247 | + values.push(lit.value) |
157 | 248 | } |
158 | 249 | return { [node.expr.name]: { [negate ? '$nin' : '$in']: values } } |
159 | 250 | } |
0 commit comments