File size: 8,455 Bytes
9ae1216 | 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 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 | /**
* @import {AsyncRowGroup, BaseParquetReadOptions, DecodedArray, ParquetReadOptions, SchemaElement} from '../src/types.js'
*/
import { columnsNeededForFilter, matchFilter } from './filter.js'
import { parquetMetadataAsync, parquetSchema } from './metadata.js'
import { parquetPlan, prefetchAsyncBuffer, prefetchBloomFilters } from './plan.js'
import { assembleAsync, asyncGroupToRows, readRowGroup } from './rowgroup.js'
import { concat } from './utils.js'
/**
* Read parquet data rows from a file-like object.
* Reads the minimal number of row groups and columns to satisfy the request.
*
* Returns a void promise when complete.
* Errors are thrown on the returned promise.
* Data is returned in callbacks onComplete, onChunk, onPage, NOT the return promise.
* See parquetReadObjects for a more convenient API.
*
* @param {ParquetReadOptions} options read options
* @returns {Promise<void>} resolves when all requested rows and columns are parsed, all errors are thrown here
*/
export async function parquetRead(options) {
// load metadata if not provided
options.metadata ??= await parquetMetadataAsync(options.file, options)
const { rowStart = 0, rowEnd, columns, onChunk, onComplete, rowFormat, filter, filterStrict = true } = options
// Filter requires object format to match column names
if (filter && rowFormat !== 'object') {
throw new Error('parquet filter requires rowFormat: "object"')
}
// Include filter columns in the read plan
const filterColumns = columnsNeededForFilter(filter)
if (filterColumns.length) {
const schemaColumns = parquetSchema(options.metadata).children.map(c => c.element.name)
const missingColumns = filterColumns.filter(c => !schemaColumns.includes(c))
if (missingColumns.length) {
throw new Error(`parquet filter columns not found: ${missingColumns.join(', ')}`)
}
}
let readColumns = columns
let requiresProjection = false
if (columns && filter) {
const missingFilterColumns = filterColumns.filter(c => !columns.includes(c))
if (missingFilterColumns.length) {
readColumns = [...columns, ...missingFilterColumns]
requiresProjection = true
}
}
// read row groups with expanded columns
let readOptions = readColumns !== columns ? { ...options, columns: readColumns } : options
readOptions = await withBloomFilters(readOptions)
const asyncGroups = parquetReadAsync(readOptions)
// skip assembly if no onComplete or onChunk, but wait for reading to finish
if (!onComplete && !onChunk) {
await awaitAllColumns(asyncGroups)
return
}
// assemble struct columns
const schemaTree = parquetSchema(options.metadata)
const assembled = asyncGroups.map(arg => assembleAsync(arg, schemaTree, options.parsers))
// onChunk emit all chunks (don't await). Rejection is surfaced by awaitAllColumns below.
if (onChunk) {
for (const asyncGroup of assembled) {
for (const asyncColumn of asyncGroup.asyncColumns) {
asyncColumn.data.then(({ data, skipped }) => {
let rowStart = asyncGroup.groupStart + skipped
for (const columnData of data) {
onChunk({
columnName: asyncColumn.pathInSchema[0],
columnData,
rowStart,
rowEnd: rowStart + columnData.length,
})
rowStart += columnData.length
}
}, () => {})
}
}
}
// onComplete transpose column chunks to rows
if (onComplete) {
// wait for all reads to settle so a sibling rejection cannot leak
await awaitAllColumns(assembled)
// loosen the types to avoid duplicate code
/** @type {any[]} */
const rows = []
for (const asyncGroup of assembled) {
// filter to rows in range
const selectStart = Math.max(rowStart - asyncGroup.groupStart, 0)
const selectEnd = Math.min((rowEnd ?? Infinity) - asyncGroup.groupStart, asyncGroup.groupRows)
// transpose column chunks to rows in output
const groupData = rowFormat === 'object' ?
await asyncGroupToRows(asyncGroup, selectStart, selectEnd, readColumns, 'object') :
await asyncGroupToRows(asyncGroup, selectStart, selectEnd, columns, 'array')
// Apply filter and projection
if (filter) {
// eslint-disable-next-line no-extra-parens
for (const row of /** @type {Record<string, any>[]} */ (groupData)) {
if (matchFilter(row, filter, filterStrict)) {
if (requiresProjection && columns) {
for (const col of filterColumns) {
if (!columns.includes(col)) delete row[col]
}
}
rows.push(row)
}
}
} else {
concat(rows, groupData)
}
}
onComplete(rows)
} else {
// wait for all async groups to finish (complete takes care of this)
await awaitAllColumns(assembled)
}
}
/**
* Await every column promise across the given row groups via Promise.allSettled
* so no rejection escapes as an unhandledRejection. Throws the first rejection.
*
* @param {AsyncRowGroup[]} asyncGroups
* @returns {Promise<void>}
*/
async function awaitAllColumns(asyncGroups) {
const all = asyncGroups.flatMap(g => g.asyncColumns.map(c => c.data))
const results = await Promise.allSettled(all)
const failed = results.find(r => r.status === 'rejected')
if (failed) throw failed.reason
}
/**
* @param {ParquetReadOptions} options read options
* @returns {AsyncRowGroup[]}
*/
export function parquetReadAsync(options) {
if (!options.metadata) throw new Error('parquet requires metadata')
// TODO: validate options (start, end, columns, etc)
// prefetch byte ranges
const plan = parquetPlan(options)
options.file = prefetchAsyncBuffer(options.file, plan)
// read row groups
return plan.groups.map(groupPlan => readRowGroup(options, plan, groupPlan))
}
/**
* Reads a single column from a parquet file.
*
* @param {BaseParquetReadOptions} options
* @returns {Promise<DecodedArray>}
*/
export async function parquetReadColumn(options) {
if (options.columns?.length !== 1) {
throw new Error('parquetReadColumn expected columns: [columnName]')
}
options.metadata ??= await parquetMetadataAsync(options.file, options)
const asyncGroups = parquetReadAsync(await withBloomFilters(options))
// assemble struct columns
const schemaTree = parquetSchema(options.metadata)
const assembled = asyncGroups.map(arg => assembleAsync(arg, schemaTree, options.parsers))
// wait for all reads to settle so a sibling rejection cannot leak
await awaitAllColumns(assembled)
/** @type {DecodedArray} */
const columnData = []
for (const rg of assembled) {
const { data } = await rg.asyncColumns[0].data
for (const chunk of data) {
concat(columnData, chunk)
}
}
return columnData
}
/**
* Conditionally fetch bloom filters and attach them (and the per-column schema
* elements they require) to options so parquetPlan can use them for row-group
* pruning. Returns options unchanged when there's no filter or the user has
* disabled bloom pushdown.
*
* @param {BaseParquetReadOptions} options
* @returns {Promise<BaseParquetReadOptions>}
*/
async function withBloomFilters(options) {
if (!options.useBloomFilters) return options
if (!options.filter || !options.metadata) return options
const schemaTree = parquetSchema(options.metadata)
/** @type {Record<string, SchemaElement>} */
const schemaElements = {}
for (const child of schemaTree.children) schemaElements[child.element.name] = child.element
const bloomFiltersByGroup = await prefetchBloomFilters({
file: options.file,
metadata: options.metadata,
filter: options.filter,
filterStrict: options.filterStrict,
})
// eslint-disable-next-line no-extra-parens
return /** @type {BaseParquetReadOptions} */ ({ ...options, bloomFiltersByGroup, schemaElements })
}
/**
* This is a helper function to read parquet row data as a promise.
* It is a wrapper around the more configurable parquetRead function.
*
* @param {Omit<ParquetReadOptions, 'onComplete'>} options
* @returns {Promise<Record<string, any>[]>} resolves when all requested rows and columns are parsed
*/
export function parquetReadObjects(options) {
return new Promise((onComplete, reject) => {
parquetRead({
...options,
rowFormat: 'object', // force object output
onComplete,
}).catch(reject)
})
}
|