Skip to content

Commit 8701ee1

Browse files
committed
feat(converters): add parquet to csv conversion with compressor support
1 parent 0965928 commit 8701ee1

5 files changed

Lines changed: 221 additions & 0 deletions

File tree

bun.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020
"@elysiajs/jwt": "^1.4.1",
2121
"@elysiajs/static": "^1.4.7",
2222
"@kitajs/html": "^4.2.13",
23+
"csv-stringify": "^6.7.0",
2324
"elysia": "1.4.22",
25+
"hyparquet": "^1.25.6",
26+
"hyparquet-compressors": "^1.1.1",
2427
"sanitize-filename": "^1.6.4",
2528
"tar": "^7.5.13"
2629
},

src/converters/main.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { convert as convertVtracer, properties as propertiesVtracer } from "./vt
2525
import { convert as convertVcf, properties as propertiesVcf } from "./vcf";
2626
import { convert as convertxelatex, properties as propertiesxelatex } from "./xelatex";
2727
import { convert as convertMarkitdown, properties as propertiesMarkitdown } from "./markitdown";
28+
import { convert as convertParquet, properties as propertiesParquet } from "./parquet";
2829

2930
// This should probably be reconstructed so that the functions are not imported instead the functions hook into this to make the converters more modular
3031

@@ -137,6 +138,10 @@ const properties: Record<
137138
properties: propertiesMarkitdown,
138139
converter: convertMarkitdown,
139140
},
141+
parquet: {
142+
properties: propertiesParquet,
143+
converter: convertParquet,
144+
},
140145
};
141146

142147
function chunks<T>(arr: T[], size: number): T[][] {

src/converters/parquet.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import * as fs from "node:fs/promises";
2+
import { createWriteStream } from "node:fs";
3+
import { stringify } from "csv-stringify";
4+
import { parquetMetadataAsync, parquetRead } from "hyparquet";
5+
import { compressors } from "hyparquet-compressors";
6+
7+
export const properties = {
8+
from: {
9+
data: ["parquet"],
10+
},
11+
to: {
12+
data: ["csv"],
13+
},
14+
};
15+
16+
export async function convert(
17+
filePath: string,
18+
fileType: string,
19+
convertTo: string,
20+
targetPath: string,
21+
options?: unknown, // eslint-disable-line @typescript-eslint/no-unused-vars
22+
): Promise<string> {
23+
const fileHandle = await fs.open(filePath, "r");
24+
try {
25+
const stat = await fileHandle.stat();
26+
const file = {
27+
byteLength: stat.size,
28+
async slice(start: number, end?: number): Promise<ArrayBuffer> {
29+
const length = (end ?? stat.size) - start;
30+
const buf = Buffer.allocUnsafe(length);
31+
const { bytesRead } = await fileHandle.read(buf, 0, length, start);
32+
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + bytesRead);
33+
},
34+
};
35+
36+
const metadata = await parquetMetadataAsync(file, { compressors } as never);
37+
const stringifier = stringify({
38+
header: true,
39+
cast: {
40+
object: (value: unknown) =>
41+
JSON.stringify(value, (_key: string, val: unknown) =>
42+
typeof val === "bigint" ? Number(val) : val,
43+
),
44+
},
45+
});
46+
const writeStream = createWriteStream(targetPath);
47+
48+
return new Promise((resolve, reject) => {
49+
stringifier.pipe(writeStream);
50+
51+
let settled = false;
52+
const settle = (err?: unknown) => {
53+
if (settled) return;
54+
settled = true;
55+
fileHandle.close().catch(() => {});
56+
if (err) reject(err);
57+
else resolve("Done");
58+
};
59+
60+
writeStream.on("finish", settle);
61+
writeStream.on("error", settle);
62+
stringifier.on("error", settle);
63+
64+
const writeRow = async (rawRow: Record<string, unknown>) => {
65+
const row: Record<string, unknown> = {};
66+
for (const [key, value] of Object.entries(rawRow)) {
67+
row[key] = typeof value === "bigint" ? Number(value) : value;
68+
}
69+
if (stringifier.write(row)) return;
70+
await new Promise<void>((resolve) => {
71+
stringifier.once("drain", resolve);
72+
});
73+
};
74+
75+
const writePromises: Promise<void>[] = [];
76+
77+
(async () => {
78+
try {
79+
let rowStart = 0;
80+
for (const rowGroup of metadata.row_groups) {
81+
const numRows = Number(rowGroup.num_rows);
82+
const rowEnd = rowStart + numRows;
83+
84+
await parquetRead({
85+
file,
86+
rowStart,
87+
rowEnd,
88+
rowFormat: "object",
89+
compressors,
90+
onComplete: (rows) => {
91+
if (Array.isArray(rows)) {
92+
writePromises.push(
93+
(async () => {
94+
for (const row of rows) {
95+
await writeRow(row);
96+
}
97+
})(),
98+
);
99+
}
100+
},
101+
});
102+
rowStart = rowEnd;
103+
}
104+
105+
await Promise.all(writePromises);
106+
stringifier.end();
107+
} catch (err) {
108+
settle(err);
109+
writeStream.destroy(err as Error);
110+
stringifier.destroy(err as Error);
111+
}
112+
})();
113+
});
114+
} catch (err) {
115+
await fileHandle.close();
116+
throw err;
117+
}
118+
}

