Skip to content

Commit bec1c89

Browse files
committed
Timestamp and cast literal pushdown
1 parent 098fed3 commit bec1c89

6 files changed

Lines changed: 295 additions & 10 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
"test": "vitest run"
5454
},
5555
"dependencies": {
56-
"hyparquet": "1.27.1",
56+
"hyparquet": "1.28.1",
5757
"hyparquet-compressors": "1.1.1",
5858
"hyparquet-writer": "0.16.5",
5959
"squirreling": "0.15.0"

src/prune.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,12 @@ function opMightMatch(op, value, v, transform, sourceType) {
150150
}
151151

152152
const kind = transformKind(transform)
153+
// A day-transform partition value has iceberg type `date`, which the Avro
154+
// reader decodes to a `Date`, while `applyTransform('day', ...)` projects
155+
// literals to day ordinals; normalize to days so both sides compare.
156+
if (transform === 'day' && v instanceof Date) {
157+
v = Math.floor(v.getTime() / 86400000)
158+
}
153159
if (kind === 'identity') return identityMightMatch(op, v, value)
154160
if (kind === 'monotonic') return monotonicMightMatch(op, v, value, transform, sourceType)
155161
if (kind === 'bucket') return bucketMightMatch(op, v, value, transform, sourceType)

src/sql/whereFilter.js

Lines changed: 100 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* downstream in `readDataFile`.
99
*
1010
* @import {ExprNode} from 'squirreling'
11-
* @import {BinaryNode, InValuesNode} from 'squirreling/src/ast.js'
11+
* @import {BinaryNode, CastType, InValuesNode} from 'squirreling/src/ast.js'
1212
* @import {ParquetQueryFilter} from 'hyparquet'
1313
* @param {ExprNode | undefined} where
1414
* @returns {ParquetQueryFilter | undefined}
@@ -38,12 +38,20 @@ function convertExpr(node, negate) {
3838
if (node.type === 'in valuelist') {
3939
return convertInValues(node, negate)
4040
}
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.
4245
return convertExpr(node.expr, negate)
4346
}
4447
return undefined
4548
}
4649

50+
/** @type {Set<CastType>} */
51+
const TRUTHINESS_PRESERVING_CASTS = new Set(
52+
['BOOLEAN', 'BOOL', 'INTEGER', 'INT', 'BIGINT', 'FLOAT', 'REAL', 'DOUBLE']
53+
)
54+
4755
/**
4856
* @param {BinaryNode} node
4957
* @param {boolean} negate
@@ -78,15 +86,97 @@ function convertBinary({ op, left, right }, negate) {
7886
* @returns {{column: string | undefined, value: any, flipped: boolean}}
7987
*/
8088
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 }
8695
}
8796
return { column: undefined, value: undefined, flipped: false }
8897
}
8998

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+
90180
const COMP_OPS = new Set(['=', '==', '!=', '<>', '<', '>', '<=', '>='])
91181

