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
4 changes: 2 additions & 2 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,11 @@ Two components are described below and a claim true of one can be false of the o
See [`providers/neo4j.md`](providers/neo4j.md#56-the-graph-tab).

### 15. Professional Data Export
* **Format Versatility:** Instantly export query result sets to CSV, JSON, SQL `INSERT` statements, or a generated `CREATE TABLE` DDL.
* **Format Versatility:** Instantly export query result sets to CSV, JSON, SQL `INSERT` statements, a generated `CREATE TABLE` DDL, a Markdown table or a standalone HTML table.
* **CSV Delimiters:** Choose comma (default), semicolon or tab in the import preview or result export menu. Changing the import delimiter reparses the preview and retains the header setting and column mappings. Export quoting, formula neutralization and UTF-8 encoding apply to every separator.
* **Import into a new table:** the table name and every column name are quoted in the connection's own style in both the `CREATE TABLE` and the `INSERT`, so a header that is a reserved word (`when`, `order`, `user`), has mixed case or holds a space creates that column as written. A name typed with a dot (`sales.imported`) is a table in that schema.
In the standalone app, an import the database refuses keeps the dialog open on the review step with the database's message, and the file, target and column mapping stay as they were; the dialog closes once the import ran, or when the confirmation dialog takes it over. The embedded workspace of the npm package still closes the dialog whatever the outcome, because its query adapter reports none.
* **Masked Results:** While data masking applies to a result, every export and copy writes the masked text, so the export menu names the masked columns and says so. The grid's column filter and sort read the masked text too. SQL `INSERT` is not offered in that state, because it would store the mask as the column's value; CSV, JSON and DDL stay. When the result carries no declared column types, the DDL export infers a column's type from the masked text, so a masked column can come out as a text type.
* **Masked Results:** While data masking applies to a result, every export and copy writes the masked text, so the export menu names the masked columns and says so. The grid's column filter and sort read the masked text too. SQL `INSERT` is not offered in that state, because it would store the mask as the column's value; CSV, JSON, DDL, Markdown and HTML stay. When the result carries no declared column types, the DDL export infers a column's type from the masked text, so a masked column can come out as a text type.
* **Developer-Ready:** Clean data output optimized for external analysis, reporting, or database migrations.
* **Binary Values:** A `bytea`, `BLOB`, `RAW` or `varbinary` value is written as the `\x` hex the grid shows (`\xdeadbeef00ff`) in the CSV and the JSON export and by Copy Row as JSON, and as the dialect's own binary literal in a SQL `INSERT`.
* **Replayable SQL:** The SQL `INSERT` form reads each column's declared type and writes arrays, maps, structs, tuples, intervals, wide integers and typed scalars in the literal the connected engine reads back (`src/lib/export/typed-literals.ts`). A row holding a value the engine has no literal for is replaced by a `-- Row N skipped: column "c" ...` comment instead of a statement that would stop the whole file. Masking turns a value into text, so with masking on a masked array, map, row or tuple cell is skipped this way on Trino and Cassandra, and on the other engines is written as the masked text, which the engine refuses on replay. The statement targets the one table the producing `SELECT` read, when it read exactly one, and otherwise the tab's title.
Expand Down
23 changes: 16 additions & 7 deletions src/components/Studio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
FALLBACK_TABLE_NAME,
resultExportFileName,
type ResultExportFormat,
type ResultExportSource,
} from "@/lib/export/result-export";
import { downloadText } from "@/lib/export/download";
import { writeToClipboard } from "@/components/copy-button";
Expand Down Expand Up @@ -845,8 +846,8 @@ export default function Studio() {
* `currentTab.result` wrote rows nobody was looking at. That is why the menu used to
* be hidden over a hydrated view instead of retargeted.
*/
const buildResultFile = useCallback(
(format: ResultExportFormat, hydrated: AgentArtifactHydration | null, csvDelimiter?: CsvDelimiter) => {
const buildExportSource = useCallback(
(hydrated: AgentArtifactHydration | null): ResultExportSource | null => {
const source = hydrated?.result ?? tabMgr.currentTab.result;
if (!source) return null;
// The columns the engine declared for THIS result. The writers read every row by
Expand All @@ -856,8 +857,7 @@ export default function Studio() {
const fields = source.fields;
const sensitiveColumns = detectSensitiveColumnsFromConfig(fields, maskingConfig);
const rows = effectiveMasking ? applyMaskingToRows(source.rows, fields, sensitiveColumns) : source.rows;

return buildResultExport(format, {
return {
rows,
fields,
// A run's rows did not come from this tab, so the SQL forms take the neutral
Expand All @@ -871,8 +871,7 @@ export default function Studio() {
// The types the engine declared for THIS result, which is what the DDL form
// writes when they are there — the only source for a computed column.
columnTypes: source.columnTypes,
csvDelimiter,
});
};
},
[
tabMgr.currentTab.result,
Expand All @@ -884,11 +883,21 @@ export default function Studio() {
],
);

const buildResultFile = useCallback(
(format: ResultExportFormat, hydrated: AgentArtifactHydration | null, csvDelimiter?: CsvDelimiter) => {
const source = buildExportSource(hydrated);
if (source === null) return null;
return buildResultExport(format, { ...source, csvDelimiter });
},
[buildExportSource],
);

const exportResults = useCallback(
(format: ResultExportFormat, hydrated: AgentArtifactHydration | null = null, csvDelimiter?: CsvDelimiter) => {
const fileName = (extension: string) => resultExportFileName(extension, hydrated?.runId);
const file = buildResultFile(format, hydrated, csvDelimiter);
if (file === null) return;
downloadText(file.content, file.mimeType, resultExportFileName(file.extension, hydrated?.runId));
downloadText(file.content, file.mimeType, fileName(file.extension));
},
[buildResultFile],
);
Expand Down
8 changes: 6 additions & 2 deletions src/components/studio/BottomPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,19 @@ import { offersSqlExport } from "@/lib/db/types";
* drift between two spellings of one list is what put the delimiter options out of
* step with the writers before.
*/
const RESULT_FORMATS: readonly {
export type ResultFormatEntry = {
readonly format: ResultExportFormat;
readonly label: string;
readonly csvDelimiter?: CsvDelimiter;
}[] = [
};

export const RESULT_FORMATS: readonly ResultFormatEntry[] = [
{ format: "csv", label: "CSV" },
{ format: "csv", label: "CSV (semicolon)", csvDelimiter: ";" },
{ format: "csv", label: "CSV (tab)", csvDelimiter: "\t" },
{ format: "json", label: "JSON" },
{ format: "markdown", label: "Markdown" },
{ format: "html", label: "HTML" },
{ format: "sql-insert", label: "SQL INSERT" },
{ format: "sql-ddl", label: "DDL (CREATE TABLE)" },
];
Expand Down
43 changes: 43 additions & 0 deletions src/lib/export/html.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* The one HTML table writer in the studio.
*
* Same value contract as `renderValue` in `csv.ts`, then the cell is escaped for HTML
* text content: `&`, `<`, `>`, `"` and `'` all become entities, in the data cells and in
* the header cells alike. Escaping only `& < >` would be enough for a `<td>`'s text, but
* the `"` and `'` are escaped too so a future move of the value into an attribute cannot
* smuggle a quote, and a header is a cell like any other.
*
* The output is a complete standalone document, not a fragment: it can be saved and
* opened directly, and the embedded stylesheet keeps table borders, the bold header and
* pre-formatted whitespace (so a cell's newlines survive where a Markdown export would
* turn them into `<br>`).
*/

import { cellOf, renderValue, resolveColumns } from "./csv";

// The `&` is kept in a constant and spliced in rather than written as a literal entity,
// so no tooling decodes the entity back into the bare character before it reaches the file.
const ENTITY_MARK = "&";
const HTML_ESCAPES: Readonly<Record<string, string>> = {
"&": `${ENTITY_MARK}amp;`,
"<": `${ENTITY_MARK}lt;`,
">": `${ENTITY_MARK}gt;`,
'"': `${ENTITY_MARK}quot;`,
"'": `${ENTITY_MARK}#39;`,
};

/** One HTML cell — a data cell or a header cell — with the characters a parser would read as markup escaped. */
export function htmlCell(value: unknown): string {
return renderValue(value).replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
}

/** `rows` as a complete standalone HTML document holding one table. */
export function htmlTable(rows: readonly Record<string, unknown>[], columns?: readonly string[]): string {
const header = resolveColumns(rows, columns);
if (header.length === 0) return "";
const head = header.map((column) => `<th>${htmlCell(column)}</th>`).join("");
const body = rows
.map((row) => `<tr>${header.map((column) => `<td>${htmlCell(cellOf(row, column))}</td>`).join("")}</tr>`)
.join("");
return `<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<title>Query results</title>\n<style>\ntable { border-collapse: collapse; }\nth, td { border: 1px solid #999; padding: 4px 8px; text-align: left; vertical-align: top; white-space: pre-wrap; }\nth { font-weight: 600; }\n</style>\n</head>\n<body>\n<table>\n<thead>\n<tr>${head}</tr>\n</thead>\n<tbody>\n${body}\n</tbody>\n</table>\n</body>\n</html>\n`;
}
50 changes: 50 additions & 0 deletions src/lib/export/markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* The one Markdown table writer in the studio.
*
* Same value contract as the CSV writer (`renderValue` in `csv.ts`): absent is empty,
* a date is its ISO text, a binary value is the shared `\x…` hex and a structured value
* is `jsonText`. On top of that contract a Markdown cell has its own grammar to respect:
*
* - `\` first, because it is Markdown's escape character. A cell holding a literal `\|`
* would otherwise look already-escaped to a renderer, so the backslash is doubled
* before the pipe is escaped.
* - `|` next, because an unescaped pipe ends the cell and shifts every column after it.
* - `\n`/`\r` last, one `<br>` per line break (`\r\n` counts as one), because a bare
* newline ends the table row mid-cell.
*
* The header is escaped with the same function as the cells: a header is a cell too,
* and a column name with a pipe in it would otherwise split the table on the first line.
*/

import { cellOf, renderValue, resolveColumns } from "./csv";

/**
* One Markdown cell — a data cell or a header cell — with the characters a table
* renderer would otherwise read as structure escaped.
*/
export function markdownCell(value: unknown): string {
return renderValue(value)
.replace(/\\/g, "\\\\")
.replace(/\|/g, "\\|")
.replace(/\r\n|\r|\n/g, "<br>");
}

/**
* `rows` as a Markdown table: a header row, a separator row, then one row per record.
*
* The separator row is always plain `---`; column alignment (`:---`, `---:`) is
* intentionally not emitted.
*/
export function markdownTable(rows: readonly Record<string, unknown>[], columns?: readonly string[]): string {
const header = resolveColumns(rows, columns);
// A table with no columns has no cells to escape and no separator to write; the empty
// string is the same answer `toCsv` gives for this shape.
if (header.length === 0) return "";
const line = (cells: readonly string[]) => `| ${cells.join(" | ")} |`;
const separator = `| ${header.map(() => "---").join(" | ")} |`;
const lines = [line(header.map(markdownCell)), separator];
for (const row of rows) {
lines.push(line(header.map((column) => markdownCell(cellOf(row, column)))));
}
return lines.join("\n");
}
12 changes: 11 additions & 1 deletion src/lib/export/result-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { isBareIdentifier, quoteIdentifier } from "@/lib/sql/identifier";
import { quoteLiteral } from "@/lib/sql/values";
import { asBytes, binaryText } from "./binary";
import { cellOf, resolveColumns, toCsv, type CsvDelimiter } from "./csv";
import { htmlTable } from "./html";
import { binaryCellsAsHex, jsonText } from "./json";
import { markdownTable } from "./markdown";
import { resolveUpdateTarget } from "@/lib/sql/update-target";
import { cqlFrozenNested, typedLiteral, UnwritableValue } from "./typed-literals";
import { isNonFiniteWord, nonFiniteWord, type NonFiniteWord } from "@/lib/non-finite";
Expand All @@ -18,7 +20,7 @@ import { isNonFiniteWord, nonFiniteWord, type NonFiniteWord } from "@/lib/non-fi
* masking) is decided by the caller and arrives here as `rows`.
*/

export type ResultExportFormat = "csv" | "json" | "sql-insert" | "sql-ddl";
export type ResultExportFormat = "csv" | "json" | "sql-insert" | "sql-ddl" | "markdown" | "html";

export interface ResultExportSource {
/** The rows to write, already masked if the caller masks. */
Expand Down Expand Up @@ -1088,6 +1090,14 @@ export function buildResultExport(format: ResultExportFormat, source: ResultExpo
const { rows, dialect } = source;
const columns = resolveColumns(rows, source.fields);

if (format === "markdown") {
return { content: markdownTable(rows, columns), mimeType: "text/markdown;charset=utf-8", extension: "md" };
}

if (format === "html") {
return { content: htmlTable(rows, columns), mimeType: "text/html;charset=utf-8", extension: "html" };
}

if (format === "json") {
return { content: jsonText(rows.map(binaryCellsAsHex), 2), mimeType: "application/json", extension: "json" };
}
Expand Down
41 changes: 23 additions & 18 deletions src/workspace/StudioWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { useIsMobile } from "@/hooks/use-mobile";
import { ChunkBoundary, ViewLoading } from "@/components/LazyView";
import { lazyRetry } from "@/lib/lazy";
import { editorLanguageForTabType, resolveTabType } from "@/lib/editor/tab-language";
import { buildResultExport, type ResultExportFormat } from "@/lib/export/result-export";
import { buildResultExport, type ResultExportFormat, type ResultExportSource } from "@/lib/export/result-export";
import { writeToClipboard } from "@/components/copy-button";
import { downloadText } from "@/lib/export/download";

Expand Down Expand Up @@ -346,26 +346,31 @@ export function StudioWorkspace({
);

// === Export results (shared writers; this shell applies no masking) ===
const buildExportSource = useCallback((): ResultExportSource | null => {
if (!tabMgr.currentTab.result) return null;
return {
rows: tabMgr.currentTab.result.rows,
fields: tabMgr.currentTab.result.fields,
tabName: tabMgr.currentTab.name,
// The statement that fetched these rows names their table when it reads exactly one (#1386).
query: tabMgr.currentTab.resultQuery,
// Was missing from this callback's dependencies, so a SQL export written
// after the host switched connections quoted its literals for whichever
// engine happened to be active on the first render.
dialect: conn.activeConnection?.type,
// The host's own declared column types (`use-query-adapter` carries them),
// which the DDL form prefers over a type guessed from a value.
columnTypes: tabMgr.currentTab.result.columnTypes,
};
}, [tabMgr.currentTab, conn.activeConnection?.type]);

const buildResultFile = useCallback(
(format: ResultExportFormat, csvDelimiter?: CsvDelimiter) => {
if (!tabMgr.currentTab.result) return null;
return buildResultExport(format, {
rows: tabMgr.currentTab.result.rows,
fields: tabMgr.currentTab.result.fields,
tabName: tabMgr.currentTab.name,
// The statement that fetched these rows names their table when it reads exactly one (#1386).
query: tabMgr.currentTab.resultQuery,
// Was missing from this callback's dependencies, so a SQL export written
// after the host switched connections quoted its literals for whichever
// engine happened to be active on the first render.
dialect: conn.activeConnection?.type,
// The host's own declared column types (`use-query-adapter` carries them),
// which the DDL form prefers over a type guessed from a value.
columnTypes: tabMgr.currentTab.result.columnTypes,
csvDelimiter,
});
const source = buildExportSource();
if (source === null) return null;
return buildResultExport(format, { ...source, csvDelimiter });
},
[tabMgr.currentTab, conn.activeConnection?.type],
[buildExportSource],
);

const exportResults = useCallback(
Expand Down
4 changes: 4 additions & 0 deletions tests/components/studio/bottom-panel-export-formats.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,16 @@ const ALL_ITEMS = [
"Export as CSV (semicolon)",
"Export as CSV (tab)",
"Export as JSON",
"Export as Markdown",
"Export as HTML",
"Export as SQL INSERT",
"Export as DDL (CREATE TABLE)",
"Copy as CSV",
"Copy as CSV (semicolon)",
"Copy as CSV (tab)",
"Copy as JSON",
"Copy as Markdown",
"Copy as HTML",
"Copy as SQL INSERT",
"Copy as DDL (CREATE TABLE)",
];
Expand Down
14 changes: 14 additions & 0 deletions tests/unit/components/bottom-panel-formats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, test, expect } from "bun:test";
import { RESULT_FORMATS } from "@/components/studio/BottomPanel";

describe("RESULT_FORMATS", () => {
test("offers Markdown and HTML alongside the existing formats", () => {
const formats = RESULT_FORMATS.map((entry) => entry.format);
expect(formats).toContain("markdown");
expect(formats).toContain("html");
expect(formats).toContain("csv");
expect(formats).toContain("json");
expect(formats).toContain("sql-insert");
expect(formats).toContain("sql-ddl");
});
});
14 changes: 10 additions & 4 deletions tests/unit/db/etcd/tls-handshake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -898,8 +898,12 @@ const CASES: readonly CaseDefinition[] = [
: {
outcome: "failed",
category: "not-connected",
grpcCode: 14,
text: /Last error: Failed to connect/,
// 14 with "Last error: Failed to connect" where Bun surfaces the TLS alert before the
// call's deadline, 4 with "Deadline exceeded ... Waiting for LB pick" where it does not.
// Which one arrives is Bun's, not ours (#1511 precedent): the loaded Windows CI runner
// reported 4 for both client-certificate refusals while the same file passed on macOS.
grpcCode: [14, 4],
text: /Last error: Failed to connect|Deadline exceeded/,
sentence: noTlsAnswer("bun", `localhost:${ports.alerting}`, false),
},
),
Expand All @@ -925,8 +929,10 @@ const CASES: readonly CaseDefinition[] = [
: {
outcome: "failed",
category: "not-connected",
grpcCode: 14,
text: /Last error: Failed to connect/,
// Same as the no-client-certificate case above: 14 where Bun surfaces the alert in
// time, 4 where the call's deadline lands first on the loaded Windows runner.
grpcCode: [14, 4],
text: /Last error: Failed to connect|Deadline exceeded/,
sentence: noTlsAnswer("bun", `localhost:${ports.alerting}`, true),
},
),
Expand Down
Loading
Loading