tests/converters/parquet.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { expect, test, describe, mock } from "bun:test";
2+
import { Writable } from "node:stream";
3+
4+
const mockParquetMetadataAsync = mock(async () => {
5+
return {
6+
row_groups: [{ num_rows: 1 }],
7+
};
8+
});
9+
10+
const mockParquetRead = mock(
11+
async (options: {
12+
file: unknown;
13+
rowStart: number;
14+
rowEnd: number;
15+
rowFormat: string;
16+
onComplete: (rows: { col1: string }[]) => void;
17+
}) => {
18+
if (options.onComplete) {
19+
options.onComplete([{ col1: "value" }]);
20+
}
21+
return [];
22+
},
23+
);
24+
25+
const mockCreateWriteStream = mock(() => {
26+
return new Writable({
27+
write(_chunk, _encoding, callback) {
28+
callback();
29+
},
30+
});
31+
});
32+
33+
const mockFileHandle = {
34+
stat: mock(async () => ({ size: 100 })),
35+
read: mock(async (buf: Buffer) => {
36+
return { bytesRead: buf.length, buffer: buf };
37+
}),
38+
close: mock(async () => {}),
39+
};
40+
41+
mock.module("node:fs/promises", () => ({
42+
open: mock(async () => mockFileHandle),
43+
}));
44+
45+
mock.module("hyparquet", () => {
46+
return {
47+
parquetMetadataAsync: mockParquetMetadataAsync,
48+
parquetRead: mockParquetRead,
49+
};
50+
});
51+
52+
mock.module("node:fs", () => {
53+
return {
54+
createWriteStream: mockCreateWriteStream,
55+
};
56+
});
57+
58+
import { convert } from "../../src/converters/parquet";
59+
60+
describe("parquet converter", () => {
61+
test("convert resolves when process succeeds", async () => {
62+
const result = await convert("input.parquet", "parquet", "csv", "output.csv");
63+
expect(result).toBe("Done");
64+
});
65+
66+
test("convert rejects when parquetRead fails", async () => {
67+
mockParquetRead.mockRejectedValueOnce(new Error("Read error"));
68+
69+
await expect(convert("invalid.parquet", "parquet", "csv", "output.csv")).rejects.toThrow(
70+
"Read error",
71+
);
72+
});
73+
74+
test("convert rejects when metadata fails", async () => {
75+
mockParquetMetadataAsync.mockRejectedValueOnce(new Error("Metadata error"));
76+
77+
await expect(convert("invalid.parquet", "parquet", "csv", "output.csv")).rejects.toThrow(
78+
"Metadata error",
79+
);
80+
});
81+
});

0 commit comments

Comments
 (0)