92182
/**
@@ -152,8 +242,9 @@ function convertInValues(node, negate) {
152242
if (node.expr.type !== 'identifier') return undefined
153243
const values = []
154244
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)
157248
}
158249
return { [node.expr.name]: { [negate ? '$nin' : '$in']: values } }
159250
}

test/prune.test.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,16 @@ describe('partitionMightMatch — monotonic (day)', () => {
114114
const ts = new Date('2022-01-01T00:00:00Z')
115115
expect(partitionMightMatch({ ts: { $ne: ts } }, entry({ ts_day: day('2022-01-01') }), schema, m)).toBe(true)
116116
})
117+
118+
it('prunes when the partition value decodes as a Date (avro logical date)', () => {
119+
// Spec-compliant manifests (Spark, Java) type day partitions as
120+
// {int, logicalType: date}, which the avro reader decodes to a Date.
121+
const ts = new Date('2022-06-15T12:00:00Z')
122+
expect(partitionMightMatch({ ts: { $gt: ts } }, entry({ ts_day: new Date('2022-01-01') }), schema, m)).toBe(false)
123+
expect(partitionMightMatch({ ts: { $gt: ts } }, entry({ ts_day: new Date('2022-12-01') }), schema, m)).toBe(true)
124+
expect(partitionMightMatch({ ts: { $eq: ts } }, entry({ ts_day: new Date('2022-06-15') }), schema, m)).toBe(true)
125+
expect(partitionMightMatch({ ts: { $eq: ts } }, entry({ ts_day: new Date('2022-06-14') }), schema, m)).toBe(false)
126+
})
117127
})
118128

119129
describe('partitionMightMatch — combinators and conservative defaults', () => {

test/sql/scanPruning.test.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { collect } from 'squirreling'
12
import { describe, expect, it, vi } from 'vitest'
23
import { ByteWriter, parquetWrite } from 'hyparquet-writer'
34
import { fileCatalogCommit } from '../../src/write/commit.js'
45
import { icebergCreate } from '../../src/create.js'
56
import { readDataFile } from '../../src/read.js'
67
import { icebergStageAppend } from '../../src/write/stage.js'
78
import { icebergDataSource } from '../../src/sql/icebergDataSource.js'
9+
import { icebergQuery } from '../../src/sql/icebergQuery.js'
810
import { memResolver } from '../helpers.js'
911

1012
/**
@@ -293,3 +295,102 @@ describe('#21 row-group pruning (readDataFile)', () => {
293295
expect(filtered.length).toBe(25)
294296
})
295297
})
298+
299+
describe('timestamp predicate pushdown on a day-partitioned table', () => {
300+
/** @type {Schema} */
301+
const schema = {
302+
type: 'struct',
303+
'schema-id': 0,
304+
fields: [
305+
{ id: 1, name: 'id', required: true, type: 'long' },
306+
{ id: 2, name: 'message_created_at', required: false, type: 'timestamptz' },
307+
],
308+
}
309+
/** @type {import('../../src/types.js').PartitionSpec} */
310+
const partitionSpec = {
311+
'spec-id': 0,
312+
fields: [{ 'source-id': 2, 'field-id': 1000, name: 'created_day', transform: 'day' }],
313+
}
314+
const days = ['2026-08-04', '2026-08-05', '2026-08-06']
315+
316+
/**
317+
* Build a table partitioned by day(message_created_at) with one data file
318+
* per day (a single append; the writer groups records by partition tuple).
319+
*
320+
* @returns {Promise<{ tableUrl: string, resolver: Resolver, dataFilesRead: () => number }>}
321+
*/
322+
async function buildDayPartitionedTable() {
323+
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
324+
const tableUrl = 'mem://events'
325+
const { resolver: memR } = memResolver()
326+
let metadata = await icebergCreate({ tableUrl, resolver: memR, schema, partitionSpec })
327+
const records = []
328+
let id = 0n
329+
for (const day of days) {
330+
for (const hour of ['01', '09', '17']) {
331+
records.push({ id: id++, message_created_at: new Date(`${day}T${hour}:30:00Z`) })
332+
}
333+
}
334+
const staged = await icebergStageAppend({ tableUrl, metadata, records, resolver: memR })
335+
metadata = await fileCatalogCommit({ tableUrl, metadata, staged, resolver: memR })
336+
const { resolver, dataFilesRead } = countingResolver({ reader: memR.reader })
337+
return { tableUrl, resolver, dataFilesRead }
338+
}
339+
340+
/**
341+
* @param {string} where
342+
* @returns {Promise<{ rows: Record<string, any>[], dataFilesRead: number }>}
343+
*/
344+
async function query(where) {
345+
const { tableUrl, resolver, dataFilesRead } = await buildDayPartitionedTable()
346+
const source = await icebergDataSource({ tableUrl, resolver })
347+
const result = await icebergQuery({
348+
query: `SELECT id, message_created_at FROM events WHERE ${where} ORDER BY id`,
349+
tables: { events: source },
350+
})
351+
const rows = await collect(result)
352+
return { rows, dataFilesRead: dataFilesRead() }
353+
}
354+
355+
it('skips files from days that cannot match a >= TIMESTAMP predicate', async () => {
356+
const { rows, dataFilesRead } = await query('message_created_at >= TIMESTAMP \'2026-08-06T00:00:00Z\'')
357+
expect(rows.map(r => r.id)).toEqual([6n, 7n, 8n])
358+
expect(dataFilesRead).toBe(1)
359+
})
360+
361+
it('returns the same rows as engine-side filtering', async () => {
362+
// The added `id + 0 >= 0` conjunct is a tautology, but arithmetic is not
363+
// pushable, so the whole WHERE falls back to the engine and reads all files.
364+
const pushed = await query('message_created_at >= TIMESTAMP \'2026-08-06T00:00:00Z\'')
365+
const engine = await query('message_created_at >= TIMESTAMP \'2026-08-06T00:00:00Z\' AND id + 0 >= 0')
366+
expect(pushed.rows).toEqual(engine.rows)
367+
expect(engine.dataFilesRead).toBe(3)
368+
expect(pushed.dataFilesRead).toBe(1)
369+
})
370+
371+
it('prunes to two files for a < TIMESTAMP mid-range boundary', async () => {
372+
const { rows, dataFilesRead } = await query('message_created_at < TIMESTAMP \'2026-08-06T00:00:00Z\'')
373+
expect(rows.map(r => r.id)).toEqual([0n, 1n, 2n, 3n, 4n, 5n])
374+
expect(dataFilesRead).toBe(2)
375+
})
376+
377+
it('prunes to one file for an equality on a single instant', async () => {
378+
const { rows, dataFilesRead } = await query('message_created_at = TIMESTAMP \'2026-08-05T09:30:00Z\'')
379+
expect(rows.map(r => r.id)).toEqual([4n])
380+
expect(dataFilesRead).toBe(1)
381+
})
382+
383+
it('prunes to one file for an IN list of same-day instants', async () => {
384+
const { rows, dataFilesRead } = await query(
385+
'message_created_at IN (TIMESTAMP \'2026-08-05T01:30:00Z\', TIMESTAMP \'2026-08-05T17:30:00Z\')')
386+
expect(rows.map(r => r.id)).toEqual([3n, 5n])
387+
expect(dataFilesRead).toBe(1)
388+
})
389+
390+
it('keeps the boundary day for BETWEEN-style range predicates', async () => {
391+
const { rows, dataFilesRead } = await query(
392+
'message_created_at >= TIMESTAMP \'2026-08-05T00:00:00Z\' AND message_created_at < TIMESTAMP \'2026-08-06T00:00:00Z\'')
393+
expect(rows.map(r => r.id)).toEqual([3n, 4n, 5n])
394+
expect(dataFilesRead).toBe(1)
395+
})
396+
})

