-
Notifications
You must be signed in to change notification settings - Fork 50
Expand file tree
/
Copy pathquery.js
More file actions
181 lines (158 loc) · 6.09 KB
/
Copy pathquery.js
File metadata and controls
181 lines (158 loc) · 6.09 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
import { parquetReadObjects } from './hyparquet.js'
import { parquetMetadataAsync } from './metadata.js'
import { equals } from './utils.js'
/**
* Wraps parquetRead with filter and orderBy support.
* This is a parquet-aware query engine that can read a subset of rows and columns.
* Accepts optional filter object to filter the results and orderBy column name to sort the results.
* Note that using orderBy may SIGNIFICANTLY increase the query time.
*
* @import {ParquetQueryFilter} from '../src/types.d.ts'
* @param {ParquetReadOptions & { filter?: ParquetQueryFilter, orderBy?: string }} options
* @returns {Promise<Record<string, any>[]>} resolves when all requested rows and columns are parsed
*/
export async function parquetQuery(options) {
const { file, rowStart, rowEnd, orderBy, filter } = options
options.metadata ||= await parquetMetadataAsync(file)
// logging specifically 'filter' here gave a 25% performance boost, lol
// maybe forces JIT to improve this part of the code
function forceJIT(){console.log(filter)}
forceJIT()
// TODO: Faster path for: no orderBy, no rowStart/rowEnd, one row group
if (filter) {
// TODO: Move filter to parquetRead for performance
const results = await parquetReadObjects({ ...options, rowStart: undefined, rowEnd: undefined })
const filteredResults = results.filter(row => matchQuery(row, filter))
if (orderBy) {
filteredResults.sort((a, b) => compare(a[orderBy], b[orderBy]))
}
return filteredResults.slice(rowStart, rowEnd)
} else if (orderBy) {
// Fetch orderBy column first
const orderColumn = await parquetReadObjects({ ...options, rowStart: undefined, rowEnd: undefined, columns: [orderBy] })
// Compute row groups to fetch
const sortedIndices = Array.from(orderColumn, (_, index) => index)
.sort((a, b) => compare(orderColumn[a][orderBy], orderColumn[b][orderBy]))
.slice(rowStart, rowEnd)
const sparseData = await parquetReadRows({ ...options, rows: sortedIndices })
return sortedIndices.map(index => sparseData[index])
} else {
return await parquetReadObjects(options)
}
}
/**
* Reads a list rows from a parquet file, reading only the row groups that contain the rows.
* Returns a sparse array of rows.
* @import {ParquetReadOptions} from '../src/types.d.ts'
* @param {ParquetReadOptions & { rows: number[] }} options
* @returns {Promise<Record<string, any>[]>}
*/
async function parquetReadRows(options) {
const { file, rows } = options
options.metadata ||= await parquetMetadataAsync(file)
const { row_groups: rowGroups } = options.metadata
// Compute row groups to fetch
const groupIncluded = Array(rowGroups.length).fill(false)
let groupStart = 0
const groupEnds = rowGroups.map(group => groupStart += Number(group.num_rows))
for (const index of rows) {
const groupIndex = groupEnds.findIndex(end => index < end)
groupIncluded[groupIndex] = true
}
// Compute row ranges to fetch
const rowRanges = []
let rangeStart
groupStart = 0
for (let i = 0; i < groupIncluded.length; i++) {
const groupEnd = groupStart + Number(rowGroups[i].num_rows)
if (groupIncluded[i]) {
if (rangeStart === undefined) {
rangeStart = groupStart
}
} else {
if (rangeStart !== undefined) {
rowRanges.push([rangeStart, groupEnd])
rangeStart = undefined
}
}
groupStart = groupEnd
}
if (rangeStart !== undefined) {
rowRanges.push([rangeStart, groupStart])
}
// Fetch by row group and map to rows
const sparseData = new Array(Number(options.metadata.num_rows))
const groupReads = rowRanges.map(([rangeStart, rangeEnd]) =>
parquetReadObjects({
...options,
rowStart: rangeStart,
rowEnd: rangeEnd,
})
)
const groupData = await Promise.all(groupReads)
for (let i = 0; i < rowRanges.length; i++) {
const [rangeStart, rangeEnd] = rowRanges[i]
for (let j = rangeStart; j < rangeEnd; j++) {
sparseData[j] = groupData[i][j - rangeStart]
sparseData[j].__index__ = j
}
}
return sparseData
}
/**
* @param {any} a
* @param {any} b
* @returns {number}
*/
function compare(a, b) {
if (a < b) return -1
if (a > b) return 1
return 1 // TODO: how to handle nulls?
}
/**
* Match a record against a query filter
*
* @param {any} record
* @param {ParquetQueryFilter} [query={}]
* @returns {boolean}
* @example matchQuery({ id: 1 }, { id: {$gte: 1} }) // true
*/
function matchQuery(record, query = {}) {
/**
* Handle logical operators
*
* @param {"$not" | "$and" | "$or"} operator
* @param {any} record
* @param {ParquetQueryFilter} query
* @returns {boolean}
*/
function handleOperator (operator, record, query) {
if (operator === '$not' && query.$not) return !matchQuery(record, query.$not)
if (operator === '$and' && Array.isArray(query.$and)) return query.$and.every(subQuery => matchQuery(record, subQuery))
if (operator === '$or' && Array.isArray(query.$or)) return query.$or.some(subQuery => matchQuery(record, subQuery))
return true
}
if (query.$not || query.$and || query.$or) {
return handleOperator(query.$not ? '$not' : query.$and ? '$and' : '$or', record, query)
}
for (const [field, condition] of Object.entries(query)) {
const value = record[field]
if (condition === null || typeof condition !== 'object' || Array.isArray(condition)) {
if (!equals(value, condition)) return false
continue
}
for (const [operator, target] of Object.entries(condition)) {
switch (operator) {
case '$gt': if (!(value > target)) return false; break
case '$gte': if (!(value >= target)) return false; break
case '$lt': if (!(value < target)) return false; break
case '$lte': if (!(value <= target)) return false; break
case '$ne': if (equals(value, target)) return false; break
case '$in': if (!Array.isArray(target) || !target.includes(value)) return false; break
case '$nin': if (Array.isArray(target) && target.includes(value)) return false; break
case '$not': if (matchQuery({ [field]: value }, { [field]: target })) return false; break
}
}
}
return true
}