vastgrid-js 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +211 -2
- package/dist/aggregate.d.ts +45 -0
- package/dist/aggregate.d.ts.map +1 -0
- package/dist/aggregate.js +305 -0
- package/dist/aggregate.js.map +1 -0
- package/dist/arrow.d.ts +7 -0
- package/dist/arrow.d.ts.map +1 -0
- package/dist/arrow.js +25 -0
- package/dist/arrow.js.map +1 -0
- package/dist/engine.d.ts +40 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +1022 -0
- package/dist/engine.js.map +1 -0
- package/dist/evaluate.d.ts +37 -0
- package/dist/evaluate.d.ts.map +1 -0
- package/dist/evaluate.js +774 -0
- package/dist/evaluate.js.map +1 -0
- package/dist/export.d.ts +23 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +48 -0
- package/dist/export.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/lazy.d.ts +65 -0
- package/dist/lazy.d.ts.map +1 -0
- package/dist/lazy.js +177 -0
- package/dist/lazy.js.map +1 -0
- package/dist/numbers.d.ts +20 -0
- package/dist/numbers.d.ts.map +1 -0
- package/dist/numbers.js +129 -0
- package/dist/numbers.js.map +1 -0
- package/dist/order.d.ts +67 -0
- package/dist/order.d.ts.map +1 -0
- package/dist/order.js +561 -0
- package/dist/order.js.map +1 -0
- package/dist/parquet.d.ts +38 -0
- package/dist/parquet.d.ts.map +1 -0
- package/dist/parquet.js +673 -0
- package/dist/parquet.js.map +1 -0
- package/dist/plan.d.ts +37 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +51 -0
- package/dist/plan.js.map +1 -0
- package/dist/summary.d.ts +37 -0
- package/dist/summary.d.ts.map +1 -0
- package/dist/summary.js +175 -0
- package/dist/summary.js.map +1 -0
- package/dist/table.d.ts +172 -0
- package/dist/table.d.ts.map +1 -0
- package/dist/table.js +154 -0
- package/dist/table.js.map +1 -0
- package/dist/text.d.ts +18 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +1204 -0
- package/dist/text.js.map +1 -0
- package/dist/workbook.d.ts +6 -0
- package/dist/workbook.d.ts.map +1 -0
- package/dist/workbook.js +35 -0
- package/dist/workbook.js.map +1 -0
- package/package.json +46 -4
- package/src/aggregate.ts +358 -0
- package/src/arrow.ts +24 -0
- package/src/engine.ts +1163 -0
- package/src/evaluate.ts +764 -0
- package/src/export.ts +73 -0
- package/src/index.ts +2 -0
- package/src/lazy.ts +185 -0
- package/src/numbers.ts +128 -0
- package/src/order.ts +594 -0
- package/src/parquet.ts +801 -0
- package/src/plan.ts +99 -0
- package/src/summary.ts +180 -0
- package/src/table.ts +297 -0
- package/src/text.ts +1285 -0
- package/src/workbook.ts +48 -0
package/dist/parquet.js
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tables given as Parquet files, read by a module of their own that the engine loads with the first such table,
|
|
3
|
+
* so that pages whose tables are rows do not load it. A file is read with hyparquet in byte ranges: of a `Blob`
|
|
4
|
+
* by its slices, and of a URL by HTTP range requests. Opening it reads its footer only; the rows that a request
|
|
5
|
+
* reads are read by their row groups, and the columns that a view sorts, filters or groups by are read whole, and
|
|
6
|
+
* only they, into typed arrays as a text file's are. Columns have the types that DuckDB reads them as.
|
|
7
|
+
*/
|
|
8
|
+
import { parquetMetadataAsync, parquetRead, parquetSchema, } from "hyparquet";
|
|
9
|
+
import { compressors } from "hyparquet-compressors";
|
|
10
|
+
import { dateText, daysFromCivil, decimalJson, floatJson, integerJson, intervalText, timestampText, timeText, toBase64, UNIT_PER_SECOND, } from "vastgrid-spec";
|
|
11
|
+
import { FileColumns } from "./lazy.js";
|
|
12
|
+
import { TableProblem, vectorOf } from "./table.js";
|
|
13
|
+
/** The memory that the columns read whole may take by default (`ParquetTable.maxBytes`). */
|
|
14
|
+
const MAX_BYTES = 2 ** 30;
|
|
15
|
+
/** The most bytes of a file that is downloaded whole when its server does not answer range requests. */
|
|
16
|
+
const MAX_DOWNLOAD = 16 * 1024 * 1024;
|
|
17
|
+
/** Bytes of answered ranges kept for reading again. */
|
|
18
|
+
const CACHE_BYTES = 32 * 1024 * 1024;
|
|
19
|
+
/** Cells of row groups decoded for reading rows, kept for reading again. */
|
|
20
|
+
const CACHE_CELLS = 4_000_000;
|
|
21
|
+
const FIRST_DAY = daysFromCivil(1, 1, 1);
|
|
22
|
+
const LAST_DAY = daysFromCivil(9999, 12, 31);
|
|
23
|
+
const utf8 = new TextDecoder("utf-8", { fatal: true });
|
|
24
|
+
function problem(message, code = "load_failed") {
|
|
25
|
+
return new TableProblem("/parquet", message, code);
|
|
26
|
+
}
|
|
27
|
+
function errorText(error) {
|
|
28
|
+
return error instanceof Error ? error.message : String(error);
|
|
29
|
+
}
|
|
30
|
+
/** A `Blob` or a `File`, read by its slices. */
|
|
31
|
+
function blobBytes(blob) {
|
|
32
|
+
const transfer = { bytes: 0, requests: 0 };
|
|
33
|
+
return {
|
|
34
|
+
size: blob.size,
|
|
35
|
+
transfer,
|
|
36
|
+
async read(start, end) {
|
|
37
|
+
const buffer = await blob.slice(start, end).arrayBuffer();
|
|
38
|
+
transfer.bytes += buffer.byteLength;
|
|
39
|
+
transfer.requests++;
|
|
40
|
+
if (buffer.byteLength !== end - start)
|
|
41
|
+
throw problem("The file changed while it was read.", "file_changed");
|
|
42
|
+
return new Uint8Array(buffer);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** The `ETag`, or else the `Last-Modified`, of an answer; a server on another origin must expose them (CORS). */
|
|
47
|
+
function validatorOf(response) {
|
|
48
|
+
return response.headers.get("etag") ?? response.headers.get("last-modified");
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A file on a web server, read by HTTP range requests (`Range: bytes=…`). Its size comes from a `HEAD` request, or
|
|
52
|
+
* from the `Content-Range` of a range of one byte; every answer is checked against the first `ETag` (or
|
|
53
|
+
* `Last-Modified`), so that a file replaced while it is open fails with `file_changed` rather than mixing two of
|
|
54
|
+
* them. A server that ignores ranges serves files of up to 16 MiB, downloaded once.
|
|
55
|
+
*/
|
|
56
|
+
async function httpBytes(url, table) {
|
|
57
|
+
const send = table.fetch ?? fetch;
|
|
58
|
+
const headers = table.headers ?? {};
|
|
59
|
+
const transfer = { bytes: 0, requests: 0 };
|
|
60
|
+
const cache = new Map();
|
|
61
|
+
let cached = 0;
|
|
62
|
+
let whole = null;
|
|
63
|
+
const request = async (init) => {
|
|
64
|
+
transfer.requests++;
|
|
65
|
+
try {
|
|
66
|
+
return await send(url, init);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
throw problem(`${url} could not be read (${errorText(error)}). A file on another origin is served with CORS: ` +
|
|
70
|
+
"Access-Control-Allow-Origin, and Access-Control-Expose-Headers with Content-Length, Content-Range and ETag.");
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const failed = (response) => problem(`${url} answered ${response.status}.`);
|
|
74
|
+
const download = async (response) => {
|
|
75
|
+
const length = Number(response.headers.get("content-length") ?? Number.NaN);
|
|
76
|
+
if (length > MAX_DOWNLOAD) {
|
|
77
|
+
throw problem(`${url} is served without range requests (HTTP Range) and is larger than 16 MiB.`);
|
|
78
|
+
}
|
|
79
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
80
|
+
transfer.bytes += bytes.length;
|
|
81
|
+
if (bytes.length > MAX_DOWNLOAD) {
|
|
82
|
+
throw problem(`${url} is served without range requests (HTTP Range) and is larger than 16 MiB.`);
|
|
83
|
+
}
|
|
84
|
+
return bytes;
|
|
85
|
+
};
|
|
86
|
+
let size = null;
|
|
87
|
+
let revision = null;
|
|
88
|
+
const head = await request({ method: "HEAD", headers });
|
|
89
|
+
// The Content-Length of a compressed answer is not the size of the file.
|
|
90
|
+
if (head.ok && !head.headers.get("content-encoding") && /^\d+$/.test(head.headers.get("content-length") ?? "")) {
|
|
91
|
+
size = Number(head.headers.get("content-length"));
|
|
92
|
+
revision = validatorOf(head);
|
|
93
|
+
}
|
|
94
|
+
else if (head.status === 401 || head.status === 403 || head.status === 404) {
|
|
95
|
+
throw failed(head);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
// Some servers refuse HEAD; a range of one byte tells the size too.
|
|
99
|
+
const first = await request({ headers: { ...headers, Range: "bytes=0-0" } });
|
|
100
|
+
if (first.status === 206) {
|
|
101
|
+
const total = /\/(\d+)\s*$/.exec(first.headers.get("content-range") ?? "");
|
|
102
|
+
if (!total) {
|
|
103
|
+
throw problem(`The size of ${url} is unknown: its server sent no Content-Range with the size (CORS must expose it).`);
|
|
104
|
+
}
|
|
105
|
+
transfer.bytes += (await first.arrayBuffer()).byteLength;
|
|
106
|
+
size = Number(total[1]);
|
|
107
|
+
revision = validatorOf(first);
|
|
108
|
+
}
|
|
109
|
+
else if (first.ok) {
|
|
110
|
+
whole = await download(first);
|
|
111
|
+
size = whole.length;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
throw failed(first);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const length = size;
|
|
118
|
+
return {
|
|
119
|
+
size: length,
|
|
120
|
+
transfer,
|
|
121
|
+
async read(start, end) {
|
|
122
|
+
if (whole)
|
|
123
|
+
return whole.subarray(start, end);
|
|
124
|
+
const key = `${start}-${end}`;
|
|
125
|
+
const hit = cache.get(key);
|
|
126
|
+
if (hit) {
|
|
127
|
+
// The most recently used last.
|
|
128
|
+
cache.delete(key);
|
|
129
|
+
cache.set(key, hit);
|
|
130
|
+
return hit;
|
|
131
|
+
}
|
|
132
|
+
const response = await request({ headers: { ...headers, Range: `bytes=${start}-${end - 1}` } });
|
|
133
|
+
if (response.status === 416)
|
|
134
|
+
throw problem(`${url} changed while it was open.`, "file_changed");
|
|
135
|
+
if (!response.ok)
|
|
136
|
+
throw failed(response);
|
|
137
|
+
const validator = validatorOf(response);
|
|
138
|
+
if (revision !== null && validator !== null && validator !== revision) {
|
|
139
|
+
throw problem(`${url} changed while it was open.`, "file_changed");
|
|
140
|
+
}
|
|
141
|
+
if (response.status !== 206) {
|
|
142
|
+
// The server stopped answering ranges, or never did and answered HEAD with the size.
|
|
143
|
+
whole = await download(response);
|
|
144
|
+
if (whole.length !== length)
|
|
145
|
+
throw problem(`${url} changed while it was open.`, "file_changed");
|
|
146
|
+
cache.clear();
|
|
147
|
+
return whole.subarray(start, end);
|
|
148
|
+
}
|
|
149
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
150
|
+
transfer.bytes += bytes.length;
|
|
151
|
+
if (bytes.length !== end - start)
|
|
152
|
+
throw problem(`${url} changed while it was open.`, "file_changed");
|
|
153
|
+
if (bytes.length <= CACHE_BYTES) {
|
|
154
|
+
cache.set(key, bytes);
|
|
155
|
+
cached += bytes.length;
|
|
156
|
+
for (const [old, value] of cache) {
|
|
157
|
+
if (cached <= CACHE_BYTES)
|
|
158
|
+
break;
|
|
159
|
+
cache.delete(old);
|
|
160
|
+
cached -= value.length;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return bytes;
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** The bytes as an `ArrayBuffer`, without a copy when they are all of one. */
|
|
168
|
+
function bufferOf(bytes) {
|
|
169
|
+
const { buffer } = bytes;
|
|
170
|
+
if (bytes.byteOffset === 0 && bytes.byteLength === buffer.byteLength && buffer instanceof ArrayBuffer)
|
|
171
|
+
return buffer;
|
|
172
|
+
return bytes.slice().buffer;
|
|
173
|
+
}
|
|
174
|
+
/** Signed big-endian two's complement bytes as an integer. */
|
|
175
|
+
function bigIntOf(bytes) {
|
|
176
|
+
let value = 0n;
|
|
177
|
+
for (const byte of bytes)
|
|
178
|
+
value = (value << 8n) | BigInt(byte);
|
|
179
|
+
return bytes.length > 0 && bytes[0] & 0x80 ? value - (1n << BigInt(bytes.length * 8)) : value;
|
|
180
|
+
}
|
|
181
|
+
function hex(bytes, start, end) {
|
|
182
|
+
let text = "";
|
|
183
|
+
for (let index = start; index < end; index++)
|
|
184
|
+
text += bytes[index].toString(16).padStart(2, "0");
|
|
185
|
+
return text;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Values are given to the columns as hyparquet reads them, without its conversions: dates as days, timestamps as
|
|
189
|
+
* counts of their unit, and strings checked as UTF-8 (§2.2 does not allow replacing text that is not).
|
|
190
|
+
*/
|
|
191
|
+
const parsers = {
|
|
192
|
+
timestampFromMilliseconds: (count) => count,
|
|
193
|
+
timestampFromMicroseconds: (count) => count,
|
|
194
|
+
timestampFromNanoseconds: (count) => count,
|
|
195
|
+
dateFromDays: (days) => days,
|
|
196
|
+
stringFromBytes: (bytes) => bytes && utf8.decode(bytes),
|
|
197
|
+
jsonFromBytes: (bytes) => bytes && utf8.decode(bytes),
|
|
198
|
+
uuidFromBytes: (bytes) => bytes && `${hex(bytes, 0, 4)}-${hex(bytes, 4, 6)}-${hex(bytes, 6, 8)}-${hex(bytes, 8, 10)}-${hex(bytes, 10, 16)}`,
|
|
199
|
+
geometryFromBytes: (bytes) => bytes,
|
|
200
|
+
geographyFromBytes: (bytes) => bytes,
|
|
201
|
+
};
|
|
202
|
+
/** Nested values (lists, structs and maps) as DuckDB casts them to text: `[1, 2]`, `{'a': 1}`, `{k=1}`. */
|
|
203
|
+
function nestedText(value) {
|
|
204
|
+
if (value === null || value === undefined)
|
|
205
|
+
return "NULL";
|
|
206
|
+
if (typeof value === "string")
|
|
207
|
+
return value;
|
|
208
|
+
if (value instanceof Uint8Array)
|
|
209
|
+
return toBase64(value);
|
|
210
|
+
if (Array.isArray(value))
|
|
211
|
+
return `[${value.map(nestedText).join(", ")}]`;
|
|
212
|
+
if (value instanceof Map)
|
|
213
|
+
return `{${[...value].map(([key, item]) => `${nestedText(key)}=${nestedText(item)}`).join(", ")}}`;
|
|
214
|
+
if (typeof value === "object") {
|
|
215
|
+
const entries = Object.entries(value);
|
|
216
|
+
return `{${entries.map(([key, item]) => `'${key}': ${nestedText(item)}`).join(", ")}}`;
|
|
217
|
+
}
|
|
218
|
+
return String(value);
|
|
219
|
+
}
|
|
220
|
+
function typed(column, array, number, json) {
|
|
221
|
+
return {
|
|
222
|
+
column,
|
|
223
|
+
array,
|
|
224
|
+
number,
|
|
225
|
+
json,
|
|
226
|
+
read: (value) => {
|
|
227
|
+
const held = number(value);
|
|
228
|
+
return held === null ? null : json(held);
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function untyped(column, read) {
|
|
233
|
+
return {
|
|
234
|
+
column,
|
|
235
|
+
array: null,
|
|
236
|
+
number: () => null,
|
|
237
|
+
json: () => null,
|
|
238
|
+
read: (value) => (value === null || value === undefined ? null : read(value)),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/** Whether a count of `perDay` per day falls in the years 0001 to 9999, which DuckDB's engine reads (§2.2). */
|
|
242
|
+
function inYears(count, perDay) {
|
|
243
|
+
let day = count / perDay;
|
|
244
|
+
if (count % perDay < 0n)
|
|
245
|
+
day--;
|
|
246
|
+
return day >= BigInt(FIRST_DAY) && day <= BigInt(LAST_DAY);
|
|
247
|
+
}
|
|
248
|
+
function isNull(value) {
|
|
249
|
+
return value === null || value === undefined;
|
|
250
|
+
}
|
|
251
|
+
/** DuckDB's name of a nested type, as `sourceType`: `INTEGER[]`, `STRUCT(a INTEGER)`, `MAP(VARCHAR, INTEGER)`. */
|
|
252
|
+
function nestedName(node) {
|
|
253
|
+
const { element, children } = node;
|
|
254
|
+
const logical = element.logical_type?.type;
|
|
255
|
+
const converted = element.converted_type;
|
|
256
|
+
if (converted === "LIST" || logical === "LIST") {
|
|
257
|
+
const repeated = children[0];
|
|
258
|
+
const item = repeated && repeated.children.length === 1 && repeated.children[0] ? repeated.children[0] : repeated;
|
|
259
|
+
return `${item ? nestedName(item) : "NULL"}[]`;
|
|
260
|
+
}
|
|
261
|
+
if (converted === "MAP" || converted === "MAP_KEY_VALUE" || logical === "MAP") {
|
|
262
|
+
const [key, value] = children[0]?.children ?? [];
|
|
263
|
+
return `MAP(${key ? nestedName(key) : "NULL"}, ${value ? nestedName(value) : "NULL"})`;
|
|
264
|
+
}
|
|
265
|
+
if (children.length > 0 || element.type === undefined) {
|
|
266
|
+
const name = (child) => /^[a-z_][a-z0-9_]*$/.test(child.element.name) ? child.element.name : JSON.stringify(child.element.name);
|
|
267
|
+
return `STRUCT(${children.map((child) => `${name(child)} ${nestedName(child)}`).join(", ")})`;
|
|
268
|
+
}
|
|
269
|
+
return kindOf({ ...node, element: { ...node.element } }).column.sourceType ?? "BLOB";
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* The kind of a top-level column of the file, with the type that DuckDB reads it as. Decimals, intervals and BSON
|
|
273
|
+
* lose their annotations, so that hyparquet gives their stored values (it would make decimals floats and refuse
|
|
274
|
+
* intervals).
|
|
275
|
+
*/
|
|
276
|
+
function kindOf(node) {
|
|
277
|
+
const { element, children } = node;
|
|
278
|
+
const name = element.name;
|
|
279
|
+
const logical = element.logical_type;
|
|
280
|
+
const converted = element.converted_type;
|
|
281
|
+
if (children.length > 0 || element.type === undefined) {
|
|
282
|
+
return untyped({ name, type: "string", sourceType: nestedName(node), readOnly: true }, nestedText);
|
|
283
|
+
}
|
|
284
|
+
if (converted === "DECIMAL" || logical?.type === "DECIMAL") {
|
|
285
|
+
const scale = element.scale ?? (logical?.type === "DECIMAL" ? logical.scale : 0) ?? 0;
|
|
286
|
+
const precision = element.precision ?? (logical?.type === "DECIMAL" ? logical.precision : 18) ?? 18;
|
|
287
|
+
element.converted_type = undefined;
|
|
288
|
+
element.logical_type = undefined;
|
|
289
|
+
const column = { name, type: "decimal", precision, scale, sourceType: `DECIMAL(${precision},${scale})` };
|
|
290
|
+
const unscaled = (value) => value instanceof Uint8Array ? bigIntOf(value) : BigInt(value);
|
|
291
|
+
if (precision <= 18) {
|
|
292
|
+
return typed(column, "int64", (value) => (isNull(value) ? null : unscaled(value)), (value) => decimalJson(value, scale));
|
|
293
|
+
}
|
|
294
|
+
return untyped(column, (value) => decimalJson(unscaled(value), scale));
|
|
295
|
+
}
|
|
296
|
+
if (converted === "INTERVAL" || logical?.type === "INTERVAL") {
|
|
297
|
+
element.converted_type = undefined;
|
|
298
|
+
element.logical_type = undefined;
|
|
299
|
+
return untyped({ name, type: "interval", sourceType: "INTERVAL" }, (value) => {
|
|
300
|
+
const bytes = value;
|
|
301
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
302
|
+
return intervalText({
|
|
303
|
+
months: view.getInt32(0, true),
|
|
304
|
+
days: view.getInt32(4, true),
|
|
305
|
+
nanos: BigInt(view.getUint32(8, true)) * 1000000n,
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
if (converted === "BSON" || logical?.type === "BSON") {
|
|
310
|
+
element.converted_type = undefined;
|
|
311
|
+
element.logical_type = undefined;
|
|
312
|
+
return untyped({ name, type: "binary", sourceType: "BLOB" }, (value) => toBase64(value));
|
|
313
|
+
}
|
|
314
|
+
if (converted === "DATE" || logical?.type === "DATE") {
|
|
315
|
+
return typed({ name, type: "date", sourceType: "DATE" }, "int32", (value) => isNull(value) || value < FIRST_DAY || value > LAST_DAY ? null : value, (value) => dateText(value));
|
|
316
|
+
}
|
|
317
|
+
if (converted === "TIME_MILLIS" || converted === "TIME_MICROS" || logical?.type === "TIME") {
|
|
318
|
+
const unit = logical?.type === "TIME" ? logical.unit : converted === "TIME_MILLIS" ? "MILLIS" : "MICROS";
|
|
319
|
+
return typed({ name, type: "time", sourceType: "TIME" }, "int64", (value) => {
|
|
320
|
+
if (isNull(value))
|
|
321
|
+
return null;
|
|
322
|
+
const count = BigInt(value);
|
|
323
|
+
const micros = unit === "MILLIS" ? count * 1000n : unit === "NANOS" ? count / 1000n : count;
|
|
324
|
+
return micros >= 0n && micros < 86400000000n ? micros : null;
|
|
325
|
+
}, (value) => timeText(value));
|
|
326
|
+
}
|
|
327
|
+
const timestamp = logical?.type === "TIMESTAMP"
|
|
328
|
+
? logical
|
|
329
|
+
: converted === "TIMESTAMP_MILLIS" || converted === "TIMESTAMP_MICROS"
|
|
330
|
+
? { isAdjustedToUTC: false, unit: converted === "TIMESTAMP_MILLIS" ? "MILLIS" : "MICROS" }
|
|
331
|
+
: element.type === "INT96"
|
|
332
|
+
? { isAdjustedToUTC: false, unit: "INT96" }
|
|
333
|
+
: null;
|
|
334
|
+
if (timestamp) {
|
|
335
|
+
// DuckDB reads milliseconds, and the nanoseconds of INT96, as microseconds; nanoseconds as TIMESTAMP_NS.
|
|
336
|
+
const nanos = timestamp.unit === "NANOS" && !timestamp.isAdjustedToUTC;
|
|
337
|
+
const unit = nanos ? "ns" : "us";
|
|
338
|
+
const perDay = 86400n * UNIT_PER_SECOND[unit];
|
|
339
|
+
const scaled = timestamp.unit === "MILLIS"
|
|
340
|
+
? (count) => count * 1000n
|
|
341
|
+
: timestamp.unit === "INT96" || (timestamp.unit === "NANOS" && !nanos)
|
|
342
|
+
? (count) => (count < 0n && count % 1000n !== 0n ? count / 1000n - 1n : count / 1000n)
|
|
343
|
+
: (count) => count;
|
|
344
|
+
const column = timestamp.isAdjustedToUTC
|
|
345
|
+
? { name, type: "timestamptz", sourceType: "TIMESTAMP WITH TIME ZONE" }
|
|
346
|
+
: nanos
|
|
347
|
+
? { name, type: "timestamp", unit: "ns", sourceType: "TIMESTAMP_NS" }
|
|
348
|
+
: { name, type: "timestamp", sourceType: "TIMESTAMP" };
|
|
349
|
+
return typed(column, "int64", (value) => {
|
|
350
|
+
if (isNull(value))
|
|
351
|
+
return null;
|
|
352
|
+
const count = scaled(BigInt(value));
|
|
353
|
+
return inYears(count, perDay) ? count : null;
|
|
354
|
+
}, timestamp.isAdjustedToUTC
|
|
355
|
+
? (value) => `${timestampText(value)}+00`
|
|
356
|
+
: (value) => timestampText(value, unit));
|
|
357
|
+
}
|
|
358
|
+
if (converted === "JSON" || logical?.type === "JSON") {
|
|
359
|
+
return untyped({ name, type: "string", sourceType: "JSON", readOnly: true }, (value) => value);
|
|
360
|
+
}
|
|
361
|
+
if (logical?.type === "UUID")
|
|
362
|
+
return untyped({ name, type: "uuid", sourceType: "UUID" }, (value) => value);
|
|
363
|
+
if (converted === "UTF8" || converted === "ENUM" || logical?.type === "STRING" || logical?.type === "ENUM") {
|
|
364
|
+
return untyped({ name, type: "string", sourceType: "VARCHAR" }, (value) => value instanceof Uint8Array ? utf8.decode(value) : value);
|
|
365
|
+
}
|
|
366
|
+
const integer = logical?.type === "INTEGER" ? logical : null;
|
|
367
|
+
const unsigned = converted?.startsWith("UINT_") || (integer !== null && !integer.isSigned);
|
|
368
|
+
const bits = integer?.bitWidth ?? (converted ? Number(/\d+$/.exec(converted)?.[0] ?? 0) : 0);
|
|
369
|
+
switch (element.type) {
|
|
370
|
+
case "BOOLEAN":
|
|
371
|
+
return untyped({ name, type: "boolean", sourceType: "BOOLEAN" }, (value) => value);
|
|
372
|
+
case "INT32": {
|
|
373
|
+
if (unsigned && bits === 32) {
|
|
374
|
+
return typed({ name, type: "bigint", sourceType: "UINTEGER" }, "int64", (value) => (isNull(value) ? null : BigInt(value)), (value) => integerJson(value));
|
|
375
|
+
}
|
|
376
|
+
const sourceType = bits === 8
|
|
377
|
+
? unsigned
|
|
378
|
+
? "UTINYINT"
|
|
379
|
+
: "TINYINT"
|
|
380
|
+
: bits === 16
|
|
381
|
+
? unsigned
|
|
382
|
+
? "USMALLINT"
|
|
383
|
+
: "SMALLINT"
|
|
384
|
+
: "INTEGER";
|
|
385
|
+
return typed({ name, type: "integer", sourceType }, "int32", (value) => (isNull(value) ? null : value), (value) => value);
|
|
386
|
+
}
|
|
387
|
+
case "INT64":
|
|
388
|
+
if (unsigned) {
|
|
389
|
+
return untyped({ name, type: "decimal", precision: 38, scale: 0, sourceType: "UBIGINT" }, (value) => BigInt(value).toString());
|
|
390
|
+
}
|
|
391
|
+
return typed({ name, type: "bigint", sourceType: "BIGINT" }, "int64", (value) => (isNull(value) ? null : BigInt(value)), (value) => integerJson(value));
|
|
392
|
+
case "FLOAT":
|
|
393
|
+
return typed({ name, type: "float", bits: 32, sourceType: "FLOAT" }, "float32", (value) => (isNull(value) ? null : value), (value) => floatJson(value));
|
|
394
|
+
case "DOUBLE":
|
|
395
|
+
return typed({ name, type: "float", sourceType: "DOUBLE" }, "float64", (value) => (isNull(value) ? null : value), (value) => floatJson(value));
|
|
396
|
+
default:
|
|
397
|
+
if (logical?.type === "FLOAT16") {
|
|
398
|
+
return typed({ name, type: "float", bits: 32, sourceType: "FLOAT" }, "float32", (value) => (isNull(value) ? null : Math.fround(value)), (value) => floatJson(value));
|
|
399
|
+
}
|
|
400
|
+
return untyped({ name, type: "binary", sourceType: "BLOB" }, (value) => toBase64(value));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function wholeOf(kind, rows) {
|
|
404
|
+
if (kind.column.type === "boolean") {
|
|
405
|
+
// A byte per row: 0 for false, 1 for true and 2 for null.
|
|
406
|
+
const flags = new Uint8Array(rows).fill(2);
|
|
407
|
+
return {
|
|
408
|
+
vector: { length: rows, at: (row) => (flags[row] === 2 ? null : flags[row] === 1) },
|
|
409
|
+
set: (row, value) => {
|
|
410
|
+
const held = kind.read(value);
|
|
411
|
+
if (held !== null)
|
|
412
|
+
flags[row] = held ? 1 : 0;
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
if (kind.array === null) {
|
|
417
|
+
const values = new Array(rows).fill(null);
|
|
418
|
+
return {
|
|
419
|
+
vector: vectorOf(values),
|
|
420
|
+
set: (row, value) => {
|
|
421
|
+
values[row] = kind.read(value);
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
const numbers = kind.array === "int32"
|
|
426
|
+
? new Int32Array(rows)
|
|
427
|
+
: kind.array === "float32"
|
|
428
|
+
? new Float32Array(rows)
|
|
429
|
+
: kind.array === "float64"
|
|
430
|
+
? new Float64Array(rows)
|
|
431
|
+
: new BigInt64Array(rows);
|
|
432
|
+
const validity = new Uint8Array(Math.ceil(rows / 8)).fill(0xff);
|
|
433
|
+
const json = kind.json;
|
|
434
|
+
return {
|
|
435
|
+
vector: {
|
|
436
|
+
length: rows,
|
|
437
|
+
numbers,
|
|
438
|
+
validity,
|
|
439
|
+
at: (row) => (((validity[row >> 3] >> (row & 7)) & 1) === 1 ? json(numbers[row]) : null),
|
|
440
|
+
},
|
|
441
|
+
set: (row, value) => {
|
|
442
|
+
const held = kind.number(value);
|
|
443
|
+
if (held === null)
|
|
444
|
+
validity[row >> 3] &= ~(1 << (row & 7));
|
|
445
|
+
else
|
|
446
|
+
numbers[row] = held;
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
/** The bytes that a column of typed arrays takes when it is read whole. */
|
|
451
|
+
function arrayBytes(array, rows) {
|
|
452
|
+
return (array === "int32" || array === "float32" ? 4 : 8) * rows + rows / 8;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* About the bytes that a chunk of a column of JSON values takes when it is read whole, from its size in the file
|
|
456
|
+
* (`total_uncompressed_size`): a reference per value, and the values as JavaScript holds them, text in strings of
|
|
457
|
+
* twice its UTF-8 at most. The values of a dictionary are its strings, which every row that holds them shares.
|
|
458
|
+
*/
|
|
459
|
+
function chunkBytes(meta) {
|
|
460
|
+
const stored = Number(meta.total_uncompressed_size);
|
|
461
|
+
const values = Number(meta.num_values);
|
|
462
|
+
if (fromDictionary(meta))
|
|
463
|
+
return 8 * values + 4 * stored;
|
|
464
|
+
// A string's header and its text; values of BYTE_ARRAY are stored after 4 bytes of their length.
|
|
465
|
+
const text = meta.type === "BYTE_ARRAY" ? Math.max(0, stored - 4 * values) : stored;
|
|
466
|
+
return 8 * values + 16 * values + 2 * text;
|
|
467
|
+
}
|
|
468
|
+
/** Whether every data page of a chunk holds indexes into its dictionary. */
|
|
469
|
+
function fromDictionary(meta) {
|
|
470
|
+
if (meta.dictionary_page_offset === undefined)
|
|
471
|
+
return false;
|
|
472
|
+
const dictionary = (encoding) => encoding === "PLAIN_DICTIONARY" || encoding === "RLE_DICTIONARY";
|
|
473
|
+
if (meta.encoding_stats) {
|
|
474
|
+
return meta.encoding_stats.every((stat) => !stat.page_type.startsWith("DATA_PAGE") || dictionary(stat.encoding));
|
|
475
|
+
}
|
|
476
|
+
// Without the encodings of each page, PLAIN may be the dictionary's or a data page's.
|
|
477
|
+
return !meta.encodings.includes("PLAIN") && meta.encodings.some(dictionary);
|
|
478
|
+
}
|
|
479
|
+
/** A Parquet file that is open: its columns, and what has been read of them. */
|
|
480
|
+
export class ParquetFile extends FileColumns {
|
|
481
|
+
bytes;
|
|
482
|
+
metadata;
|
|
483
|
+
kinds;
|
|
484
|
+
/** The first row of each row group, and the row count after the last. */
|
|
485
|
+
starts;
|
|
486
|
+
buffer;
|
|
487
|
+
/** Columns of row groups decoded for reading rows, by `group:column`, the most recently used last. */
|
|
488
|
+
cache = new Map();
|
|
489
|
+
cached = 0;
|
|
490
|
+
constructor(bytes, metadata, kinds, maxBytes) {
|
|
491
|
+
super(kinds.map((kind) => kind.column), Number(metadata.num_rows), maxBytes);
|
|
492
|
+
this.bytes = bytes;
|
|
493
|
+
this.metadata = metadata;
|
|
494
|
+
this.kinds = kinds;
|
|
495
|
+
this.starts = [0];
|
|
496
|
+
for (const group of metadata.row_groups)
|
|
497
|
+
this.starts.push(this.starts[this.starts.length - 1] + Number(group.num_rows));
|
|
498
|
+
this.buffer = {
|
|
499
|
+
byteLength: bytes.size,
|
|
500
|
+
slice: async (start, end) => bufferOf(await bytes.read(start, end ?? bytes.size)),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
/** Opens a file: reads its footer, and finds its columns. */
|
|
504
|
+
static async open(table) {
|
|
505
|
+
const { parquet, maxBytes = MAX_BYTES } = table;
|
|
506
|
+
if (typeof maxBytes !== "number" || !(maxBytes > 0)) {
|
|
507
|
+
throw new TableProblem("/maxBytes", "maxBytes is a number of bytes.");
|
|
508
|
+
}
|
|
509
|
+
let bytes;
|
|
510
|
+
if (typeof parquet === "string")
|
|
511
|
+
bytes = await httpBytes(parquet, table);
|
|
512
|
+
else if (parquet !== null && typeof parquet === "object" && typeof parquet.slice === "function") {
|
|
513
|
+
bytes = blobBytes(parquet);
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
throw problem("parquet is a Blob, a File or the URL of a file.");
|
|
517
|
+
}
|
|
518
|
+
let metadata;
|
|
519
|
+
try {
|
|
520
|
+
metadata = await parquetMetadataAsync({ byteLength: bytes.size, slice: async (start, end) => bufferOf(await bytes.read(start, end ?? bytes.size)) }, { parsers, geoparquet: false, initialFetchSize: Math.min(bytes.size, 64 * 1024) });
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
if (error instanceof TableProblem)
|
|
524
|
+
throw error;
|
|
525
|
+
throw problem(`The file is not a Parquet file that can be read: ${errorText(error)}`);
|
|
526
|
+
}
|
|
527
|
+
const kinds = parquetSchema(metadata).children.map(kindOf);
|
|
528
|
+
const names = new Set();
|
|
529
|
+
for (const kind of kinds) {
|
|
530
|
+
if (names.has(kind.column.name))
|
|
531
|
+
throw problem(`"${kind.column.name}" names two columns.`);
|
|
532
|
+
names.add(kind.column.name);
|
|
533
|
+
}
|
|
534
|
+
return new ParquetFile(bytes, metadata, kinds, maxBytes);
|
|
535
|
+
}
|
|
536
|
+
/** Bytes and reads of the file so far. */
|
|
537
|
+
get transfer() {
|
|
538
|
+
return { ...this.bytes.transfer };
|
|
539
|
+
}
|
|
540
|
+
/** Decodes columns of rows `start` to `end` of the file, giving each value to `set`. */
|
|
541
|
+
async decode(start, end, indexes, set) {
|
|
542
|
+
const positions = new Map(indexes.map((index, at) => [this.columns[index].name, at]));
|
|
543
|
+
try {
|
|
544
|
+
await parquetRead({
|
|
545
|
+
file: this.buffer,
|
|
546
|
+
metadata: this.metadata,
|
|
547
|
+
columns: [...positions.keys()],
|
|
548
|
+
rowStart: start,
|
|
549
|
+
rowEnd: end,
|
|
550
|
+
utf8: false,
|
|
551
|
+
parsers,
|
|
552
|
+
compressors,
|
|
553
|
+
geoparquet: false,
|
|
554
|
+
useOffsetIndex: true,
|
|
555
|
+
// Column by column: no rows are made.
|
|
556
|
+
onChunk: (chunk) => {
|
|
557
|
+
const at = positions.get(chunk.columnName);
|
|
558
|
+
if (at === undefined)
|
|
559
|
+
return;
|
|
560
|
+
const data = chunk.columnData;
|
|
561
|
+
for (let row = Math.max(start, chunk.rowStart); row < Math.min(end, chunk.rowEnd); row++) {
|
|
562
|
+
set(at, row, data[row - chunk.rowStart]);
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
catch (error) {
|
|
568
|
+
if (error instanceof TableProblem)
|
|
569
|
+
throw error;
|
|
570
|
+
throw problem(`The file could not be read: ${errorText(error)}`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/** The row group of a row. */
|
|
574
|
+
partOf(row) {
|
|
575
|
+
let low = 0;
|
|
576
|
+
let high = this.starts.length - 2;
|
|
577
|
+
while (low < high) {
|
|
578
|
+
const middle = (low + high + 1) >> 1;
|
|
579
|
+
if (this.starts[middle] <= row)
|
|
580
|
+
low = middle;
|
|
581
|
+
else
|
|
582
|
+
high = middle - 1;
|
|
583
|
+
}
|
|
584
|
+
return low;
|
|
585
|
+
}
|
|
586
|
+
/** A column of a row group as typed JSON, decoded once and kept while it fits the cache. */
|
|
587
|
+
groupColumn(group, index) {
|
|
588
|
+
const key = `${group}:${index}`;
|
|
589
|
+
let entry = this.cache.get(key);
|
|
590
|
+
const size = this.starts[group + 1] - this.starts[group];
|
|
591
|
+
if (entry) {
|
|
592
|
+
this.cache.delete(key);
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
const start = this.starts[group];
|
|
596
|
+
const values = new Array(size).fill(null);
|
|
597
|
+
const kind = this.kinds[index];
|
|
598
|
+
entry = this.decode(start, start + size, [index], (_, row, value) => {
|
|
599
|
+
values[row - start] = kind.read(value);
|
|
600
|
+
}).then(() => values);
|
|
601
|
+
this.cached += size;
|
|
602
|
+
// A failed read is not kept.
|
|
603
|
+
entry.catch(() => {
|
|
604
|
+
if (this.cache.get(key) === entry) {
|
|
605
|
+
this.cache.delete(key);
|
|
606
|
+
this.cached -= size;
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
this.cache.set(key, entry);
|
|
611
|
+
for (const [old] of this.cache) {
|
|
612
|
+
if (this.cached <= CACHE_CELLS || old === key)
|
|
613
|
+
break;
|
|
614
|
+
const oldGroup = Number(old.slice(0, old.indexOf(":")));
|
|
615
|
+
this.cache.delete(old);
|
|
616
|
+
this.cached -= this.starts[oldGroup + 1] - this.starts[oldGroup];
|
|
617
|
+
}
|
|
618
|
+
return entry;
|
|
619
|
+
}
|
|
620
|
+
async readCells(index, rows) {
|
|
621
|
+
const groups = new Map();
|
|
622
|
+
for (let at = 0; at < rows.length; at++) {
|
|
623
|
+
const group = this.partOf(rows[at]);
|
|
624
|
+
if (!groups.has(group))
|
|
625
|
+
groups.set(group, this.groupColumn(group, index));
|
|
626
|
+
}
|
|
627
|
+
const read = new Map();
|
|
628
|
+
for (const [group, values] of groups)
|
|
629
|
+
read.set(group, await values);
|
|
630
|
+
return Array.from(rows, (row) => {
|
|
631
|
+
const group = this.partOf(row);
|
|
632
|
+
return read.get(group)[row - this.starts[group]];
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
/** About the memory that a column read whole takes. */
|
|
636
|
+
sizeOf(index) {
|
|
637
|
+
const kind = this.kinds[index];
|
|
638
|
+
if (kind.column.type === "boolean")
|
|
639
|
+
return this.rowCount;
|
|
640
|
+
if (kind.array !== null)
|
|
641
|
+
return arrayBytes(kind.array, this.rowCount);
|
|
642
|
+
const name = kind.column.name;
|
|
643
|
+
let bytes = 0;
|
|
644
|
+
for (const group of this.metadata.row_groups) {
|
|
645
|
+
for (const chunk of group.columns) {
|
|
646
|
+
if (chunk.meta_data?.path_in_schema[0] === name)
|
|
647
|
+
bytes += chunkBytes(chunk.meta_data);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return bytes;
|
|
651
|
+
}
|
|
652
|
+
async readWhole(indexes, pause) {
|
|
653
|
+
const wholes = indexes.map((index) => wholeOf(this.kinds[index], this.rowCount));
|
|
654
|
+
for (let group = 0; group < this.starts.length - 1; group++) {
|
|
655
|
+
await pause();
|
|
656
|
+
await this.decode(this.starts[group], this.starts[group + 1], indexes, (at, row, value) => wholes[at].set(row, value));
|
|
657
|
+
}
|
|
658
|
+
// Their row groups are read from the columns now.
|
|
659
|
+
for (const index of indexes) {
|
|
660
|
+
for (let group = 0; group < this.starts.length - 1; group++) {
|
|
661
|
+
if (this.cache.delete(`${group}:${index}`))
|
|
662
|
+
this.cached -= this.starts[group + 1] - this.starts[group];
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return wholes.map((whole) => whole.vector);
|
|
666
|
+
}
|
|
667
|
+
/** Forgets what was read. */
|
|
668
|
+
close() {
|
|
669
|
+
this.cache.clear();
|
|
670
|
+
this.cached = 0;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
//# sourceMappingURL=parquet.js.map
|