test/sql/whereFilter.test.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ function inList(expr, values) {
4949
return /** @type {ExprNode} */ ({ type: 'in valuelist', expr, values })
5050
}
5151

52+
/**
53+
* @param {string} toType
54+
* @param {ExprNode} expr
55+
* @returns {ExprNode}
56+
*/
57+
function cast(toType, expr) {
58+
return /** @type {ExprNode} */ ({ type: 'cast', toType, expr })
59+
}
60+
5261
describe.concurrent('whereToParquetFilter', () => {
5362
it('returns undefined for missing where', () => {
5463
expect(whereToParquetFilter(undefined)).toBeUndefined()
@@ -144,6 +153,74 @@ describe.concurrent('whereToParquetFilter', () => {
144153
expect(whereToParquetFilter(where)).toEqual({ a: { $eq: 1 } })
145154
})
146155

156+
it('falls back for a truthiness-changing cast at boolean position', () => {
157+
// CAST(a = 1 AS TEXT) yields 'false', which is truthy, so unwrapping the
158+
// cast would filter rows the engine keeps.
159+
expect(whereToParquetFilter(cast('TEXT', bin('=', id('a'), lit(1))))).toBeUndefined()
160+
expect(whereToParquetFilter(cast('TIMESTAMP', bin('=', id('a'), lit(1))))).toBeUndefined()
161+
})
162+
163+
it('converts a TIMESTAMP typed literal into a Date predicate', () => {
164+
const where = bin('>=', id('message_created_at'), cast('TIMESTAMP', lit('2026-08-06T00:00:00Z')))
165+
expect(whereToParquetFilter(where)).toEqual({
166+
message_created_at: { $gte: new Date('2026-08-06T00:00:00Z') },
167+
})
168+
})
169+
170+
it('flips a TIMESTAMP literal on the left of the comparison', () => {
171+
const where = bin('>', cast('TIMESTAMP', lit('2026-08-06T00:00:00Z')), id('ts'))
172+
expect(whereToParquetFilter(where)).toEqual({ ts: { $lt: new Date('2026-08-06T00:00:00Z') } })
173+
})
174+
175+
it('negates a TIMESTAMP comparison under NOT', () => {
176+
const where = un('NOT', bin('<', id('ts'), cast('TIMESTAMP', lit('2026-08-06T00:00:00Z'))))
177+
expect(whereToParquetFilter(where)).toEqual({ ts: { $gte: new Date('2026-08-06T00:00:00Z') } })
178+
})
179+
180+
it('casts numeric epoch literals to TIMESTAMP', () => {
181+
const where = bin('=', id('ts'), cast('TIMESTAMP', lit(86400000)))
182+
expect(whereToParquetFilter(where)).toEqual({ ts: { $eq: new Date('1970-01-02T00:00:00Z') } })
183+
})
184+
185+
it('folds numeric, boolean, and text casts of literals', () => {
186+
expect(whereToParquetFilter(bin('=', id('a'), cast('INT', lit('5'))))).toEqual({ a: { $eq: 5 } })
187+
expect(whereToParquetFilter(bin('=', id('a'), cast('INT', lit(5.7))))).toEqual({ a: { $eq: 5 } })
188+
expect(whereToParquetFilter(bin('=', id('a'), cast('BIGINT', lit(5))))).toEqual({ a: { $eq: 5n } })
189+
expect(whereToParquetFilter(bin('=', id('a'), cast('DOUBLE', lit('2.5'))))).toEqual({ a: { $eq: 2.5 } })
190+
expect(whereToParquetFilter(bin('=', id('a'), cast('BOOL', lit(1))))).toEqual({ a: { $eq: true } })
191+
expect(whereToParquetFilter(bin('=', id('a'), cast('TEXT', lit(5))))).toEqual({ a: { $eq: '5' } })
192+
})
193+
194+
it('folds nested casts', () => {
195+
const where = bin('=', id('a'), cast('TEXT', cast('INT', lit('5.7'))))
196+
expect(whereToParquetFilter(where)).toEqual({ a: { $eq: '5' } })
197+
})
198+
199+
it('falls back for casts the engine would evaluate to null', () => {
200+
expect(whereToParquetFilter(bin('>=', id('ts'), cast('TIMESTAMP', lit('not a date'))))).toBeUndefined()
201+
expect(whereToParquetFilter(bin('>=', id('ts'), cast('TIMESTAMP', lit(null))))).toBeUndefined()
202+
expect(whereToParquetFilter(bin('=', id('a'), cast('INT', lit('abc'))))).toBeUndefined()
203+
expect(whereToParquetFilter(bin('=', id('a'), cast('INT', lit(null))))).toBeUndefined()
204+
})
205+
206+
it('pushes TIMESTAMP literals inside IN lists', () => {
207+
// Needs hyparquet >= 1.28.0, whose $in/$nin compare Dates by time.
208+
const where = inList(id('ts'), [cast('TIMESTAMP', lit('2026-08-06T00:00:00Z')), lit(1)])
209+
expect(whereToParquetFilter(where)).toEqual({
210+
ts: { $in: [new Date('2026-08-06T00:00:00Z'), 1] },
211+
})
212+
})
213+
214+
it('falls back when an IN value cast is unparseable', () => {
215+
const where = inList(id('ts'), [cast('TIMESTAMP', lit('not a date'))])
216+
expect(whereToParquetFilter(where)).toBeUndefined()
217+
})
218+
219+
it('folds casts inside IN lists', () => {
220+
const where = inList(id('a'), [cast('INT', lit('1')), lit(2)])
221+
expect(whereToParquetFilter(where)).toEqual({ a: { $in: [1, 2] } })
222+
})
223+
147224
it('returns undefined for LIKE (not pushable)', () => {
148225
const where = bin('LIKE', id('a'), lit('foo%'))
149226
expect(whereToParquetFilter(where)).toBeUndefined()

0 commit comments

Comments
 (0)