@milaboratories/pf-driver 1.3.11 → 1.4.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/dist/csv_writer.cjs +79 -0
- package/dist/csv_writer.cjs.map +1 -0
- package/dist/csv_writer.js +78 -0
- package/dist/csv_writer.js.map +1 -0
- package/dist/driver_decl.d.ts +4 -2
- package/dist/driver_decl.d.ts.map +1 -1
- package/dist/driver_double.cjs +1 -1
- package/dist/driver_double.js +1 -1
- package/dist/driver_impl.cjs +94 -17
- package/dist/driver_impl.cjs.map +1 -1
- package/dist/driver_impl.d.ts +2 -1
- package/dist/driver_impl.d.ts.map +1 -1
- package/dist/driver_impl.js +93 -18
- package/dist/driver_impl.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/package.json +8 -7
- package/src/__tests__/csv_writer.test.ts +419 -0
- package/src/__tests__/download_ptable.test.ts +617 -0
- package/src/csv_writer.ts +154 -0
- package/src/driver_decl.ts +14 -0
- package/src/driver_impl.ts +100 -3
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
PObjectId,
|
|
4
|
+
ValueType,
|
|
5
|
+
type PTableColumnSpec,
|
|
6
|
+
type PTableVector,
|
|
7
|
+
type TableRange,
|
|
8
|
+
} from "@milaboratories/pl-model-common";
|
|
9
|
+
import { formatHeader, formatRow, streamPTableRows, type PTableDataSource } from "../csv_writer";
|
|
10
|
+
|
|
11
|
+
// ── formatHeader ─────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
describe("formatHeader", () => {
|
|
14
|
+
it("uses annotation label when available", () => {
|
|
15
|
+
const specs = [makeAxisSpec("id", "Row ID"), makeColumnSpec("value", "Score")];
|
|
16
|
+
expect(formatHeader(specs, ",")).toBe("Row ID,Score\r\n");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("falls back to spec name when no label annotation", () => {
|
|
20
|
+
const specs = [makeAxisSpec("sample"), makeColumnSpec("count")];
|
|
21
|
+
expect(formatHeader(specs, ",")).toBe("sample,count\r\n");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("escapes header containing separator", () => {
|
|
25
|
+
const specs = [makeAxisSpec("a,b", "a,b")];
|
|
26
|
+
expect(formatHeader(specs, ",")).toBe('"a,b"\r\n');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("escapes header containing double-quote", () => {
|
|
30
|
+
const specs = [makeAxisSpec('a"b', 'a"b')];
|
|
31
|
+
expect(formatHeader(specs, ",")).toBe('"a""b"\r\n');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("uses tab separator for TSV", () => {
|
|
35
|
+
const specs = [makeAxisSpec("x", "X"), makeAxisSpec("y", "Y")];
|
|
36
|
+
expect(formatHeader(specs, "\t")).toBe("X\tY\r\n");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// ── formatRow ────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
describe("formatRow", () => {
|
|
43
|
+
it("formats simple string values", () => {
|
|
44
|
+
const vectors = [makeStringVector(["hello", "world"])];
|
|
45
|
+
expect(formatRow(vectors, 0, ",")).toBe("hello\r\n");
|
|
46
|
+
expect(formatRow(vectors, 1, ",")).toBe("world\r\n");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("formats integer values", () => {
|
|
50
|
+
const vectors = [makeIntVector([42, -1])];
|
|
51
|
+
expect(formatRow(vectors, 0, ",")).toBe("42\r\n");
|
|
52
|
+
expect(formatRow(vectors, 1, ",")).toBe("-1\r\n");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("escapes field containing comma", () => {
|
|
56
|
+
const vectors = [makeStringVector(["a,b"])];
|
|
57
|
+
expect(formatRow(vectors, 0, ",")).toBe('"a,b"\r\n');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("escapes field containing double-quote by doubling", () => {
|
|
61
|
+
const vectors = [makeStringVector(['say "hi"'])];
|
|
62
|
+
expect(formatRow(vectors, 0, ",")).toBe('"say ""hi"""\r\n');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("escapes field containing CRLF", () => {
|
|
66
|
+
const vectors = [makeStringVector(["line1\r\nline2"])];
|
|
67
|
+
expect(formatRow(vectors, 0, ",")).toBe('"line1\r\nline2"\r\n');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("escapes field containing bare LF", () => {
|
|
71
|
+
const vectors = [makeStringVector(["line1\nline2"])];
|
|
72
|
+
expect(formatRow(vectors, 0, ",")).toBe('"line1\nline2"\r\n');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("handles unicode characters", () => {
|
|
76
|
+
const vectors = [makeStringVector(["кириллица"]), makeStringVector(["日本語"])];
|
|
77
|
+
expect(formatRow(vectors, 0, ",")).toBe("кириллица,日本語\r\n");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("serializes bigint values", () => {
|
|
81
|
+
const vectors = [makeLongVector([9007199254740993n, -9007199254740993n])];
|
|
82
|
+
expect(formatRow(vectors, 0, ",")).toBe("9007199254740993\r\n");
|
|
83
|
+
expect(formatRow(vectors, 1, ",")).toBe("-9007199254740993\r\n");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("serializes null as empty string", () => {
|
|
87
|
+
const vectors = [makeStringVector([null])];
|
|
88
|
+
expect(formatRow(vectors, 0, ",")).toBe("\r\n");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("serializes NaN as empty string", () => {
|
|
92
|
+
const vectors = [makeDoubleVector([NaN])];
|
|
93
|
+
expect(formatRow(vectors, 0, ",")).toBe("\r\n");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("serializes +Infinity as empty string", () => {
|
|
97
|
+
const vectors = [makeDoubleVector([Infinity])];
|
|
98
|
+
expect(formatRow(vectors, 0, ",")).toBe("\r\n");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("serializes -Infinity as empty string", () => {
|
|
102
|
+
const vectors = [makeDoubleVector([-Infinity])];
|
|
103
|
+
expect(formatRow(vectors, 0, ",")).toBe("\r\n");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("serializes NA-flagged values as empty string", () => {
|
|
107
|
+
const vectors = [makeVectorWithNaBits(ValueType.Int, [42, 99, 7], [1])];
|
|
108
|
+
expect(formatRow(vectors, 0, ",")).toBe("42\r\n");
|
|
109
|
+
expect(formatRow(vectors, 1, ",")).toBe("\r\n");
|
|
110
|
+
expect(formatRow(vectors, 2, ",")).toBe("7\r\n");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("quotes field containing TAB when TSV separator", () => {
|
|
114
|
+
const vectors = [makeStringVector(["has\ttab"])];
|
|
115
|
+
expect(formatRow(vectors, 0, "\t")).toBe('"has\ttab"\r\n');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("does not quote field without special chars in TSV", () => {
|
|
119
|
+
const vectors = [makeStringVector(["plain"])];
|
|
120
|
+
expect(formatRow(vectors, 0, "\t")).toBe("plain\r\n");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── streamPTableRows ─────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
describe("streamPTableRows", () => {
|
|
127
|
+
it("emits BOM when requested", async () => {
|
|
128
|
+
const pTable = makeMockPTable([]);
|
|
129
|
+
const result = await collectStream(
|
|
130
|
+
streamPTableRows({
|
|
131
|
+
pTable,
|
|
132
|
+
columnIndices: [],
|
|
133
|
+
range: undefined,
|
|
134
|
+
chunkSize: 100,
|
|
135
|
+
separator: ",",
|
|
136
|
+
specs: [],
|
|
137
|
+
includeHeader: false,
|
|
138
|
+
bom: true,
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
expect(result).toBe("\uFEFF");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("does not emit BOM when not requested", async () => {
|
|
145
|
+
const pTable = makeMockPTable([]);
|
|
146
|
+
const result = await collectStream(
|
|
147
|
+
streamPTableRows({
|
|
148
|
+
pTable,
|
|
149
|
+
columnIndices: [],
|
|
150
|
+
range: undefined,
|
|
151
|
+
chunkSize: 100,
|
|
152
|
+
separator: ",",
|
|
153
|
+
specs: [],
|
|
154
|
+
includeHeader: false,
|
|
155
|
+
bom: false,
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
expect(result).toBe("");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("skips header when includeHeader is false", async () => {
|
|
162
|
+
const specs = [makeAxisSpec("id", "ID")];
|
|
163
|
+
const vectors = [[makeIntVector([1, 2])]];
|
|
164
|
+
const pTable = makeMockPTable(vectors);
|
|
165
|
+
const range: TableRange = { offset: 0, length: 2 };
|
|
166
|
+
|
|
167
|
+
const result = await collectStream(
|
|
168
|
+
streamPTableRows({
|
|
169
|
+
pTable,
|
|
170
|
+
columnIndices: [0],
|
|
171
|
+
range,
|
|
172
|
+
chunkSize: 100,
|
|
173
|
+
separator: ",",
|
|
174
|
+
specs,
|
|
175
|
+
includeHeader: false,
|
|
176
|
+
bom: false,
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
expect(result).toBe("1\r\n2\r\n");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("includes header when includeHeader is true", async () => {
|
|
183
|
+
const specs = [makeAxisSpec("id", "ID")];
|
|
184
|
+
const vectors = [[makeIntVector([1])]];
|
|
185
|
+
const pTable = makeMockPTable(vectors);
|
|
186
|
+
const range: TableRange = { offset: 0, length: 1 };
|
|
187
|
+
|
|
188
|
+
const result = await collectStream(
|
|
189
|
+
streamPTableRows({
|
|
190
|
+
pTable,
|
|
191
|
+
columnIndices: [0],
|
|
192
|
+
range,
|
|
193
|
+
chunkSize: 100,
|
|
194
|
+
separator: ",",
|
|
195
|
+
specs,
|
|
196
|
+
includeHeader: true,
|
|
197
|
+
bom: false,
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
expect(result).toBe("ID\r\n1\r\n");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("chunks data correctly across multiple getData calls", async () => {
|
|
204
|
+
const specs = [makeAxisSpec("v", "Value")];
|
|
205
|
+
// Two chunks: first has 2 rows, second has 1 row
|
|
206
|
+
const vectors = [[makeIntVector([10, 20])], [makeIntVector([30])]];
|
|
207
|
+
const pTable = makeMockPTable(vectors);
|
|
208
|
+
const range: TableRange = { offset: 0, length: 3 };
|
|
209
|
+
|
|
210
|
+
const result = await collectStream(
|
|
211
|
+
streamPTableRows({
|
|
212
|
+
pTable,
|
|
213
|
+
columnIndices: [0],
|
|
214
|
+
range,
|
|
215
|
+
chunkSize: 2,
|
|
216
|
+
separator: ",",
|
|
217
|
+
specs,
|
|
218
|
+
includeHeader: false,
|
|
219
|
+
bom: false,
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
expect(result).toBe("10\r\n20\r\n30\r\n");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("handles range with non-zero offset", async () => {
|
|
226
|
+
const specs = [makeAxisSpec("v", "Value")];
|
|
227
|
+
const vectors = [[makeIntVector([50, 60])]];
|
|
228
|
+
const pTable: PTableDataSource = {
|
|
229
|
+
getData: async (_cols, options) => {
|
|
230
|
+
expect(options?.range).toEqual({ offset: 5, length: 2 });
|
|
231
|
+
return vectors[0];
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
const range: TableRange = { offset: 5, length: 2 };
|
|
235
|
+
|
|
236
|
+
const result = await collectStream(
|
|
237
|
+
streamPTableRows({
|
|
238
|
+
pTable,
|
|
239
|
+
columnIndices: [0],
|
|
240
|
+
range,
|
|
241
|
+
chunkSize: 100,
|
|
242
|
+
separator: ",",
|
|
243
|
+
specs,
|
|
244
|
+
includeHeader: false,
|
|
245
|
+
bom: false,
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
expect(result).toBe("50\r\n60\r\n");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("yields nothing for data when range is undefined", async () => {
|
|
252
|
+
const specs = [makeAxisSpec("v", "Value")];
|
|
253
|
+
const pTable = makeMockPTable([]);
|
|
254
|
+
|
|
255
|
+
const result = await collectStream(
|
|
256
|
+
streamPTableRows({
|
|
257
|
+
pTable,
|
|
258
|
+
columnIndices: [0],
|
|
259
|
+
range: undefined,
|
|
260
|
+
chunkSize: 100,
|
|
261
|
+
separator: ",",
|
|
262
|
+
specs,
|
|
263
|
+
includeHeader: true,
|
|
264
|
+
bom: false,
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
expect(result).toBe("Value\r\n");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("respects abort signal", async () => {
|
|
271
|
+
const controller = new AbortController();
|
|
272
|
+
controller.abort();
|
|
273
|
+
const specs = [makeAxisSpec("v", "Value")];
|
|
274
|
+
const pTable = makeMockPTable([[makeIntVector([1])]]);
|
|
275
|
+
const range: TableRange = { offset: 0, length: 1 };
|
|
276
|
+
|
|
277
|
+
await expect(
|
|
278
|
+
collectStream(
|
|
279
|
+
streamPTableRows({
|
|
280
|
+
pTable,
|
|
281
|
+
columnIndices: [0],
|
|
282
|
+
range,
|
|
283
|
+
chunkSize: 1,
|
|
284
|
+
separator: ",",
|
|
285
|
+
signal: controller.signal,
|
|
286
|
+
specs,
|
|
287
|
+
includeHeader: false,
|
|
288
|
+
bom: false,
|
|
289
|
+
}),
|
|
290
|
+
),
|
|
291
|
+
).rejects.toThrow();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("emits BOM + header + data in correct order", async () => {
|
|
295
|
+
const specs = [makeAxisSpec("x", "X"), makeColumnSpec("y", "Y")];
|
|
296
|
+
const vectors = [[makeIntVector([1]), makeStringVector(["a"])]];
|
|
297
|
+
const pTable = makeMockPTable(vectors);
|
|
298
|
+
const range: TableRange = { offset: 0, length: 1 };
|
|
299
|
+
|
|
300
|
+
const result = await collectStream(
|
|
301
|
+
streamPTableRows({
|
|
302
|
+
pTable,
|
|
303
|
+
columnIndices: [0, 1],
|
|
304
|
+
range,
|
|
305
|
+
chunkSize: 100,
|
|
306
|
+
separator: ",",
|
|
307
|
+
specs,
|
|
308
|
+
includeHeader: true,
|
|
309
|
+
bom: true,
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
expect(result).toBe("\uFEFFX,Y\r\n1,a\r\n");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("works with TSV separator", async () => {
|
|
316
|
+
const specs = [makeAxisSpec("a", "A"), makeAxisSpec("b", "B")];
|
|
317
|
+
const vectors = [[makeIntVector([1]), makeStringVector(["hello"])]];
|
|
318
|
+
const pTable = makeMockPTable(vectors);
|
|
319
|
+
const range: TableRange = { offset: 0, length: 1 };
|
|
320
|
+
|
|
321
|
+
const result = await collectStream(
|
|
322
|
+
streamPTableRows({
|
|
323
|
+
pTable,
|
|
324
|
+
columnIndices: [0, 1],
|
|
325
|
+
range,
|
|
326
|
+
chunkSize: 100,
|
|
327
|
+
separator: "\t",
|
|
328
|
+
specs,
|
|
329
|
+
includeHeader: true,
|
|
330
|
+
bom: false,
|
|
331
|
+
}),
|
|
332
|
+
);
|
|
333
|
+
expect(result).toBe("A\tB\r\n1\thello\r\n");
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
function makeAxisSpec(name: string, label?: string): PTableColumnSpec {
|
|
340
|
+
const annotations: Record<string, string> = {};
|
|
341
|
+
if (label !== undefined) {
|
|
342
|
+
annotations["pl7.app/label"] = label;
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
type: "axis",
|
|
346
|
+
id: { name, type: "Int" },
|
|
347
|
+
spec: { name, type: "Int", annotations },
|
|
348
|
+
} as PTableColumnSpec;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function makeColumnSpec(name: string, label?: string): PTableColumnSpec {
|
|
352
|
+
const annotations: Record<string, string> = {};
|
|
353
|
+
if (label !== undefined) {
|
|
354
|
+
annotations["pl7.app/label"] = label;
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
type: "column",
|
|
358
|
+
id: `col:${name}` as PObjectId,
|
|
359
|
+
spec: {
|
|
360
|
+
kind: "PColumn",
|
|
361
|
+
name,
|
|
362
|
+
valueType: "String",
|
|
363
|
+
axesSpec: [],
|
|
364
|
+
annotations,
|
|
365
|
+
},
|
|
366
|
+
} as PTableColumnSpec;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function makeStringVector(values: (null | string)[]): PTableVector {
|
|
370
|
+
return { type: ValueType.String, data: values };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function makeIntVector(values: number[]): PTableVector {
|
|
374
|
+
return { type: ValueType.Int, data: new Int32Array(values) };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function makeDoubleVector(values: number[]): PTableVector {
|
|
378
|
+
return { type: ValueType.Double, data: new Float64Array(values) };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function makeLongVector(values: bigint[]): PTableVector {
|
|
382
|
+
return { type: ValueType.Long, data: new BigInt64Array(values) };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function makeVectorWithNaBits(
|
|
386
|
+
type: typeof ValueType.Int,
|
|
387
|
+
data: number[],
|
|
388
|
+
naBits: number[],
|
|
389
|
+
): PTableVector {
|
|
390
|
+
const isNA = new Uint8Array(Math.ceil(data.length / 8));
|
|
391
|
+
for (const bit of naBits) {
|
|
392
|
+
const chunkIndex = Math.floor(bit / 8);
|
|
393
|
+
const mask = 1 << (7 - (bit % 8));
|
|
394
|
+
isNA[chunkIndex] |= mask;
|
|
395
|
+
}
|
|
396
|
+
return { type, data: new Int32Array(data), isNA };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function makeMockPTable(vectorsByChunk: PTableVector[][]): PTableDataSource {
|
|
400
|
+
let callIndex = 0;
|
|
401
|
+
return {
|
|
402
|
+
getData: async (
|
|
403
|
+
_columnIndices: number[],
|
|
404
|
+
_options?: { range?: TableRange; signal?: AbortSignal },
|
|
405
|
+
) => {
|
|
406
|
+
const vectors = vectorsByChunk[callIndex];
|
|
407
|
+
callIndex++;
|
|
408
|
+
return vectors;
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function collectStream(iter: AsyncIterable<string>): Promise<string> {
|
|
414
|
+
const parts: string[] = [];
|
|
415
|
+
for await (const chunk of iter) {
|
|
416
|
+
parts.push(chunk);
|
|
417
|
+
}
|
|
418
|
+
return parts.join("");
|
|
419
|
+
}
|