{/* 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: "",
+ });
+ });
+});