@datarecce/ui 0.1.39 → 0.1.41
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/api.d.mts +1 -1
- package/dist/api.d.ts +1 -1
- package/dist/{components-DTLQ2djq.js → components-DfXnN1Hx.js} +592 -13
- package/dist/components-DfXnN1Hx.js.map +1 -0
- package/dist/{components-B6oaPB5f.mjs → components-jh6r4tQn.mjs} +593 -14
- package/dist/components-jh6r4tQn.mjs.map +1 -0
- package/dist/components.d.mts +1 -1
- package/dist/components.d.ts +1 -1
- package/dist/components.js +1 -1
- package/dist/components.mjs +1 -1
- package/dist/hooks.d.mts +1 -1
- package/dist/hooks.d.ts +1 -1
- package/dist/{index-DzBojsjY.d.mts → index-B5bpmv0i.d.mts} +70 -70
- package/dist/{index-DzBojsjY.d.mts.map → index-B5bpmv0i.d.mts.map} +1 -1
- package/dist/{index-DvKRw-cR.d.ts → index-B9lSPJTi.d.ts} +70 -70
- package/dist/index-B9lSPJTi.d.ts.map +1 -0
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/theme.d.mts +1 -1
- package/dist/theme.d.ts +1 -1
- package/dist/types.d.mts +1 -1
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
- package/recce-source/docs/plans/2024-12-31-csv-download-design.md +121 -0
- package/recce-source/docs/plans/2024-12-31-csv-download-implementation.md +930 -0
- package/recce-source/js/src/components/run/RunResultPane.tsx +138 -14
- package/recce-source/js/src/lib/csv/extractors.test.ts +456 -0
- package/recce-source/js/src/lib/csv/extractors.ts +468 -0
- package/recce-source/js/src/lib/csv/format.test.ts +211 -0
- package/recce-source/js/src/lib/csv/format.ts +44 -0
- package/recce-source/js/src/lib/csv/index.test.ts +155 -0
- package/recce-source/js/src/lib/csv/index.ts +109 -0
- package/recce-source/js/src/lib/hooks/useCSVExport.ts +136 -0
- package/recce-source/recce/mcp_server.py +54 -30
- package/recce-source/recce/models/check.py +10 -2
- package/dist/components-B6oaPB5f.mjs.map +0 -1
- package/dist/components-DTLQ2djq.js.map +0 -1
- package/dist/index-DvKRw-cR.d.ts.map +0 -1
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV data extractors for each run type
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { QueryDiffResult } from "@/lib/api/adhocQuery";
|
|
6
|
+
import type { ProfileDiffResult, TopKDiffResult } from "@/lib/api/profile";
|
|
7
|
+
import type { RowCountDiffResult } from "@/lib/api/rowcount";
|
|
8
|
+
import type { DataFrame } from "@/lib/api/types";
|
|
9
|
+
import type { ValueDiffResult } from "@/lib/api/valuediff";
|
|
10
|
+
|
|
11
|
+
export interface CSVData {
|
|
12
|
+
columns: string[];
|
|
13
|
+
rows: unknown[][];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CSVExportOptions {
|
|
17
|
+
displayMode?: "inline" | "side_by_side";
|
|
18
|
+
primaryKeys?: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Format a cell value for inline diff mode
|
|
23
|
+
* If base and current are the same, return the value
|
|
24
|
+
* If different, return "(base_value) (current_value)"
|
|
25
|
+
*/
|
|
26
|
+
function formatInlineDiffCell(
|
|
27
|
+
baseValue: unknown,
|
|
28
|
+
currentValue: unknown,
|
|
29
|
+
): unknown {
|
|
30
|
+
// Convert to string for comparison
|
|
31
|
+
const baseStr = baseValue == null ? "" : String(baseValue);
|
|
32
|
+
const currentStr = currentValue == null ? "" : String(currentValue);
|
|
33
|
+
|
|
34
|
+
if (baseStr === currentStr) {
|
|
35
|
+
return baseValue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Format as "(base) (current)" when different
|
|
39
|
+
const baseDisplay = baseValue == null ? "" : `(${baseValue})`;
|
|
40
|
+
const currentDisplay = currentValue == null ? "" : `(${currentValue})`;
|
|
41
|
+
return `${baseDisplay} ${currentDisplay}`.trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Extract columns and rows from a DataFrame
|
|
46
|
+
*/
|
|
47
|
+
function extractDataFrame(df: DataFrame | undefined): CSVData | null {
|
|
48
|
+
if (!df || !df.columns || !df.data) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
columns: df.columns.map((col) => col.name),
|
|
53
|
+
rows: df.data.map((row) => [...row]),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Extract CSV data from query result (single environment)
|
|
59
|
+
*/
|
|
60
|
+
function extractQuery(result: unknown): CSVData | null {
|
|
61
|
+
return extractDataFrame(result as DataFrame);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Extract CSV data from query_base result
|
|
66
|
+
*/
|
|
67
|
+
function extractQueryBase(result: unknown): CSVData | null {
|
|
68
|
+
// query_base returns a DataFrame directly (QueryResult = DataFrame)
|
|
69
|
+
return extractDataFrame(result as DataFrame);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Extract CSV data from query_diff result
|
|
74
|
+
* Supports two result shapes:
|
|
75
|
+
* 1. { diff: DataFrame } - joined diff result (QueryDiffJoinResultView)
|
|
76
|
+
* 2. { base: DataFrame, current: DataFrame } - separate base/current (QueryDiffResultView)
|
|
77
|
+
*
|
|
78
|
+
* Display modes:
|
|
79
|
+
* - "inline": Merged rows where same values shown as-is, differing values shown as "(base) (current)"
|
|
80
|
+
* - "side_by_side": Single row per record with base__col, current__col columns
|
|
81
|
+
*
|
|
82
|
+
* Note: When base and current have different row counts (e.g., added/removed rows),
|
|
83
|
+
* the merge is done positionally. Extra rows will show null for the missing environment.
|
|
84
|
+
*/
|
|
85
|
+
function extractQueryDiff(
|
|
86
|
+
result: unknown,
|
|
87
|
+
options?: CSVExportOptions,
|
|
88
|
+
): CSVData | null {
|
|
89
|
+
const typed = result as QueryDiffResult;
|
|
90
|
+
const displayMode = options?.displayMode ?? "inline";
|
|
91
|
+
const primaryKeys = options?.primaryKeys ?? [];
|
|
92
|
+
|
|
93
|
+
// First, check if diff DataFrame exists (joined result)
|
|
94
|
+
if (typed?.diff) {
|
|
95
|
+
return extractQueryDiffJoined(typed.diff, displayMode, primaryKeys);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Fall back to base/current DataFrames
|
|
99
|
+
return extractQueryDiffSeparate(typed, displayMode);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Extract CSV from joined diff DataFrame (QueryDiffJoinResultView)
|
|
104
|
+
* The diff DataFrame has columns like: pk, col1, col2, in_a, in_b
|
|
105
|
+
* where in_a/in_b indicate presence in base/current
|
|
106
|
+
*
|
|
107
|
+
* The DataFrame may have separate rows for base (in_a=true) and current (in_b=true)
|
|
108
|
+
* records. This function groups them by primary key and merges into single output rows.
|
|
109
|
+
*
|
|
110
|
+
* Produces same layout as extractQueryDiffSeparate for consistency.
|
|
111
|
+
*/
|
|
112
|
+
function extractQueryDiffJoined(
|
|
113
|
+
diff: DataFrame,
|
|
114
|
+
displayMode: "inline" | "side_by_side",
|
|
115
|
+
primaryKeys: string[],
|
|
116
|
+
): CSVData | null {
|
|
117
|
+
if (!diff?.columns || !diff?.data) return null;
|
|
118
|
+
|
|
119
|
+
// Find in_a and in_b column indices
|
|
120
|
+
const inAIndex = diff.columns.findIndex(
|
|
121
|
+
(col) => col.key.toLowerCase() === "in_a",
|
|
122
|
+
);
|
|
123
|
+
const inBIndex = diff.columns.findIndex(
|
|
124
|
+
(col) => col.key.toLowerCase() === "in_b",
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Get data columns (exclude in_a and in_b)
|
|
128
|
+
const dataColumns = diff.columns.filter(
|
|
129
|
+
(col) =>
|
|
130
|
+
col.key.toLowerCase() !== "in_a" && col.key.toLowerCase() !== "in_b",
|
|
131
|
+
);
|
|
132
|
+
const dataColumnNames = dataColumns.map((col) => col.name);
|
|
133
|
+
const dataColumnIndices = dataColumns.map((col) =>
|
|
134
|
+
diff.columns.findIndex((c) => c.key === col.key),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Find primary key column indices
|
|
138
|
+
const pkIndices = primaryKeys
|
|
139
|
+
.map((pk) => diff.columns.findIndex((col) => col.key === pk))
|
|
140
|
+
.filter((idx) => idx >= 0);
|
|
141
|
+
|
|
142
|
+
// Extract row values for data columns only
|
|
143
|
+
const extractRowValues = (rowData: unknown[]): unknown[] => {
|
|
144
|
+
return dataColumnIndices.map((colIndex) => rowData[colIndex]);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Generate primary key string for grouping
|
|
148
|
+
const getPrimaryKeyValue = (rowData: unknown[]): string => {
|
|
149
|
+
if (pkIndices.length === 0) {
|
|
150
|
+
// No primary keys - use row index (will be set later)
|
|
151
|
+
return "";
|
|
152
|
+
}
|
|
153
|
+
return pkIndices.map((idx) => String(rowData[idx] ?? "")).join("|||");
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Group rows by primary key, separating base and current
|
|
157
|
+
const groupedRows: Map<
|
|
158
|
+
string,
|
|
159
|
+
{ base: unknown[] | null; current: unknown[] | null }
|
|
160
|
+
> = new Map();
|
|
161
|
+
const rowOrder: string[] = []; // Track insertion order
|
|
162
|
+
|
|
163
|
+
diff.data.forEach((rowData, index) => {
|
|
164
|
+
const inA = inAIndex >= 0 ? rowData[inAIndex] : true;
|
|
165
|
+
const inB = inBIndex >= 0 ? rowData[inBIndex] : true;
|
|
166
|
+
|
|
167
|
+
// Use primary key or index for grouping
|
|
168
|
+
let pkValue = getPrimaryKeyValue(rowData);
|
|
169
|
+
if (pkValue === "") {
|
|
170
|
+
pkValue = String(index);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!groupedRows.has(pkValue)) {
|
|
174
|
+
groupedRows.set(pkValue, { base: null, current: null });
|
|
175
|
+
rowOrder.push(pkValue);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const group = groupedRows.get(pkValue);
|
|
179
|
+
if (!group) return;
|
|
180
|
+
|
|
181
|
+
const values = extractRowValues(rowData);
|
|
182
|
+
|
|
183
|
+
if (inA) {
|
|
184
|
+
group.base = values;
|
|
185
|
+
}
|
|
186
|
+
if (inB) {
|
|
187
|
+
group.current = values;
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if (displayMode === "side_by_side") {
|
|
192
|
+
// Side-by-side: columns like base__col1, current__col1, base__col2, current__col2
|
|
193
|
+
const columns: string[] = [];
|
|
194
|
+
dataColumnNames.forEach((name) => {
|
|
195
|
+
columns.push(`base__${name}`, `current__${name}`);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const rows: unknown[][] = [];
|
|
199
|
+
|
|
200
|
+
for (const pkValue of rowOrder) {
|
|
201
|
+
const group = groupedRows.get(pkValue);
|
|
202
|
+
if (!group) continue;
|
|
203
|
+
|
|
204
|
+
const baseValues = group.base;
|
|
205
|
+
const currentValues = group.current;
|
|
206
|
+
|
|
207
|
+
const row: unknown[] = [];
|
|
208
|
+
dataColumnNames.forEach((_, colIndex) => {
|
|
209
|
+
row.push(baseValues ? baseValues[colIndex] : null);
|
|
210
|
+
row.push(currentValues ? currentValues[colIndex] : null);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
rows.push(row);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { columns, rows };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Inline mode: merged rows with diff shown in parentheses
|
|
220
|
+
// Format: value if same, "(base_value) (current_value)" if different
|
|
221
|
+
const columns = [...dataColumnNames];
|
|
222
|
+
const rows: unknown[][] = [];
|
|
223
|
+
|
|
224
|
+
for (const pkValue of rowOrder) {
|
|
225
|
+
const group = groupedRows.get(pkValue);
|
|
226
|
+
if (!group) continue;
|
|
227
|
+
|
|
228
|
+
const baseValues = group.base;
|
|
229
|
+
const currentValues = group.current;
|
|
230
|
+
|
|
231
|
+
// Merge base and current into single row
|
|
232
|
+
const row: unknown[] = [];
|
|
233
|
+
dataColumnNames.forEach((_, colIndex) => {
|
|
234
|
+
const baseVal = baseValues ? baseValues[colIndex] : null;
|
|
235
|
+
const currentVal = currentValues ? currentValues[colIndex] : null;
|
|
236
|
+
row.push(formatInlineDiffCell(baseVal, currentVal));
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
rows.push(row);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { columns, rows };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Extract CSV from separate base/current DataFrames (QueryDiffResultView)
|
|
247
|
+
*/
|
|
248
|
+
function extractQueryDiffSeparate(
|
|
249
|
+
typed: QueryDiffResult,
|
|
250
|
+
displayMode: "inline" | "side_by_side",
|
|
251
|
+
): CSVData | null {
|
|
252
|
+
const df = typed?.current || typed?.base;
|
|
253
|
+
if (!df) return null;
|
|
254
|
+
|
|
255
|
+
// If only one exists, just return it
|
|
256
|
+
if (!typed?.base || !typed?.current) {
|
|
257
|
+
return extractDataFrame(df);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const columnNames = typed.current.columns.map((c) => c.name);
|
|
261
|
+
|
|
262
|
+
if (displayMode === "side_by_side") {
|
|
263
|
+
// Side-by-side: columns like base__col1, current__col1, base__col2, current__col2
|
|
264
|
+
const columns: string[] = [];
|
|
265
|
+
columnNames.forEach((name) => {
|
|
266
|
+
columns.push(`base__${name}`, `current__${name}`);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const rows: unknown[][] = [];
|
|
270
|
+
const maxRows = Math.max(typed.base.data.length, typed.current.data.length);
|
|
271
|
+
|
|
272
|
+
for (let i = 0; i < maxRows; i++) {
|
|
273
|
+
const row: unknown[] = [];
|
|
274
|
+
const baseRow = i < typed.base.data.length ? typed.base.data[i] : null;
|
|
275
|
+
const currentRow =
|
|
276
|
+
i < typed.current.data.length ? typed.current.data[i] : null;
|
|
277
|
+
|
|
278
|
+
columnNames.forEach((_, colIndex) => {
|
|
279
|
+
row.push(baseRow ? baseRow[colIndex] : null);
|
|
280
|
+
row.push(currentRow ? currentRow[colIndex] : null);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
rows.push(row);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return { columns, rows };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Inline mode: merged rows with diff shown in parentheses
|
|
290
|
+
// Format: value if same, "(base_value) (current_value)" if different
|
|
291
|
+
const columns = [...columnNames];
|
|
292
|
+
const rows: unknown[][] = [];
|
|
293
|
+
|
|
294
|
+
const maxRows = Math.max(typed.base.data.length, typed.current.data.length);
|
|
295
|
+
for (let i = 0; i < maxRows; i++) {
|
|
296
|
+
const baseRow = i < typed.base.data.length ? typed.base.data[i] : null;
|
|
297
|
+
const currentRow =
|
|
298
|
+
i < typed.current.data.length ? typed.current.data[i] : null;
|
|
299
|
+
|
|
300
|
+
// Merge base and current into single row
|
|
301
|
+
const row: unknown[] = [];
|
|
302
|
+
columnNames.forEach((_, colIndex) => {
|
|
303
|
+
const baseVal = baseRow ? baseRow[colIndex] : null;
|
|
304
|
+
const currentVal = currentRow ? currentRow[colIndex] : null;
|
|
305
|
+
row.push(formatInlineDiffCell(baseVal, currentVal));
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
rows.push(row);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return { columns, rows };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Extract CSV data from profile_diff result
|
|
316
|
+
*/
|
|
317
|
+
function extractProfileDiff(result: unknown): CSVData | null {
|
|
318
|
+
const typed = result as ProfileDiffResult;
|
|
319
|
+
|
|
320
|
+
// Profile data has metrics as columns, one row per profiled column
|
|
321
|
+
const df = typed?.current || typed?.base;
|
|
322
|
+
if (!df) return null;
|
|
323
|
+
|
|
324
|
+
// If both exist, combine with source column
|
|
325
|
+
if (typed?.base && typed?.current) {
|
|
326
|
+
const columns = ["_source", ...typed.current.columns.map((c) => c.name)];
|
|
327
|
+
const rows: unknown[][] = [];
|
|
328
|
+
|
|
329
|
+
typed.base.data.forEach((row) => {
|
|
330
|
+
rows.push(["base", ...row]);
|
|
331
|
+
});
|
|
332
|
+
typed.current.data.forEach((row) => {
|
|
333
|
+
rows.push(["current", ...row]);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
return { columns, rows };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return extractDataFrame(df);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Extract CSV data from row_count_diff result
|
|
344
|
+
*/
|
|
345
|
+
function extractRowCountDiff(result: unknown): CSVData | null {
|
|
346
|
+
const typed = result as RowCountDiffResult;
|
|
347
|
+
if (!typed || typeof typed !== "object") return null;
|
|
348
|
+
|
|
349
|
+
const columns = [
|
|
350
|
+
"node",
|
|
351
|
+
"base_count",
|
|
352
|
+
"current_count",
|
|
353
|
+
"diff",
|
|
354
|
+
"diff_percent",
|
|
355
|
+
];
|
|
356
|
+
const rows: unknown[][] = [];
|
|
357
|
+
|
|
358
|
+
for (const [nodeName, counts] of Object.entries(typed)) {
|
|
359
|
+
if (counts && typeof counts === "object") {
|
|
360
|
+
const base = (counts as { base?: number | null }).base;
|
|
361
|
+
const current = (counts as { curr?: number | null }).curr;
|
|
362
|
+
const diff = base != null && current != null ? current - base : null;
|
|
363
|
+
const diffPercent =
|
|
364
|
+
base && diff !== null ? ((diff / base) * 100).toFixed(2) + "%" : null;
|
|
365
|
+
rows.push([nodeName, base, current, diff, diffPercent]);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { columns, rows };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Extract CSV data from value_diff result
|
|
374
|
+
*/
|
|
375
|
+
function extractValueDiff(result: unknown): CSVData | null {
|
|
376
|
+
const typed = result as ValueDiffResult;
|
|
377
|
+
if (!typed?.data) return null;
|
|
378
|
+
return extractDataFrame(typed.data);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Extract CSV data from value_diff_detail result
|
|
383
|
+
*/
|
|
384
|
+
function extractValueDiffDetail(result: unknown): CSVData | null {
|
|
385
|
+
return extractDataFrame(result as DataFrame);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Extract CSV data from top_k_diff result
|
|
390
|
+
*/
|
|
391
|
+
function extractTopKDiff(result: unknown): CSVData | null {
|
|
392
|
+
const typed = result as TopKDiffResult;
|
|
393
|
+
|
|
394
|
+
// Check if either base or current has values
|
|
395
|
+
const hasBaseValues = !!typed?.base?.values;
|
|
396
|
+
const hasCurrentValues = !!typed?.current?.values;
|
|
397
|
+
if (!hasBaseValues && !hasCurrentValues) return null;
|
|
398
|
+
|
|
399
|
+
// TopK has { values: [...], counts: [...], valids: number }
|
|
400
|
+
const columns = ["_source", "value", "count"];
|
|
401
|
+
const rows: unknown[][] = [];
|
|
402
|
+
|
|
403
|
+
if (typed?.base?.values) {
|
|
404
|
+
typed.base.values.forEach((value, index) => {
|
|
405
|
+
rows.push(["base", value, typed.base.counts[index]]);
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
if (typed?.current?.values) {
|
|
409
|
+
typed.current.values.forEach((value, index) => {
|
|
410
|
+
rows.push(["current", value, typed.current.counts[index]]);
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return { columns, rows };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Map of run types to their extractor functions
|
|
419
|
+
* Some extractors accept options (like query_diff for displayMode)
|
|
420
|
+
*/
|
|
421
|
+
const extractors: Record<
|
|
422
|
+
string,
|
|
423
|
+
(result: unknown, options?: CSVExportOptions) => CSVData | null
|
|
424
|
+
> = {
|
|
425
|
+
query: extractQuery,
|
|
426
|
+
query_base: extractQueryBase,
|
|
427
|
+
query_diff: extractQueryDiff,
|
|
428
|
+
profile: extractProfileDiff,
|
|
429
|
+
profile_diff: extractProfileDiff,
|
|
430
|
+
row_count: extractRowCountDiff,
|
|
431
|
+
row_count_diff: extractRowCountDiff,
|
|
432
|
+
value_diff: extractValueDiff,
|
|
433
|
+
value_diff_detail: extractValueDiffDetail,
|
|
434
|
+
top_k_diff: extractTopKDiff,
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Extract CSV data from a run result
|
|
439
|
+
* @param runType - The type of run (query, query_diff, etc.)
|
|
440
|
+
* @param result - The run result data
|
|
441
|
+
* @param options - Optional export options (e.g., displayMode for query_diff)
|
|
442
|
+
* @returns CSVData or null if the run type doesn't support CSV export
|
|
443
|
+
*/
|
|
444
|
+
export function extractCSVData(
|
|
445
|
+
runType: string,
|
|
446
|
+
result: unknown,
|
|
447
|
+
options?: CSVExportOptions,
|
|
448
|
+
): CSVData | null {
|
|
449
|
+
const extractor = extractors[runType];
|
|
450
|
+
if (!extractor) return null;
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
return extractor(result, options);
|
|
454
|
+
} catch (error) {
|
|
455
|
+
console.error(
|
|
456
|
+
`Failed to extract CSV data for run type "${runType}":`,
|
|
457
|
+
error,
|
|
458
|
+
);
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Check if a run type supports CSV export
|
|
465
|
+
*/
|
|
466
|
+
export function supportsCSVExport(runType: string): boolean {
|
|
467
|
+
return runType in extractors;
|
|
468
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for CSV formatting utilities
|
|
3
|
+
*/
|
|
4
|
+
import { toCSV } from "./format";
|
|
5
|
+
|
|
6
|
+
describe("toCSV", () => {
|
|
7
|
+
describe("basic formatting", () => {
|
|
8
|
+
test("should format simple data with headers", () => {
|
|
9
|
+
const columns = ["name", "age"];
|
|
10
|
+
const rows = [
|
|
11
|
+
["Alice", 30],
|
|
12
|
+
["Bob", 25],
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const result = toCSV(columns, rows);
|
|
16
|
+
|
|
17
|
+
// Should have BOM prefix
|
|
18
|
+
expect(result.startsWith("\uFEFF")).toBe(true);
|
|
19
|
+
// Should have correct content
|
|
20
|
+
expect(result).toContain("name,age");
|
|
21
|
+
expect(result).toContain("Alice,30");
|
|
22
|
+
expect(result).toContain("Bob,25");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("should use CRLF line endings", () => {
|
|
26
|
+
const columns = ["col1"];
|
|
27
|
+
const rows = [["a"], ["b"]];
|
|
28
|
+
|
|
29
|
+
const result = toCSV(columns, rows);
|
|
30
|
+
|
|
31
|
+
// Remove BOM for easier checking
|
|
32
|
+
const content = result.slice(1);
|
|
33
|
+
expect(content).toBe("col1\r\na\r\nb");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("should handle empty rows", () => {
|
|
37
|
+
const columns = ["col1", "col2"];
|
|
38
|
+
const rows: unknown[][] = [];
|
|
39
|
+
|
|
40
|
+
const result = toCSV(columns, rows);
|
|
41
|
+
|
|
42
|
+
expect(result).toBe("\uFEFFcol1,col2");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("should handle single column", () => {
|
|
46
|
+
const columns = ["value"];
|
|
47
|
+
const rows = [[1], [2], [3]];
|
|
48
|
+
|
|
49
|
+
const result = toCSV(columns, rows);
|
|
50
|
+
const content = result.slice(1);
|
|
51
|
+
|
|
52
|
+
expect(content).toBe("value\r\n1\r\n2\r\n3");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("special character escaping", () => {
|
|
57
|
+
test("should escape values containing commas", () => {
|
|
58
|
+
const columns = ["name"];
|
|
59
|
+
const rows = [["Smith, John"]];
|
|
60
|
+
|
|
61
|
+
const result = toCSV(columns, rows);
|
|
62
|
+
|
|
63
|
+
expect(result).toContain('"Smith, John"');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("should escape values containing double quotes", () => {
|
|
67
|
+
const columns = ["quote"];
|
|
68
|
+
const rows = [['He said "hello"']];
|
|
69
|
+
|
|
70
|
+
const result = toCSV(columns, rows);
|
|
71
|
+
|
|
72
|
+
// Quotes should be doubled and wrapped
|
|
73
|
+
expect(result).toContain('"He said ""hello"""');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("should escape values containing newlines", () => {
|
|
77
|
+
const columns = ["text"];
|
|
78
|
+
const rows = [["line1\nline2"]];
|
|
79
|
+
|
|
80
|
+
const result = toCSV(columns, rows);
|
|
81
|
+
|
|
82
|
+
expect(result).toContain('"line1\nline2"');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("should escape values containing carriage returns", () => {
|
|
86
|
+
const columns = ["text"];
|
|
87
|
+
const rows = [["line1\rline2"]];
|
|
88
|
+
|
|
89
|
+
const result = toCSV(columns, rows);
|
|
90
|
+
|
|
91
|
+
expect(result).toContain('"line1\rline2"');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("should escape values with multiple special characters", () => {
|
|
95
|
+
const columns = ["complex"];
|
|
96
|
+
const rows = [['value with "quotes", commas, and\nnewlines']];
|
|
97
|
+
|
|
98
|
+
const result = toCSV(columns, rows);
|
|
99
|
+
|
|
100
|
+
expect(result).toContain(
|
|
101
|
+
'"value with ""quotes"", commas, and\nnewlines"',
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("should not escape values without special characters", () => {
|
|
106
|
+
const columns = ["simple"];
|
|
107
|
+
const rows = [["plain text"]];
|
|
108
|
+
|
|
109
|
+
const result = toCSV(columns, rows);
|
|
110
|
+
const content = result.slice(1);
|
|
111
|
+
|
|
112
|
+
expect(content).toBe("simple\r\nplain text");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("null and undefined handling", () => {
|
|
117
|
+
test("should convert null to empty string", () => {
|
|
118
|
+
const columns = ["value"];
|
|
119
|
+
const rows = [[null]];
|
|
120
|
+
|
|
121
|
+
const result = toCSV(columns, rows);
|
|
122
|
+
const content = result.slice(1);
|
|
123
|
+
|
|
124
|
+
expect(content).toBe("value\r\n");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("should convert undefined to empty string", () => {
|
|
128
|
+
const columns = ["value"];
|
|
129
|
+
const rows = [[undefined]];
|
|
130
|
+
|
|
131
|
+
const result = toCSV(columns, rows);
|
|
132
|
+
const content = result.slice(1);
|
|
133
|
+
|
|
134
|
+
expect(content).toBe("value\r\n");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("should handle mixed null and values", () => {
|
|
138
|
+
const columns = ["a", "b", "c"];
|
|
139
|
+
const rows = [[1, null, 3]];
|
|
140
|
+
|
|
141
|
+
const result = toCSV(columns, rows);
|
|
142
|
+
|
|
143
|
+
expect(result).toContain("1,,3");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("data type handling", () => {
|
|
148
|
+
test("should convert numbers to strings", () => {
|
|
149
|
+
const columns = ["num"];
|
|
150
|
+
const rows = [[42], [3.14], [-100]];
|
|
151
|
+
|
|
152
|
+
const result = toCSV(columns, rows);
|
|
153
|
+
|
|
154
|
+
expect(result).toContain("42");
|
|
155
|
+
expect(result).toContain("3.14");
|
|
156
|
+
expect(result).toContain("-100");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("should convert booleans to strings", () => {
|
|
160
|
+
const columns = ["bool"];
|
|
161
|
+
const rows = [[true], [false]];
|
|
162
|
+
|
|
163
|
+
const result = toCSV(columns, rows);
|
|
164
|
+
|
|
165
|
+
expect(result).toContain("true");
|
|
166
|
+
expect(result).toContain("false");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("should JSON stringify objects", () => {
|
|
170
|
+
const columns = ["obj"];
|
|
171
|
+
const rows = [[{ key: "value" }]];
|
|
172
|
+
|
|
173
|
+
const result = toCSV(columns, rows);
|
|
174
|
+
|
|
175
|
+
// Objects get JSON stringified, quotes get doubled and wrapped
|
|
176
|
+
// {"key":"value"} becomes "{""key"":""value""}"
|
|
177
|
+
expect(result).toContain('"{""key"":""value""}"');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("should JSON stringify arrays", () => {
|
|
181
|
+
const columns = ["arr"];
|
|
182
|
+
const rows = [[[1, 2, 3]]];
|
|
183
|
+
|
|
184
|
+
const result = toCSV(columns, rows);
|
|
185
|
+
|
|
186
|
+
// Arrays get JSON stringified, contains commas so gets quoted
|
|
187
|
+
expect(result).toContain('"[1,2,3]"');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("UTF-8 BOM", () => {
|
|
192
|
+
test("should include UTF-8 BOM at start for Excel compatibility", () => {
|
|
193
|
+
const columns = ["col"];
|
|
194
|
+
const rows = [["data"]];
|
|
195
|
+
|
|
196
|
+
const result = toCSV(columns, rows);
|
|
197
|
+
|
|
198
|
+
expect(result.charCodeAt(0)).toBe(0xfeff);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("should handle unicode characters", () => {
|
|
202
|
+
const columns = ["unicode"];
|
|
203
|
+
const rows = [["Hello \u4e16\u754c"], ["\u00e9\u00e0\u00fc"]];
|
|
204
|
+
|
|
205
|
+
const result = toCSV(columns, rows);
|
|
206
|
+
|
|
207
|
+
expect(result).toContain("Hello \u4e16\u754c");
|
|
208
|
+
expect(result).toContain("\u00e9\u00e0\u00fc");
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
});
|