UNPKG

hyparquet

Version:

Parquet file parser for JavaScript

280 lines (267 loc) • 11 kB
/** * @import {AsyncColumn, AsyncRowGroup, ChunkPlan, ColumnDecoder, DecodedArray, GroupPlan, PageLocation, ParquetParsers, ParquetReadOptions, QueryPlan, SchemaTree} from '../src/types.js' */ import { assembleNested } from './assemble.js' import { readColumn } from './column.js' import { DEFAULT_PARSERS } from './convert.js' import { readOffsetIndex } from './indexes.js' import { parquetSchema } from './metadata.js' import { getTreePath } from './schema.js' import { flatten } from './utils.js' /** * Read a row group from a file-like object. * * @param {ParquetReadOptions} options * @param {QueryPlan} plan * @param {GroupPlan} groupPlan * @returns {AsyncRowGroup} resolves to column data */ export function readRowGroup(options, { metadata }, groupPlan) { /** @type {AsyncColumn[]} */ const asyncColumns = [] // build the schema tree once, not per column chunk const schemaTree = parquetSchema(metadata) // read column data for (const chunk of groupPlan.chunks) { const { path_in_schema: pathInSchema } = chunk.columnMetadata const schemaPath = getTreePath(schemaTree, pathInSchema) const columnDecoder = { pathInSchema, element: schemaPath[schemaPath.length - 1].element, schemaPath, ...options, ...chunk.columnMetadata, // merge after options, so a partial parsers object keeps the defaults parsers: { ...DEFAULT_PARSERS, ...options.parsers }, } const { startByte, endByte } = chunk.range if ('pageLocations' in chunk) { // page locations already parsed from the offset index asyncColumns.push({ pathInSchema, data: readSelectedPages(options, groupPlan, chunk, chunk.pageLocations, columnDecoder), }) } else if ('offsetIndex' in chunk) { asyncColumns.push({ pathInSchema, // fetch offset index data: Promise.resolve(options.file.slice(chunk.offsetIndex.startByte, chunk.offsetIndex.endByte)) .then(arrayBuffer => { const pages = readOffsetIndex({ view: new DataView(arrayBuffer), offset: 0 }).page_locations return readSelectedPages(options, groupPlan, chunk, pages, columnDecoder) }), }) } else { // full column chunk asyncColumns.push({ pathInSchema, data: Promise.resolve(options.file.slice(startByte, endByte)) .then(buffer => { const reader = { view: new DataView(buffer), offset: 0 } return readColumn(reader, groupPlan, columnDecoder, options.onPage) }), }) } } return { groupStart: groupPlan.groupStart, groupRows: groupPlan.groupRows, selectStart: groupPlan.selectStart, selectEnd: groupPlan.selectEnd, asyncColumns, } } /** * Read only the pages of a column chunk that overlap the group plan's select * range [selectStart, selectEnd), using page locations from the offset index. * * @param {ParquetReadOptions} options * @param {GroupPlan} groupPlan * @param {ChunkPlan} chunk * @param {PageLocation[]} pages * @param {ColumnDecoder} columnDecoder * @returns {Promise<{data: DecodedArray[], skipped: number}>} */ async function readSelectedPages(options, groupPlan, chunk, pages, columnDecoder) { const { data_page_offset, dictionary_page_offset } = chunk.columnMetadata const { selectStart, selectEnd } = groupPlan let { startByte, endByte } = chunk.range let skipped = -1 // include dictionary if present, handle polars missing dictionary_page_offset const hasDict = dictionary_page_offset || data_page_offset < pages[0].offset for (let i = 0; i < pages.length; i++) { const page = pages[i] const pageStart = Number(page.first_row_index) const pageEnd = i + 1 < pages.length ? Number(pages[i + 1].first_row_index) : groupPlan.groupRows // last page extends to end of row group // check if page overlaps with [selectStart, selectEnd) if (skipped < 0 && pageEnd > selectStart) { startByte = Number(page.offset) skipped = pageStart } if (pageStart < selectEnd) { endByte = Number(page.offset) + page.compressed_page_size } } if (skipped < 0) skipped = 0 /** @type {DataView} */ let view if (hasDict && skipped) { // fetch the dictionary page separately from the selected data pages so // the skipped leading pages are not transferred const dictLength = Number(pages[0].offset) - chunk.range.startByte const [dictBuffer, dataBuffer] = await Promise.all([ options.file.slice(chunk.range.startByte, Number(pages[0].offset)), options.file.slice(startByte, endByte), ]) // clamp in case the AsyncBuffer returned more bytes than requested const combined = new Uint8Array(dictLength + dataBuffer.byteLength) combined.set(new Uint8Array(dictBuffer, 0, dictLength)) combined.set(new Uint8Array(dataBuffer), dictLength) view = new DataView(combined.buffer) } else if (hasDict) { // dictionary page is contiguous with the first selected page view = new DataView(await options.file.slice(chunk.range.startByte, endByte)) } else { view = new DataView(await options.file.slice(startByte, endByte)) } const reader = { view, offset: 0 } // adjust row selection for skipped pages const adjustedGroupPlan = skipped ? { ...groupPlan, groupStart: groupPlan.groupStart + skipped, selectStart: groupPlan.selectStart - skipped, selectEnd: groupPlan.selectEnd - skipped, } : groupPlan const { data, skipped: columnSkipped } = readColumn(reader, adjustedGroupPlan, columnDecoder, options.onPage) return { data, skipped: skipped + columnSkipped, } } /** * @overload * @param {AsyncRowGroup} asyncGroup * @param {number} selectStart * @param {number} selectEnd * @param {string[] | undefined} columns * @param {'object'} rowFormat * @returns {Promise<Record<string, any>[]>} resolves to row data */ /** * @overload * @param {AsyncRowGroup} asyncGroup * @param {number} selectStart * @param {number} selectEnd * @param {string[] | undefined} columns * @param {'array'} [rowFormat] * @returns {Promise<any[][]>} resolves to row data */ /** * @param {AsyncRowGroup} asyncGroup * @param {number} selectStart * @param {number} selectEnd * @param {string[] | undefined} columns * @param {'object' | 'array'} [rowFormat] * @returns {Promise<Record<string, any>[] | any[][]>} resolves to row data */ export async function asyncGroupToRows({ asyncColumns }, selectStart, selectEnd, columns, rowFormat) { // TODO: do it without flatten const asyncPages = await Promise.all(asyncColumns.map(column => column.data.then(({ skipped, data }) => ({ skipped, data: flatten(data) })) )) // transpose columns into rows const selectCount = selectEnd - selectStart if (rowFormat === 'object') { /** @type {Record<string, any>[]} */ const groupData = Array(selectCount) for (let selectRow = 0; selectRow < selectCount; selectRow++) { // return each row as an object /** @type {Record<string, any>} */ const rowData = {} for (let i = 0; i < asyncColumns.length; i++) { const { data, skipped } = asyncPages[i] rowData[asyncColumns[i].pathInSchema[0]] = data[selectStart + selectRow - skipped] } groupData[selectRow] = rowData } return groupData } // careful mapping of column order for rowFormat: array const includedColumnNames = asyncColumns .map(child => child.pathInSchema[0]) .filter(name => !columns || columns.includes(name)) const columnOrder = columns ?? includedColumnNames const columnIndexes = columnOrder.map(name => asyncColumns.findIndex(column => column.pathInSchema[0] === name)) /** @type {any[][]} */ const groupData = Array(selectCount) for (let selectRow = 0; selectRow < selectCount; selectRow++) { // return each row as an array const rowData = Array(asyncColumns.length) for (let i = 0; i < columnOrder.length; i++) { const colIdx = columnIndexes[i] if (colIdx < 0) throw new Error(`parquet column not found: ${columnOrder[i]}`) const { data, skipped } = asyncPages[colIdx] rowData[i] = data[selectStart + selectRow - skipped] } groupData[selectRow] = rowData } return groupData } /** * Assemble physical columns into top-level columns asynchronously. * * @param {AsyncRowGroup} asyncRowGroup * @param {SchemaTree} schemaTree * @param {Partial<ParquetParsers>} [parsers] * @returns {AsyncRowGroup} */ export function assembleAsync(asyncRowGroup, schemaTree, parsers) { const { asyncColumns } = asyncRowGroup const allParsers = { ...DEFAULT_PARSERS, ...parsers } /** @type {AsyncColumn[]} */ const assembled = [] for (const child of schemaTree.children) { if (child.children.length) { const childColumns = asyncColumns.filter(column => column.pathInSchema[0] === child.element.name) if (!childColumns.length) continue assembled.push({ pathInSchema: child.path, data: (async () => { // collect subcolumn data — Promise.all observes every rejection so // a sibling failure cannot leak as an unhandledRejection const resolved = await Promise.all(childColumns.map(c => c.data)) /** @type {Map<string, DecodedArray>} */ const subcolumnData = new Map() const flattened = resolved.map(({ data }) => flatten(data)) // Physical pages can cover far more rows than the requested range. // Clip before nested/VARIANT assembly, which expands compact binary // values into independent object graphs and strings for every row. const skipped = Math.max(asyncRowGroup.selectStart ?? 0, ...resolved.map(result => result.skipped)) const end = Math.min(asyncRowGroup.selectEnd ?? Infinity, ...resolved.map((result, i) => result.skipped + flattened[i].length)) for (let i = 0; i < childColumns.length; i++) { // Offset-index reads may start each physical child at a different // page boundary. Align them to their common absolute row range. const start = skipped - resolved[i].skipped const length = Math.max(0, end - skipped) subcolumnData.set( childColumns[i].pathInSchema.join('.'), flattened[i].slice(start, start + length) ) } // assemble the column assembleNested(subcolumnData, child, allParsers) const assembled = subcolumnData.get(child.element.name) if (!assembled) throw new Error('parquet column data not assembled') return { data: [assembled], skipped } })(), }) } else { // leaf node, return the column const asyncColumn = asyncColumns.find(column => column.pathInSchema[0] === child.element.name) if (asyncColumn) assembled.push(asyncColumn) } } return { ...asyncRowGroup, asyncColumns: assembled } }