diff --git a/src/components/DataProfiler.tsx b/src/components/DataProfiler.tsx index 6ac46771..17cc68e6 100644 --- a/src/components/DataProfiler.tsx +++ b/src/components/DataProfiler.tsx @@ -2,30 +2,20 @@ 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"; - -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[]; -} +import { dataProfileText, type ColumnProfile, type ProfileData } from "@/lib/export/data-profile"; +import { downloadText } from "@/lib/export/download"; +import { Button } from "./ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; interface DataProfilerProps { isOpen: boolean; @@ -64,6 +54,18 @@ export function DataProfiler({ return detectSensitiveColumns(tableSchema.columns.map((c) => c.name)); }, [tableSchema]); + const exportProfile = (format: "csv" | "json") => { + if (!profile) return; + + const safeTableName = profile.tableName.replace(/[^a-zA-Z0-9_-]/g, "_") || "table"; + + downloadText( + dataProfileText(profile, sensitiveColumnNames, format), + format === "csv" ? "text/csv" : "application/json", + `data_profile_${safeTableName}_${Date.now()}.${format}`, + ); + }; + const fetchAiSummary = async (data: ProfileData) => { setIsAiLoading(true); try { @@ -216,13 +218,39 @@ export function DataProfiler({ 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 */} 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 5c17ad49..ff6e0a39 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,4 +814,118 @@ describe("DataProfiler", () => { expect(body.connection).toBeUndefined(); expect(body.tableName).toBe("users"); }); + + // ── 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; + } + + 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 = 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 = mock(() => { + captured.fileName = (element as HTMLAnchorElement).download; + }); + } + + return element; + }) as unknown as typeof document.createElement; + + return { + get blob() { + return captured.blob; + }, + get fileName() { + return captured.fileName; + }, + restore() { + document.createElement = originalCreateElement; + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + }, + }; + } + + async function clickExport(item: string) { + const user = userEvent.setup(); + const { container } = render(); + + await waitFor(() => { + expect(within(container).queryByText("Export")).not.toBeNull(); + }); + + 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)); + } + + 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(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 { + download.restore(); + (detectSensitiveColumns as ReturnType).mockImplementation(() => new Map()); + } + }); + + test("the JSON item writes the same profile as JSON", async () => { + const download = captureDownload(); + + try { + await clickExport("Export as JSON"); + + expect(download.blob.type).toStartWith("application/json"); + expect(download.fileName).toMatch(/^data_profile_users_\d+\.json$/); + + const written = JSON.parse(await download.blob.text()); + + expect(written).toMatchObject({ tableName: "users", totalRows: 100 }); + expect(written.columns).toHaveLength(3); + } finally { + 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: "", + }); + }); +});