Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 55 additions & 27 deletions src/components/DataProfiler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -216,13 +218,39 @@ export function DataProfiler({
<span className="text-xs font-medium text-fg shrink-0">Data Profiler</span>
<span className="text-xs text-fg-muted font-mono truncate">{tableName}</span>
</div>
<button
onClick={onClose}
aria-label="Close data profiler"
className="shrink-0 p-1 rounded hover:bg-fill text-fg-muted"
>
<X strokeWidth={1.5} className="w-3.5 h-3.5" />
</button>

<div className="flex shrink-0 items-center gap-1">
{profile && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs font-medium text-fg-tertiary hover:text-fg-bright gap-1.5"
>
<Download strokeWidth={1.5} className="w-3 h-3" />
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-raised border-hairline-strong text-fg-secondary">
<DropdownMenuItem onClick={() => exportProfile("csv")} className="text-xs cursor-pointer">
Export as CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportProfile("json")} className="text-xs cursor-pointer">
Export as JSON
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}

<button
onClick={onClose}
aria-label="Close data profiler"
className="shrink-0 p-1 rounded hover:bg-fill text-fg-muted"
>
<X strokeWidth={1.5} className="w-3.5 h-3.5" />
</button>
</div>
</div>

{/* Content */}
Expand Down
118 changes: 118 additions & 0 deletions src/lib/export/data-profile.ts
Original file line number Diff line number Diff line change
@@ -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<ColumnProfile> {
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<string, MaskingRule>,
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");
}
115 changes: 115 additions & 0 deletions tests/components/DataProfiler.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(<DataProfiler {...createDefaultProps()} />);

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<typeof mock>).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<typeof mock>).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();
}
});
});
Loading
Loading