From 6107625ebed44d505a1d78528fcd3d8eeb85a0af Mon Sep 17 00:00:00 2001 From: na12334 Date: Thu, 10 Sep 2026 01:36:39 +0530 Subject: [PATCH 1/4] add data profiler CSV and JSON export --- src/components/DataProfiler.tsx | 415 +++++++++++++++++++++---- tests/components/DataProfiler.test.tsx | 65 ++++ 2 files changed, 416 insertions(+), 64 deletions(-) diff --git a/src/components/DataProfiler.tsx b/src/components/DataProfiler.tsx index 6ac46771..6a959a97 100644 --- a/src/components/DataProfiler.tsx +++ b/src/components/DataProfiler.tsx @@ -2,11 +2,29 @@ import { appFetch } from "@/lib/config/base-path"; import { useState, useEffect, useMemo } from "react"; -import { LoaderCircle, ChartColumn, X, Hash, CircleAlert, Sparkles, Lock } from "lucide-react"; +import { + LoaderCircle, + ChartColumn, + X, + Hash, + CircleAlert, + Sparkles, + Lock, + Download, +} from "lucide-react"; import { cn } from "@/lib/utils"; import { TableSchema, DatabaseConnection } from "@/lib/types"; import { detectSensitiveColumns, maskValue } from "@/lib/data-masking"; import { buildConnectionPayload } from "@/hooks/use-connection-payload"; +import { csvRow } from "@/lib/export/csv"; +import { downloadText } from "@/lib/export/download"; +import { Button } from "./ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; interface ColumnProfile { name: string; @@ -36,9 +54,15 @@ interface DataProfilerProps { schemaContext?: string; databaseType?: string; /** Optional API adapter: when provided, bypasses the built-in /api/db/profile fetch. */ - onProfile?: (params: { connectionId: string; tableName: string }) => Promise; + onProfile?: (params: { + connectionId: string; + tableName: string; + }) => Promise; /** Optional API adapter: when provided, bypasses the built-in /api/ai/describe-schema fetch. */ - onDescribeSchema?: (params: { tableName: string; schemaContext: string }) => Promise; + onDescribeSchema?: (params: { + tableName: string; + schemaContext: string; + }) => Promise; } export function DataProfiler({ @@ -64,8 +88,90 @@ export function DataProfiler({ return detectSensitiveColumns(tableSchema.columns.map((c) => c.name)); }, [tableSchema]); + const getExportColumn = (col: ColumnProfile) => { + const rule = sensitiveColumnNames.get(col.name); + + return { + name: col.name, + type: col.type || "", + totalRows: col.totalRows, + nullCount: col.nullCount, + nullPercent: col.nullPercent, + distinctCount: col.distinctCount, + minValue: + col.minValue && rule + ? maskValue(col.minValue, rule) + : col.minValue || "", + maxValue: + col.maxValue && rule + ? maskValue(col.maxValue, rule) + : col.maxValue || "", + sampleValues: + col.sampleValues?.map((value) => + rule ? maskValue(value, rule) : value, + ) || [], + error: col.error || "", + }; + }; + + const exportProfile = (format: "csv" | "json") => { + if (!profile) return; + + const exportedColumns = profile.columns.map(getExportColumn); + const safeTableName = + profile.tableName.replace(/[^a-zA-Z0-9_-]/g, "_") || "table"; + const fileName = `data_profile_${safeTableName}_${Date.now()}.${format}`; + + if (format === "csv") { + const headers = [ + "Column", + "Type", + "Total Rows", + "Null Count", + "Null %", + "Distinct Count", + "Min", + "Max", + "Sample Values", + "Error", + ]; + + const rows = exportedColumns.map((col) => + csvRow([ + col.name, + col.type, + col.totalRows, + col.nullCount, + col.nullPercent, + col.distinctCount, + col.minValue, + col.maxValue, + col.sampleValues.join(" | "), + col.error, + ]), + ); + + const content = [csvRow(headers), ...rows].join("\n"); + downloadText(content, "text/csv", fileName); + return; + } + + const content = JSON.stringify( + { + tableName: profile.tableName, + totalRows: profile.totalRows, + columns: exportedColumns, + }, + null, + 2, + ); + + downloadText(content, "application/json", fileName); + }; + const fetchAiSummary = async (data: ProfileData) => { setIsAiLoading(true); + try { const profileSummary = data.columns .map( @@ -78,7 +184,10 @@ export function DataProfiler({ if (onDescribeSchema) { // Platform adapter: use callback instead of fetch - const result = await onDescribeSchema({ tableName, schemaContext: fullSchemaContext }); + const result = await onDescribeSchema({ + tableName, + schemaContext: fullSchemaContext, + }); setAiSummary(result); } else { // Default: existing fetch behavior @@ -98,9 +207,12 @@ export function DataProfiler({ if (!reader) return; let full = ""; + while (true) { const { done, value } = await reader.read(); + if (done) break; + full += new TextDecoder().decode(value); setAiSummary(full); } @@ -117,6 +229,7 @@ export function DataProfiler({ // earlier than its declaration. Pure code motion - no hook order changes. const fetchProfile = async () => { if (!connection || !tableSchema) return; + setIsLoading(true); setError(null); @@ -125,17 +238,25 @@ export function DataProfiler({ if (onProfile) { // Platform adapter: use callback instead of fetch - data = await onProfile({ connectionId: connection.id, tableName }); + data = await onProfile({ + connectionId: connection.id, + tableName, + }); } else { // Default: existing fetch behavior const columns = tableSchema.columns?.map((c) => c.name) || []; + const response = await appFetch("/api/db/profile", { method: "POST", headers: { "Content-Type": "application/json" }, // The seed id for a managed connection: the browser's copy has had its // password and connection string stripped, so the object cannot be // resolved to a database from a cold provider cache. - body: JSON.stringify({ ...buildConnectionPayload(connection), tableName, columns }), + body: JSON.stringify({ + ...buildConnectionPayload(connection), + tableName, + columns, + }), }); if (!response.ok) { @@ -161,11 +282,13 @@ export function DataProfiler({ if (isOpen && tableName && connection) { fetchProfile(); } + return () => { setProfile(null); setAiSummary(""); setError(null); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen, tableName]); @@ -189,11 +312,14 @@ export function DataProfiler({ */ useEffect(() => { if (!isOpen) return; + const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; onClose(); }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); }, [isOpen, onClose]); @@ -212,31 +338,93 @@ export function DataProfiler({ */}
- - Data Profiler - {tableName} + + + + Data Profiler + + + + {tableName} + +
+ +
+ {profile && ( + + + + + + + exportProfile("csv")} + className="text-xs cursor-pointer" + > + Export as CSV + + + exportProfile("json")} + className="text-xs cursor-pointer" + > + Export as JSON + + + + )} + +
-
{/* Content */}
{isLoading && (
- - Profiling {tableName}... + + + + Profiling {tableName}... +
)} {error && (
- + + {error}
)} @@ -246,18 +434,38 @@ export function DataProfiler({ {/* Summary Stats */}
-

Total Rows

-

{profile.totalRows.toLocaleString()}

+

+ Total Rows +

+ +

+ {profile.totalRows.toLocaleString()} +

+
-

Columns

-

{profile.columns.length}

+

+ Columns +

+ +

+ {profile.columns.length} +

+
-

Avg Null %

+

+ Avg Null % +

+

{profile.columns.length > 0 - ? Math.round(profile.columns.reduce((sum, c) => sum + c.nullPercent, 0) / profile.columns.length) + ? Math.round( + profile.columns.reduce( + (sum, c) => sum + c.nullPercent, + 0, + ) / profile.columns.length, + ) : 0} %

@@ -266,25 +474,51 @@ export function DataProfiler({ {/* Column Profiles */}
-

Column Profiles

+

+ Column Profiles +

+ {profile.columns.map((col) => ( -
+
- - {col.name} - {col.type && {col.type}} + + + + {col.name} + + + {col.type && ( + + {col.type} + + )} + {sensitiveColumnNames.has(col.name) && ( - + )}
- {col.distinctCount.toLocaleString()} distinct + + + {col.distinctCount.toLocaleString()} distinct +
{col.error ? ( -

{col.error}

+

+ {col.error} +

) : ( <> {/* Null bar */} @@ -299,9 +533,12 @@ export function DataProfiler({ ? "bg-warning-tint" : "bg-success-tint", )} - style={{ width: `${100 - col.nullPercent}%` }} + style={{ + width: `${100 - col.nullPercent}%`, + }} />
+ {col.minValue && (() => { - const rule = sensitiveColumnNames.get(col.name); - const display = rule ? maskValue(col.minValue, rule) : col.minValue.substring(0, 30); + const rule = sensitiveColumnNames.get( + col.name, + ); + + const display = rule + ? maskValue(col.minValue, rule) + : col.minValue.substring(0, 30); + return ( min:{" "} - + {display} ); })()} + {col.maxValue && (() => { - const rule = sensitiveColumnNames.get(col.name); - const display = rule ? maskValue(col.maxValue, rule) : col.maxValue.substring(0, 30); + const rule = sensitiveColumnNames.get( + col.name, + ); + + const display = rule + ? maskValue(col.maxValue, rule) + : col.maxValue.substring(0, 30); + return ( max:{" "} - + {display} @@ -347,25 +611,33 @@ export function DataProfiler({
{/* Sample Values */} - {col.sampleValues && col.sampleValues.length > 0 && ( -
- {col.sampleValues.map((val, i) => { - const rule = sensitiveColumnNames.get(col.name); - const display = rule ? maskValue(val, rule) : val.substring(0, 20); - return ( - - {display} - - ); - })} -
- )} + {col.sampleValues && + col.sampleValues.length > 0 && ( +
+ {col.sampleValues.map((val, i) => { + const rule = + sensitiveColumnNames.get(col.name); + + const display = rule + ? maskValue(val, rule) + : val.substring(0, 20); + + return ( + + {display} + + ); + })} +
+ )} )}
@@ -376,12 +648,27 @@ export function DataProfiler({ {(aiSummary || isAiLoading) && (
- - AI Analysis - {isAiLoading && } + + + + AI Analysis + + + {isAiLoading && ( + + )}
+ {aiSummary && ( -
{aiSummary}
+
+ {aiSummary} +
)}
)} @@ -391,4 +678,4 @@ export function DataProfiler({
); -} +} \ No newline at end of file diff --git a/tests/components/DataProfiler.test.tsx b/tests/components/DataProfiler.test.tsx index 5c17ad49..dd5a07e0 100644 --- a/tests/components/DataProfiler.test.tsx +++ b/tests/components/DataProfiler.test.tsx @@ -814,3 +814,68 @@ describe("DataProfiler", () => { expect(body.tableName).toBe("users"); }); }); + // ── Data profile export ──────────────────────────────────────────────────── + + test("shows CSV and JSON export options after profile loads", async () => { + const props = createDefaultProps(); + const { container } = render(); + const view = within(container); + + await waitFor(() => { + expect(view.queryByText("Export")).not.toBeNull(); + }); + + fireEvent.click(view.getByText("Export")); + + expect(view.queryByText("Export as CSV")).not.toBeNull(); + expect(view.queryByText("Export as JSON")).not.toBeNull(); + }); + + test("downloads the data profile as CSV", async () => { + const createObjectURL = mock(() => "blob:test"); + const revokeObjectURL = mock(() => {}); + + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: createObjectURL, + }); + + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: revokeObjectURL, + }); + + const clickSpy = mock(() => {}); + const originalCreateElement = document.createElement.bind(document); + + const createElementSpy = mock((tagName: string) => { + const element = originalCreateElement(tagName); + + if (tagName.toLowerCase() === "a") { + Object.defineProperty(element, "click", { + configurable: true, + value: clickSpy, + }); + } + + return element; + }); + + document.createElement = createElementSpy as typeof document.createElement; + + const props = createDefaultProps(); + const { container } = render(); + const view = within(container); + + await waitFor(() => { + expect(view.queryByText("Export")).not.toBeNull(); + }); + + fireEvent.click(view.getByText("Export")); + fireEvent.click(view.getByText("Export as CSV")); + + expect(createObjectURL).toHaveBeenCalledTimes(1); + expect(clickSpy).toHaveBeenCalledTimes(1); + + document.createElement = originalCreateElement; + }); From bff3a53c18c53b7c9c11e8dd1f091785fa9f0852 Mon Sep 17 00:00:00 2001 From: na12334 Date: Thu, 10 Sep 2026 11:15:18 +0530 Subject: [PATCH 2/4] address review feedback --- src/components/DataProfiler.tsx | 338 ++++++++------------------------ 1 file changed, 79 insertions(+), 259 deletions(-) diff --git a/src/components/DataProfiler.tsx b/src/components/DataProfiler.tsx index 6a959a97..3d45254e 100644 --- a/src/components/DataProfiler.tsx +++ b/src/components/DataProfiler.tsx @@ -2,16 +2,7 @@ import { appFetch } from "@/lib/config/base-path"; import { useState, useEffect, useMemo } from "react"; -import { - LoaderCircle, - ChartColumn, - X, - Hash, - CircleAlert, - Sparkles, - Lock, - Download, -} from "lucide-react"; +import { LoaderCircle, ChartColumn, X, Hash, CircleAlert, Sparkles, Lock, Download } from "lucide-react"; import { cn } from "@/lib/utils"; import { TableSchema, DatabaseConnection } from "@/lib/types"; import { detectSensitiveColumns, maskValue } from "@/lib/data-masking"; @@ -54,15 +45,9 @@ interface DataProfilerProps { schemaContext?: string; databaseType?: string; /** Optional API adapter: when provided, bypasses the built-in /api/db/profile fetch. */ - onProfile?: (params: { - connectionId: string; - tableName: string; - }) => Promise; + onProfile?: (params: { connectionId: string; tableName: string }) => Promise; /** Optional API adapter: when provided, bypasses the built-in /api/ai/describe-schema fetch. */ - onDescribeSchema?: (params: { - tableName: string; - schemaContext: string; - }) => Promise; + onDescribeSchema?: (params: { tableName: string; schemaContext: string }) => Promise; } export function DataProfiler({ @@ -98,18 +83,9 @@ export function DataProfiler({ nullCount: col.nullCount, nullPercent: col.nullPercent, distinctCount: col.distinctCount, - minValue: - col.minValue && rule - ? maskValue(col.minValue, rule) - : col.minValue || "", - maxValue: - col.maxValue && rule - ? maskValue(col.maxValue, rule) - : col.maxValue || "", - sampleValues: - col.sampleValues?.map((value) => - rule ? maskValue(value, rule) : value, - ) || [], + minValue: col.minValue && rule ? maskValue(col.minValue, rule) : col.minValue || "", + maxValue: col.maxValue && rule ? maskValue(col.maxValue, rule) : col.maxValue || "", + sampleValues: col.sampleValues?.map((value) => (rule ? maskValue(value, rule) : value)) || [], error: col.error || "", }; }; @@ -118,8 +94,7 @@ export function DataProfiler({ if (!profile) return; const exportedColumns = profile.columns.map(getExportColumn); - const safeTableName = - profile.tableName.replace(/[^a-zA-Z0-9_-]/g, "_") || "table"; + const safeTableName = profile.tableName.replace(/[^a-zA-Z0-9_-]/g, "_") || "table"; const fileName = `data_profile_${safeTableName}_${Date.now()}.${format}`; if (format === "csv") { @@ -151,27 +126,27 @@ export function DataProfiler({ ]), ); - const content = [csvRow(headers), ...rows].join("\n"); - downloadText(content, "text/csv", fileName); + downloadText([csvRow(headers), ...rows].join("\n"), "text/csv", fileName); return; } - const content = JSON.stringify( - { - tableName: profile.tableName, - totalRows: profile.totalRows, - columns: exportedColumns, - }, - null, - 2, + downloadText( + JSON.stringify( + { + tableName: profile.tableName, + totalRows: profile.totalRows, + columns: exportedColumns, + }, + null, + 2, + ), + "application/json", + fileName, ); - - downloadText(content, "application/json", fileName); }; const fetchAiSummary = async (data: ProfileData) => { setIsAiLoading(true); - try { const profileSummary = data.columns .map( @@ -184,10 +159,7 @@ export function DataProfiler({ if (onDescribeSchema) { // Platform adapter: use callback instead of fetch - const result = await onDescribeSchema({ - tableName, - schemaContext: fullSchemaContext, - }); + const result = await onDescribeSchema({ tableName, schemaContext: fullSchemaContext }); setAiSummary(result); } else { // Default: existing fetch behavior @@ -207,12 +179,9 @@ export function DataProfiler({ if (!reader) return; let full = ""; - while (true) { const { done, value } = await reader.read(); - if (done) break; - full += new TextDecoder().decode(value); setAiSummary(full); } @@ -229,7 +198,6 @@ export function DataProfiler({ // earlier than its declaration. Pure code motion - no hook order changes. const fetchProfile = async () => { if (!connection || !tableSchema) return; - setIsLoading(true); setError(null); @@ -238,25 +206,17 @@ export function DataProfiler({ if (onProfile) { // Platform adapter: use callback instead of fetch - data = await onProfile({ - connectionId: connection.id, - tableName, - }); + data = await onProfile({ connectionId: connection.id, tableName }); } else { // Default: existing fetch behavior const columns = tableSchema.columns?.map((c) => c.name) || []; - const response = await appFetch("/api/db/profile", { method: "POST", headers: { "Content-Type": "application/json" }, // The seed id for a managed connection: the browser's copy has had its // password and connection string stripped, so the object cannot be // resolved to a database from a cold provider cache. - body: JSON.stringify({ - ...buildConnectionPayload(connection), - tableName, - columns, - }), + body: JSON.stringify({ ...buildConnectionPayload(connection), tableName, columns }), }); if (!response.ok) { @@ -282,13 +242,11 @@ export function DataProfiler({ if (isOpen && tableName && connection) { fetchProfile(); } - return () => { setProfile(null); setAiSummary(""); setError(null); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen, tableName]); @@ -312,14 +270,11 @@ export function DataProfiler({ */ useEffect(() => { if (!isOpen) return; - const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; onClose(); }; - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); }, [isOpen, onClose]); @@ -338,18 +293,9 @@ export function DataProfiler({ */}
- - - - Data Profiler - - - - {tableName} - + + Data Profiler + {tableName}
@@ -361,29 +307,15 @@ export function DataProfiler({ size="sm" className="h-7 text-xs font-medium text-fg-tertiary hover:text-fg-bright gap-1.5" > - + Export - - - exportProfile("csv")} - className="text-xs cursor-pointer" - > + + exportProfile("csv")} className="text-xs cursor-pointer"> Export as CSV - - exportProfile("json")} - className="text-xs cursor-pointer" - > + exportProfile("json")} className="text-xs cursor-pointer"> Export as JSON @@ -395,10 +327,7 @@ export function DataProfiler({ aria-label="Close data profiler" className="shrink-0 p-1 rounded hover:bg-fill text-fg-muted" > - +
@@ -407,24 +336,14 @@ export function DataProfiler({
{isLoading && (
- - - - Profiling {tableName}... - + + Profiling {tableName}...
)} {error && (
- - + {error}
)} @@ -434,38 +353,18 @@ export function DataProfiler({ {/* Summary Stats */}
-

- Total Rows -

- -

- {profile.totalRows.toLocaleString()} -

+

Total Rows

+

{profile.totalRows.toLocaleString()}

-
-

- Columns -

- -

- {profile.columns.length} -

+

Columns

+

{profile.columns.length}

-
-

- Avg Null % -

- +

Avg Null %

{profile.columns.length > 0 - ? Math.round( - profile.columns.reduce( - (sum, c) => sum + c.nullPercent, - 0, - ) / profile.columns.length, - ) + ? Math.round(profile.columns.reduce((sum, c) => sum + c.nullPercent, 0) / profile.columns.length) : 0} %

@@ -474,51 +373,25 @@ export function DataProfiler({ {/* Column Profiles */}
-

- Column Profiles -

- +

Column Profiles

{profile.columns.map((col) => ( -
+
- - - - {col.name} - - - {col.type && ( - - {col.type} - - )} - + + {col.name} + {col.type && {col.type}} {sensitiveColumnNames.has(col.name) && ( - + )}
- - - {col.distinctCount.toLocaleString()} distinct - + {col.distinctCount.toLocaleString()} distinct
{col.error ? ( -

- {col.error} -

+

{col.error}

) : ( <> {/* Null bar */} @@ -533,12 +406,9 @@ export function DataProfiler({ ? "bg-warning-tint" : "bg-success-tint", )} - style={{ - width: `${100 - col.nullPercent}%`, - }} + style={{ width: `${100 - col.nullPercent}%` }} />
- {col.minValue && (() => { - const rule = sensitiveColumnNames.get( - col.name, - ); - - const display = rule - ? maskValue(col.minValue, rule) - : col.minValue.substring(0, 30); - + const rule = sensitiveColumnNames.get(col.name); + const display = rule ? maskValue(col.minValue, rule) : col.minValue.substring(0, 30); return ( min:{" "} - + {display} ); })()} - {col.maxValue && (() => { - const rule = sensitiveColumnNames.get( - col.name, - ); - - const display = rule - ? maskValue(col.maxValue, rule) - : col.maxValue.substring(0, 30); - + const rule = sensitiveColumnNames.get(col.name); + const display = rule ? maskValue(col.maxValue, rule) : col.maxValue.substring(0, 30); return ( max:{" "} - + {display} @@ -611,33 +454,25 @@ export function DataProfiler({
{/* Sample Values */} - {col.sampleValues && - col.sampleValues.length > 0 && ( -
- {col.sampleValues.map((val, i) => { - const rule = - sensitiveColumnNames.get(col.name); - - const display = rule - ? maskValue(val, rule) - : val.substring(0, 20); - - return ( - - {display} - - ); - })} -
- )} + {col.sampleValues && col.sampleValues.length > 0 && ( +
+ {col.sampleValues.map((val, i) => { + const rule = sensitiveColumnNames.get(col.name); + const display = rule ? maskValue(val, rule) : val.substring(0, 20); + return ( + + {display} + + ); + })} +
+ )} )}
@@ -648,27 +483,12 @@ export function DataProfiler({ {(aiSummary || isAiLoading) && (
- - - - AI Analysis - - - {isAiLoading && ( - - )} + + AI Analysis + {isAiLoading && }
- {aiSummary && ( -
- {aiSummary} -
+
{aiSummary}
)}
)} @@ -678,4 +498,4 @@ export function DataProfiler({
); -} \ No newline at end of file +} From bb71deb6046c261254b585519cc7bb0b1d35d617 Mon Sep 17 00:00:00 2001 From: na12334 Date: Thu, 10 Sep 2026 11:18:26 +0530 Subject: [PATCH 3/4] address Data Profiler export tests --- tests/components/DataProfiler.test.tsx | 179 ++++++++++++++++++------- 1 file changed, 131 insertions(+), 48 deletions(-) diff --git a/tests/components/DataProfiler.test.tsx b/tests/components/DataProfiler.test.tsx index dd5a07e0..cbc75a28 100644 --- a/tests/components/DataProfiler.test.tsx +++ b/tests/components/DataProfiler.test.tsx @@ -12,6 +12,7 @@ mock.module("@/lib/data-masking", () => ({ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { render, fireEvent, within, waitFor, cleanup } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import React from "react"; import { mockGlobalFetch, restoreGlobalFetch, type MockFetchResponse } from "../helpers/mock-fetch"; @@ -813,69 +814,151 @@ describe("DataProfiler", () => { expect(body.connection).toBeUndefined(); expect(body.tableName).toBe("users"); }); -}); - // ── Data profile export ──────────────────────────────────────────────────── - - test("shows CSV and JSON export options after profile loads", async () => { - const props = createDefaultProps(); - const { container } = render(); - const view = within(container); - - await waitFor(() => { - expect(view.queryByText("Export")).not.toBeNull(); - }); - - fireEvent.click(view.getByText("Export")); - expect(view.queryByText("Export as CSV")).not.toBeNull(); - expect(view.queryByText("Export as JSON")).not.toBeNull(); - }); - - test("downloads the data profile as CSV", async () => { - const createObjectURL = mock(() => "blob:test"); - const revokeObjectURL = mock(() => {}); + // ── Data profile export ─────────────────────────────────────────────────── - Object.defineProperty(URL, "createObjectURL", { - configurable: true, - value: createObjectURL, - }); + test("exports the data profile as CSV with sensitive values masked", async () => { + const user = userEvent.setup(); + const mockRule = { pattern: /email/i, label: "Email", mask: (v: string) => v }; - Object.defineProperty(URL, "revokeObjectURL", { - configurable: true, - value: revokeObjectURL, - }); + (detectSensitiveColumns as ReturnType).mockImplementation( + () => new Map([["email", mockRule]]), + ); + (maskValue as ReturnType).mockImplementation(() => "****"); - const clickSpy = mock(() => {}); + const createObjectURLMock = mock(() => "blob:profile-csv"); + const clickMock = mock(() => {}); const originalCreateElement = document.createElement.bind(document); + const originalCreateObjectURL = URL.createObjectURL; + const originalRevokeObjectURL = URL.revokeObjectURL; - const createElementSpy = mock((tagName: string) => { + URL.createObjectURL = createObjectURLMock; + URL.revokeObjectURL = mock(() => {}); + + document.createElement = mock((tagName: string) => { const element = originalCreateElement(tagName); if (tagName.toLowerCase() === "a") { - Object.defineProperty(element, "click", { - configurable: true, - value: clickSpy, - }); + element.click = clickMock; } return element; - }); - - document.createElement = createElementSpy as typeof document.createElement; + }) as unknown as typeof document.createElement; + + try { + const { container } = render(); + + await waitFor(() => { + expect(within(container).queryByText("Export")).not.toBeNull(); + }); + + await user.click(within(container).getByText("Export")); + await user.click(within(document.body).getByText("Export as CSV")); + + expect(createObjectURLMock).toHaveBeenCalledTimes(1); + expect(clickMock).toHaveBeenCalledTimes(1); + + const blob = createObjectURLMock.mock.calls[0][0] as Blob; + const csv = await blob.text(); + + expect(csv).toBe( + "\uFEFFColumn,Type,Total Rows,Null Count,Null %,Distinct Count,Min,Max,Sample Values,Error\n" + + "id,integer,100,0,0,100,1,100,1 | 2 | 3,\n" + + "name,varchar(255),100,5,5,90,Alice,Zara,Alice | Bob | Carol,\n" + + "email,varchar(255),100,0,0,100,****,****,**** | ****,", + ); + } finally { + document.createElement = originalCreateElement; + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + (detectSensitiveColumns as ReturnType).mockImplementation(() => new Map()); + (maskValue as ReturnType).mockImplementation(() => "****"); + } + }); - const props = createDefaultProps(); - const { container } = render(); - const view = within(container); + test("exports the data profile as JSON", async () => { + const user = userEvent.setup(); + const createObjectURLMock = mock(() => "blob:profile-json"); + const clickMock = mock(() => {}); + const originalCreateElement = document.createElement.bind(document); + const originalCreateObjectURL = URL.createObjectURL; + const originalRevokeObjectURL = URL.revokeObjectURL; - await waitFor(() => { - expect(view.queryByText("Export")).not.toBeNull(); - }); + URL.createObjectURL = createObjectURLMock; + URL.revokeObjectURL = mock(() => {}); - fireEvent.click(view.getByText("Export")); - fireEvent.click(view.getByText("Export as CSV")); + document.createElement = mock((tagName: string) => { + const element = originalCreateElement(tagName); - expect(createObjectURL).toHaveBeenCalledTimes(1); - expect(clickSpy).toHaveBeenCalledTimes(1); + if (tagName.toLowerCase() === "a") { + element.click = clickMock; + } - document.createElement = originalCreateElement; + return element; + }) as unknown as typeof document.createElement; + + try { + const { container } = render(); + + await waitFor(() => { + expect(within(container).queryByText("Export")).not.toBeNull(); + }); + + await user.click(within(container).getByText("Export")); + await user.click(within(document.body).getByText("Export as JSON")); + + expect(createObjectURLMock).toHaveBeenCalledTimes(1); + expect(clickMock).toHaveBeenCalledTimes(1); + + const blob = createObjectURLMock.mock.calls[0][0] as Blob; + const json = JSON.parse(await blob.text()); + + expect(json).toEqual({ + tableName: "users", + totalRows: 100, + columns: [ + { + name: "id", + type: "integer", + totalRows: 100, + nullCount: 0, + nullPercent: 0, + distinctCount: 100, + minValue: "1", + maxValue: "100", + sampleValues: ["1", "2", "3"], + error: "", + }, + { + name: "name", + type: "varchar(255)", + totalRows: 100, + nullCount: 5, + nullPercent: 5, + distinctCount: 90, + minValue: "Alice", + maxValue: "Zara", + sampleValues: ["Alice", "Bob", "Carol"], + error: "", + }, + { + name: "email", + type: "varchar(255)", + totalRows: 100, + nullCount: 0, + nullPercent: 0, + distinctCount: 100, + minValue: "alice@example.com", + maxValue: "zara@example.com", + sampleValues: ["alice@example.com", "bob@example.com"], + error: "", + }, + ], + }); + } finally { + document.createElement = originalCreateElement; + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + } }); +}); From 2ae615d65bee892f4f0f6e7c8a956e42b203c8cd Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 10 Sep 2026 20:08:02 +0300 Subject: [PATCH 4/4] test(profiler): move the export text into src/lib/export and pin it there Three jobs were red for two causes, both in the test file. Biome formatting on one wrapped `mockImplementation` call, and `mock.calls[0][0]` typed `[]` because the `URL.createObjectURL` double declared no parameters; the second is what `Engine Smoke - Build Payload` died on too, since `next build` type-checks `tests/` as well. The expected CSV also carried a leading BOM. `downloadText` does write one for `text/csv`, but `blob.text()` is a UTF-8 decode and strips it, which `tests/unit/lib/export/download.test.ts:92` already records. Building the text now lives in `src/lib/export/data-profile.ts`, the shape #740 established for the query-history export, and goes through `jsonText` instead of `JSON.stringify`, so the one JSON serializer stays the one. Its headers, column order, escaping and masking are asserted there as plain strings, and the two component tests keep only the wiring: that each menu item exports in its own format, and that the masking map the screen uses is the one handed to the export. --- src/components/DataProfiler.tsx | 87 +--------- src/lib/export/data-profile.ts | 118 +++++++++++++ tests/components/DataProfiler.test.tsx | 191 +++++++++------------ tests/unit/lib/export/data-profile.test.ts | 110 ++++++++++++ 4 files changed, 311 insertions(+), 195 deletions(-) create mode 100644 src/lib/export/data-profile.ts create mode 100644 tests/unit/lib/export/data-profile.test.ts diff --git a/src/components/DataProfiler.tsx b/src/components/DataProfiler.tsx index 3d45254e..17cc68e6 100644 --- a/src/components/DataProfiler.tsx +++ b/src/components/DataProfiler.tsx @@ -7,7 +7,7 @@ import { cn } from "@/lib/utils"; import { TableSchema, DatabaseConnection } from "@/lib/types"; import { detectSensitiveColumns, maskValue } from "@/lib/data-masking"; import { buildConnectionPayload } from "@/hooks/use-connection-payload"; -import { csvRow } from "@/lib/export/csv"; +import { dataProfileText, type ColumnProfile, type ProfileData } from "@/lib/export/data-profile"; import { downloadText } from "@/lib/export/download"; import { Button } from "./ui/button"; import { @@ -17,25 +17,6 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -interface ColumnProfile { - name: string; - type?: string; - totalRows: number; - nullCount: number; - nullPercent: number; - distinctCount: number; - minValue?: string; - maxValue?: string; - sampleValues?: string[]; - error?: string; -} - -interface ProfileData { - tableName: string; - totalRows: number; - columns: ColumnProfile[]; -} - interface DataProfilerProps { isOpen: boolean; onClose: () => void; @@ -73,75 +54,15 @@ export function DataProfiler({ return detectSensitiveColumns(tableSchema.columns.map((c) => c.name)); }, [tableSchema]); - const getExportColumn = (col: ColumnProfile) => { - const rule = sensitiveColumnNames.get(col.name); - - return { - name: col.name, - type: col.type || "", - totalRows: col.totalRows, - nullCount: col.nullCount, - nullPercent: col.nullPercent, - distinctCount: col.distinctCount, - minValue: col.minValue && rule ? maskValue(col.minValue, rule) : col.minValue || "", - maxValue: col.maxValue && rule ? maskValue(col.maxValue, rule) : col.maxValue || "", - sampleValues: col.sampleValues?.map((value) => (rule ? maskValue(value, rule) : value)) || [], - error: col.error || "", - }; - }; - const exportProfile = (format: "csv" | "json") => { if (!profile) return; - const exportedColumns = profile.columns.map(getExportColumn); const safeTableName = profile.tableName.replace(/[^a-zA-Z0-9_-]/g, "_") || "table"; - const fileName = `data_profile_${safeTableName}_${Date.now()}.${format}`; - - if (format === "csv") { - const headers = [ - "Column", - "Type", - "Total Rows", - "Null Count", - "Null %", - "Distinct Count", - "Min", - "Max", - "Sample Values", - "Error", - ]; - - const rows = exportedColumns.map((col) => - csvRow([ - col.name, - col.type, - col.totalRows, - col.nullCount, - col.nullPercent, - col.distinctCount, - col.minValue, - col.maxValue, - col.sampleValues.join(" | "), - col.error, - ]), - ); - - downloadText([csvRow(headers), ...rows].join("\n"), "text/csv", fileName); - return; - } downloadText( - JSON.stringify( - { - tableName: profile.tableName, - totalRows: profile.totalRows, - columns: exportedColumns, - }, - null, - 2, - ), - "application/json", - fileName, + dataProfileText(profile, sensitiveColumnNames, format), + format === "csv" ? "text/csv" : "application/json", + `data_profile_${safeTableName}_${Date.now()}.${format}`, ); }; diff --git a/src/lib/export/data-profile.ts b/src/lib/export/data-profile.ts new file mode 100644 index 00000000..f7e84432 --- /dev/null +++ b/src/lib/export/data-profile.ts @@ -0,0 +1,118 @@ +/** + * The text a Data Profiler download carries, for both formats. + * + * Separate from the component for the same reason `query-history.ts` is: the part + * worth pinning is what gets WRITTEN, and asserting that through a component means + * a portal, a stubbed `document.createElement` and a blob read before the first + * character can be checked. Here the headers, the column order, the escaping and + * the masking are all ordinary string assertions. + * + * The masking is the reason this file is not a formatting detail. The profiler + * shows `MIN`, `MAX` and five sample values per column, so a profile of a `users` + * table holds real addresses; the screen masks them for a sensitive column and an + * export that did not would hand out what the UI is careful to hide. Every path + * out of here masks the same three fields the component renders masked. + */ + +import { maskValue, type MaskingRule } from "@/lib/data-masking"; +import { csvRow } from "./csv"; +import { jsonText } from "./json"; + +/** One column's statistics, as `/api/db/profile` returns them. */ +export interface ColumnProfile { + name: string; + type?: string; + totalRows: number; + nullCount: number; + nullPercent: number; + distinctCount: number; + minValue?: string; + maxValue?: string; + sampleValues?: string[]; + error?: string; +} + +/** A whole profiling run for one table. */ +export interface ProfileData { + tableName: string; + totalRows: number; + columns: ColumnProfile[]; +} + +/** What separates the sample values inside their single cell. */ +const SAMPLE_SEPARATOR = " | "; + +const HEADERS = [ + "Column", + "Type", + "Total Rows", + "Null Count", + "Null %", + "Distinct Count", + "Min", + "Max", + "Sample Values", + "Error", +]; + +/** + * `column` with its sensitive values masked and its absent ones written as empty. + * + * Absent stays empty rather than becoming the mask: a column with no `MIN` has + * nothing to hide, and `maskValue` answers `NULL` for an absent value, which reads + * back as a column that genuinely holds that word. + */ +function exportedColumn(column: ColumnProfile, rule: MaskingRule | undefined): Required { + return { + name: column.name, + type: column.type || "", + totalRows: column.totalRows, + nullCount: column.nullCount, + nullPercent: column.nullPercent, + distinctCount: column.distinctCount, + minValue: column.minValue && rule ? maskValue(column.minValue, rule) : column.minValue || "", + maxValue: column.maxValue && rule ? maskValue(column.maxValue, rule) : column.maxValue || "", + sampleValues: column.sampleValues?.map((value) => (rule ? maskValue(value, rule) : value)) || [], + error: column.error || "", + }; +} + +/** + * `profile` as the text of a `format` download. + * + * `sensitive` is the map the component already computed for the screen, passed in + * rather than recomputed here, so the file cannot mask a different set of columns + * from the one the user was shown. + */ +export function dataProfileText( + profile: ProfileData, + sensitive: ReadonlyMap, + format: "csv" | "json", +): string { + const columns = profile.columns.map((column) => exportedColumn(column, sensitive.get(column.name))); + + // Through `jsonText`, which is what every other export writes JSON with: a bigint + // or a cycle takes a bare `JSON.stringify` down from inside the click handler, with + // no file and nothing in the UI to say why. Today's fields cannot hold either, and + // a field added later must not be able to. + if (format === "json") { + return jsonText({ tableName: profile.tableName, totalRows: profile.totalRows, columns }, 2); + } + + const rows = columns.map((column) => + csvRow([ + column.name, + column.type, + column.totalRows, + column.nullCount, + column.nullPercent, + column.distinctCount, + column.minValue, + column.maxValue, + column.sampleValues.join(SAMPLE_SEPARATOR), + column.error, + ]), + ); + + return [csvRow(HEADERS), ...rows].join("\n"); +} diff --git a/tests/components/DataProfiler.test.tsx b/tests/components/DataProfiler.test.tsx index cbc75a28..ff6e0a39 100644 --- a/tests/components/DataProfiler.test.tsx +++ b/tests/components/DataProfiler.test.tsx @@ -816,149 +816,116 @@ describe("DataProfiler", () => { }); // ── Data profile export ─────────────────────────────────────────────────── + // + // What gets WRITTEN is asserted in tests/unit/lib/export/data-profile.test.ts. + // These two cover the wiring only: that the menu reaches the export with the + // format the item names, and that the masking map the screen uses is the one + // handed to it. + // + // The download itself is taken through the real `downloadText`, so the stubs + // below are `URL` and `document.createElement`. `mock.module` would be the + // shorter route and is the wrong one here: it is process-wide, and this file + // shares its process with QueryHistory's own download test. + + interface CapturedDownload { + blob: Blob; + fileName: string; + restore: () => void; + } - test("exports the data profile as CSV with sensitive values masked", async () => { - const user = userEvent.setup(); - const mockRule = { pattern: /email/i, label: "Email", mask: (v: string) => v }; - - (detectSensitiveColumns as ReturnType).mockImplementation( - () => new Map([["email", mockRule]]), - ); - (maskValue as ReturnType).mockImplementation(() => "****"); - - const createObjectURLMock = mock(() => "blob:profile-csv"); - const clickMock = mock(() => {}); + function captureDownload(): CapturedDownload { + const captured = { blob: new Blob([]), fileName: "" }; const originalCreateElement = document.createElement.bind(document); const originalCreateObjectURL = URL.createObjectURL; const originalRevokeObjectURL = URL.revokeObjectURL; - URL.createObjectURL = createObjectURLMock; + URL.createObjectURL = mock((blob: Blob) => { + captured.blob = blob; + return "blob:data-profile"; + }); URL.revokeObjectURL = mock(() => {}); document.createElement = mock((tagName: string) => { const element = originalCreateElement(tagName); if (tagName.toLowerCase() === "a") { - element.click = clickMock; + element.click = mock(() => { + captured.fileName = (element as HTMLAnchorElement).download; + }); } return element; }) as unknown as typeof document.createElement; - try { - const { container } = render(); + return { + get blob() { + return captured.blob; + }, + get fileName() { + return captured.fileName; + }, + restore() { + document.createElement = originalCreateElement; + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + }, + }; + } - await waitFor(() => { - expect(within(container).queryByText("Export")).not.toBeNull(); - }); + async function clickExport(item: string) { + const user = userEvent.setup(); + const { container } = render(); - await user.click(within(container).getByText("Export")); - await user.click(within(document.body).getByText("Export as CSV")); + await waitFor(() => { + expect(within(container).queryByText("Export")).not.toBeNull(); + }); - expect(createObjectURLMock).toHaveBeenCalledTimes(1); - expect(clickMock).toHaveBeenCalledTimes(1); + await user.click(within(container).getByText("Export")); + // Radix renders the menu content in a portal, so it is not inside `container`. + await user.click(within(document.body).getByText(item)); + } - const blob = createObjectURLMock.mock.calls[0][0] as Blob; - const csv = await blob.text(); + test("the CSV item writes the profile, masked, as a CSV named after the table", async () => { + const download = captureDownload(); + (detectSensitiveColumns as ReturnType).mockImplementation( + () => new Map([["email", { pattern: /email/i, label: "Email", mask: (v: string) => v }]]), + ); + + try { + await clickExport("Export as CSV"); - expect(csv).toBe( - "\uFEFFColumn,Type,Total Rows,Null Count,Null %,Distinct Count,Min,Max,Sample Values,Error\n" + - "id,integer,100,0,0,100,1,100,1 | 2 | 3,\n" + - "name,varchar(255),100,5,5,90,Alice,Zara,Alice | Bob | Carol,\n" + - "email,varchar(255),100,0,0,100,****,****,**** | ****,", - ); + expect(download.blob.type).toStartWith("text/csv"); + expect(download.fileName).toMatch(/^data_profile_users_\d+\.csv$/); + + const text = await download.blob.text(); + + // One header row and one row per profiled column. + expect(text.split("\n")).toHaveLength(4); + // The masked column reaches the file masked, and the addresses on screen do + // not reach it at all. + expect(text).toContain("email,varchar(255),100,0,0,100,****,****,**** | ****,"); + expect(text).not.toContain("alice@example.com"); } finally { - document.createElement = originalCreateElement; - URL.createObjectURL = originalCreateObjectURL; - URL.revokeObjectURL = originalRevokeObjectURL; + download.restore(); (detectSensitiveColumns as ReturnType).mockImplementation(() => new Map()); - (maskValue as ReturnType).mockImplementation(() => "****"); } }); - test("exports the data profile as JSON", async () => { - const user = userEvent.setup(); - const createObjectURLMock = mock(() => "blob:profile-json"); - const clickMock = mock(() => {}); - const originalCreateElement = document.createElement.bind(document); - const originalCreateObjectURL = URL.createObjectURL; - const originalRevokeObjectURL = URL.revokeObjectURL; - - URL.createObjectURL = createObjectURLMock; - URL.revokeObjectURL = mock(() => {}); + test("the JSON item writes the same profile as JSON", async () => { + const download = captureDownload(); - document.createElement = mock((tagName: string) => { - const element = originalCreateElement(tagName); + try { + await clickExport("Export as JSON"); - if (tagName.toLowerCase() === "a") { - element.click = clickMock; - } + expect(download.blob.type).toStartWith("application/json"); + expect(download.fileName).toMatch(/^data_profile_users_\d+\.json$/); - return element; - }) as unknown as typeof document.createElement; + const written = JSON.parse(await download.blob.text()); - try { - const { container } = render(); - - await waitFor(() => { - expect(within(container).queryByText("Export")).not.toBeNull(); - }); - - await user.click(within(container).getByText("Export")); - await user.click(within(document.body).getByText("Export as JSON")); - - expect(createObjectURLMock).toHaveBeenCalledTimes(1); - expect(clickMock).toHaveBeenCalledTimes(1); - - const blob = createObjectURLMock.mock.calls[0][0] as Blob; - const json = JSON.parse(await blob.text()); - - expect(json).toEqual({ - tableName: "users", - totalRows: 100, - columns: [ - { - name: "id", - type: "integer", - totalRows: 100, - nullCount: 0, - nullPercent: 0, - distinctCount: 100, - minValue: "1", - maxValue: "100", - sampleValues: ["1", "2", "3"], - error: "", - }, - { - name: "name", - type: "varchar(255)", - totalRows: 100, - nullCount: 5, - nullPercent: 5, - distinctCount: 90, - minValue: "Alice", - maxValue: "Zara", - sampleValues: ["Alice", "Bob", "Carol"], - error: "", - }, - { - name: "email", - type: "varchar(255)", - totalRows: 100, - nullCount: 0, - nullPercent: 0, - distinctCount: 100, - minValue: "alice@example.com", - maxValue: "zara@example.com", - sampleValues: ["alice@example.com", "bob@example.com"], - error: "", - }, - ], - }); + expect(written).toMatchObject({ tableName: "users", totalRows: 100 }); + expect(written.columns).toHaveLength(3); } finally { - document.createElement = originalCreateElement; - URL.createObjectURL = originalCreateObjectURL; - URL.revokeObjectURL = originalRevokeObjectURL; + download.restore(); } }); }); diff --git a/tests/unit/lib/export/data-profile.test.ts b/tests/unit/lib/export/data-profile.test.ts new file mode 100644 index 00000000..d219be99 --- /dev/null +++ b/tests/unit/lib/export/data-profile.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test"; +import { dataProfileText, type ColumnProfile, type ProfileData } from "@/lib/export/data-profile"; +import type { MaskingRule } from "@/lib/data-masking"; + +const headers = "Column,Type,Total Rows,Null Count,Null %,Distinct Count,Min,Max,Sample Values,Error"; + +const column: ColumnProfile = { + name: "id", + type: "integer", + totalRows: 100, + nullCount: 0, + nullPercent: 0, + distinctCount: 100, + minValue: "1", + maxValue: "100", + sampleValues: ["1", "2", "3"], +}; + +const profile = (columns: ColumnProfile[]): ProfileData => ({ tableName: "users", totalRows: 100, columns }); + +const emailRule: MaskingRule = { pattern: /email/i, label: "Email", mask: () => "****" }; +const sensitive = new Map([["email", emailRule]]); + +describe("dataProfileText", () => { + test("empty CSV retains all ten headers", () => { + expect(dataProfileText(profile([]), new Map(), "csv")).toBe(headers); + }); + + test("CSV writes one row per column and preserves zero", () => { + expect(dataProfileText(profile([column]), new Map(), "csv")).toBe( + `${headers}\nid,integer,100,0,0,100,1,100,1 | 2 | 3,`, + ); + }); + + test("CSV leaves an absent type, min, max, sample list and error empty", () => { + const bare: ColumnProfile = { name: "notes", totalRows: 7, nullCount: 7, nullPercent: 100, distinctCount: 0 }; + expect(dataProfileText(profile([bare]), new Map(), "csv")).toBe(`${headers}\nnotes,,7,7,100,0,,,,`); + }); + + test("CSV keeps commas, quotes, newlines and Unicode in their original columns", () => { + const awkward: ColumnProfile = { + ...column, + name: 'çağrı,"notu"', + minValue: "a,b", + maxValue: 'say "merhaba"', + sampleValues: ["line\none", "x,y"], + error: "Could not profile this column", + }; + expect(dataProfileText(profile([awkward]), new Map(), "csv")).toBe( + `${headers}\n"çağrı,""notu""",integer,100,0,0,100,"a,b","say ""merhaba""","line\none | x,y",Could not profile this column`, + ); + }); + + test("CSV masks min, max and every sample value of a sensitive column, and only that column", () => { + const email: ColumnProfile = { + ...column, + name: "email", + minValue: "alice@example.com", + maxValue: "zara@example.com", + sampleValues: ["alice@example.com", "bob@example.com"], + }; + expect(dataProfileText(profile([column, email]), sensitive, "csv")).toBe( + `${headers}\nid,integer,100,0,0,100,1,100,1 | 2 | 3,\nemail,integer,100,0,0,100,****,****,**** | ****,`, + ); + }); + + test("an absent min and max stay empty on a sensitive column rather than becoming the mask", () => { + // `maskValue` answers `NULL` for an absent value, which reads back as a column + // that genuinely holds that word. A column with no MIN has nothing to hide. + const empty: ColumnProfile = { name: "email", totalRows: 0, nullCount: 0, nullPercent: 0, distinctCount: 0 }; + expect(dataProfileText(profile([empty]), sensitive, "csv")).toBe(`${headers}\nemail,,0,0,0,0,,,,`); + }); + + test("CSV neutralizes a formula prefix in the column name and in a sample value", () => { + const formula: ColumnProfile = { ...column, name: "=name", sampleValues: ["@cmd"] }; + expect(dataProfileText(profile([formula]), new Map(), "csv")).toBe( + `${headers}\n"'=name",integer,100,0,0,100,1,100,"'@cmd",`, + ); + }); + + test("JSON carries the table, its row count and every column with two-space indentation", () => { + expect(dataProfileText(profile([column]), new Map(), "json")).toBe( + JSON.stringify({ tableName: "users", totalRows: 100, columns: [{ ...column, error: "" }] }, null, 2), + ); + }); + + test("JSON masks the same three fields as the CSV", () => { + const email: ColumnProfile = { ...column, name: "email", minValue: "a@b.co", sampleValues: ["a@b.co"] }; + const written = JSON.parse(dataProfileText(profile([email]), sensitive, "json")) as ProfileData; + expect(written.columns[0].minValue).toBe("****"); + expect(written.columns[0].maxValue).toBe("****"); + expect(written.columns[0].sampleValues).toEqual(["****"]); + }); + + test("JSON writes an absent optional as empty rather than dropping the key", () => { + const bare: ColumnProfile = { name: "notes", totalRows: 7, nullCount: 7, nullPercent: 100, distinctCount: 0 }; + expect(JSON.parse(dataProfileText(profile([bare]), new Map(), "json")).columns[0]).toEqual({ + name: "notes", + type: "", + totalRows: 7, + nullCount: 7, + nullPercent: 100, + distinctCount: 0, + minValue: "", + maxValue: "", + sampleValues: [], + error: "", + }); + }); +});