From a878ec203af8025e30d2a0e6bb2157fe10d1e417 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 9 May 2026 23:11:15 +0800 Subject: [PATCH] feat(data): support connection URLs and XLSX export --- src-tauri/capabilities/default.json | 1 + .../connection/ConnectionDialog.vue | 49 +++- src/components/grid/DataGrid.vue | 108 +++++-- src/i18n/locales/en.ts | 7 + src/i18n/locales/zh-CN.ts | 7 + src/lib/connectionUrl.ts | 141 +++++++++ src/lib/xlsxExport.ts | 275 ++++++++++++++++++ tests/connectionUrl.test.ts | 59 ++++ tests/xlsxExport.test.ts | 35 +++ 9 files changed, 664 insertions(+), 18 deletions(-) create mode 100644 src/lib/connectionUrl.ts create mode 100644 src/lib/xlsxExport.ts create mode 100644 tests/connectionUrl.test.ts create mode 100644 tests/xlsxExport.test.ts diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 3451ced34..c8e9a0ed9 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -21,6 +21,7 @@ "dialog:allow-save", "dialog:allow-open", "fs:default", + "fs:allow-write-file", "fs:allow-write-text-file", "fs:allow-read-text-file", "process:default", diff --git a/src/components/connection/ConnectionDialog.vue b/src/components/connection/ConnectionDialog.vue index e1a073323..f10c63b04 100644 --- a/src/components/connection/ConnectionDialog.vue +++ b/src/components/connection/ConnectionDialog.vue @@ -14,7 +14,8 @@ import { useToast } from "@/composables/useToast"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import * as api from "@/lib/api"; import { isTauriRuntime } from "@/lib/tauriRuntime"; -import { ArrowLeft, ChevronRight, Copy, FolderOpen, Grid3X3, List, Search } from "lucide-vue-next"; +import { applyParsedConnectionUrl, parseConnectionUrl } from "@/lib/connectionUrl"; +import { ArrowLeft, ChevronRight, Copy, FolderOpen, Grid3X3, Link2, List, Search } from "lucide-vue-next"; type DbOption = { value: string; label: string }; type DbCategory = { key: string; title: string; options: DbOption[] }; @@ -72,6 +73,7 @@ const form = ref(defaultForm()); const selectedType = ref("mysql"); const customDriverName = ref(""); const mongoUseUrl = ref(false); +const connectionUrlInput = ref(""); const dialogStep = ref("select"); const dbPickerView = ref("icon"); const dbSearchQuery = ref(""); @@ -445,6 +447,23 @@ function resetTestState() { testResult.value = null; } +function applyConnectionUrl() { + try { + const parsed = parseConnectionUrl(connectionUrlInput.value, selectedType.value); + form.value = applyParsedConnectionUrl(form.value, parsed); + selectedType.value = parsed.driverProfile; + customDriverName.value = isCustomCompatibleProfile() ? parsed.driverLabel : ""; + mongoUseUrl.value = !!parsed.useMongoUrl; + if (!form.value.name.trim()) { + form.value.name = parsed.database || parsed.host || parsed.driverLabel; + } + resetTestState(); + toast(t("connection.parseConnectionUrlApplied"), 2000); + } catch (e: any) { + toast(t("connection.parseConnectionUrlFailed", { message: e?.message || String(e) }), 5000); + } +} + async function copyTestResult() { if (!testResultMessage.value) return; await navigator.clipboard.writeText(testResultMessage.value); @@ -457,6 +476,7 @@ function resetForm() { selectedType.value = "mysql"; customDriverName.value = ""; mongoUseUrl.value = false; + connectionUrlInput.value = ""; dialogStep.value = "select"; dbPickerView.value = "icon"; dbSearchQuery.value = ""; @@ -677,6 +697,33 @@ async function browseDbFilePath() {
+
+ +
+ + + + + + {{ t("connection.parseConnectionUrl") }} + +
+
+
diff --git a/src/components/grid/DataGrid.vue b/src/components/grid/DataGrid.vue index 865beb4b5..489c374cf 100644 --- a/src/components/grid/DataGrid.vue +++ b/src/components/grid/DataGrid.vue @@ -73,6 +73,7 @@ import { import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql"; import { buildDataGridSaveStatements, formatGridSqlLiteral } from "@/lib/dataGridSql"; import { formatMarkdownTable } from "@/lib/markdownTable"; +import { buildXlsxWorkbook } from "@/lib/xlsxExport"; import { matchesRowStatusFilter, rowStatusFilterAfterAddingRow, @@ -1592,7 +1593,12 @@ function copyAll() { copyText(`${header}\n${body}`); } -async function saveFileContent(content: string, defaultFileName: string, filterName: string, filterExt: string) { +async function saveFileContent( + content: string, + defaultFileName: string, + filterName: string, + filterExt: string, +): Promise { if (isTauriRuntime()) { const { save } = await import("@tauri-apps/plugin-dialog"); const { writeTextFile } = await import("@tauri-apps/plugin-fs"); @@ -1600,7 +1606,9 @@ async function saveFileContent(content: string, defaultFileName: string, filterN defaultPath: defaultFileName, filters: [{ name: filterName, extensions: [filterExt] }], }); - if (path) await writeTextFile(path, "" + content); + if (!path) return false; + await writeTextFile(path, "" + content); + return true; } else { const blob = new Blob(["", content], { type: "text/csv;charset=utf-8" }); const url = URL.createObjectURL(blob); @@ -1609,32 +1617,96 @@ async function saveFileContent(content: string, defaultFileName: string, filterN a.download = defaultFileName; a.click(); URL.revokeObjectURL(url); + return true; + } +} + +async function saveBinaryFileContent( + content: Uint8Array, + defaultFileName: string, + filterName: string, + filterExt: string, +): Promise { + if (isTauriRuntime()) { + const { save } = await import("@tauri-apps/plugin-dialog"); + const { writeFile } = await import("@tauri-apps/plugin-fs"); + const path = await save({ + defaultPath: defaultFileName, + filters: [{ name: filterName, extensions: [filterExt] }], + }); + if (!path) return false; + await writeFile(path, content); + return true; + } else { + const blob = new Blob([content], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = defaultFileName; + a.click(); + URL.revokeObjectURL(url); + return true; } } async function exportCsv() { - const escape = (v: string) => `"${v.replace(/"/g, '""')}"`; - const header = props.result.columns.map(escape).join(","); - const body = displayItems.value.map((item) => item.data.map((c) => escape(formatCell(c))).join(",")).join("\n"); - await saveFileContent(`${header}\n${body}`, "export.csv", "CSV", "csv"); + try { + const escape = (v: string) => `"${v.replace(/"/g, '""')}"`; + const header = props.result.columns.map(escape).join(","); + const body = displayItems.value.map((item) => item.data.map((c) => escape(formatCell(c))).join(",")).join("\n"); + if (await saveFileContent(`${header}\n${body}`, "export.csv", "CSV", "csv")) { + toast(t("grid.exported")); + } + } catch (e: any) { + toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); + } } async function exportJson() { - const data = displayItems.value.map((item) => { - const obj: Record = {}; - props.result.columns.forEach((col, i) => { - obj[col] = item.data[i]; + try { + const data = displayItems.value.map((item) => { + const obj: Record = {}; + props.result.columns.forEach((col, i) => { + obj[col] = item.data[i]; + }); + return obj; }); - return obj; - }); - await saveFileContent(JSON.stringify(data, null, 2), "export.json", "JSON", "json"); + if (await saveFileContent(JSON.stringify(data, null, 2), "export.json", "JSON", "json")) { + toast(t("grid.exported")); + } + } catch (e: any) { + toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); + } } async function exportMarkdown() { - const cols = props.result.columns; - const visibleRows = displayItems.value.map((item) => item.data); - const md = formatMarkdownTable({ columns: cols, rows: visibleRows }); - await saveFileContent(md, "export.md", "Markdown", "md"); + try { + const cols = props.result.columns; + const visibleRows = displayItems.value.map((item) => item.data); + const md = formatMarkdownTable({ columns: cols, rows: visibleRows }); + if (await saveFileContent(md, "export.md", "Markdown", "md")) { + toast(t("grid.exported")); + } + } catch (e: any) { + toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); + } +} + +async function exportXlsx() { + try { + const workbook = buildXlsxWorkbook({ + sheetName: props.tableMeta?.tableName || "Export", + columns: props.result.columns, + rows: displayItems.value.map((item) => item.data), + }); + if (await saveBinaryFileContent(workbook, "export.xlsx", "Excel", "xlsx")) { + toast(t("grid.exported")); + } + } catch (e: any) { + toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); + } } const sqlOneLiner = computed(() => props.sql?.replace(/\s+/g, " ").trim() || ""); @@ -2427,6 +2499,7 @@ defineExpose({ {{ t("grid.export") }} {{ t("grid.exportCsv") }} + {{ t("grid.exportXlsx") }} {{ t("grid.exportJson") }} {{ t("grid.exportMarkdown") }} @@ -2497,6 +2570,7 @@ defineExpose({ {{ t("grid.exportCsv") }} + {{ t("grid.exportXlsx") }} {{ t("grid.exportJson") }} {{ t("grid.exportMarkdown") }} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 479ebe7a6..a4c583ca0 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -81,6 +81,10 @@ export default { driverName: "Driver Name", driverNamePlaceholder: "Vendor or environment name", urlParams: "URL Params", + connectionUrlPlaceholder: "postgresql://user:pass@host:5432/db?sslmode=require", + parseConnectionUrl: "Parse connection URL", + parseConnectionUrlApplied: "Connection URL applied", + parseConnectionUrlFailed: "Failed to parse connection URL: {message}", mode: "Connection Mode", modeForm: "Form", searchDatabasePlaceholder: "Search database types", @@ -187,8 +191,11 @@ export default { clearSelection: "Clear Selection", export: "Export", exportCsv: "Export CSV", + exportXlsx: "Export XLSX", exportJson: "Export JSON", exportMarkdown: "Export Markdown", + exported: "Exported", + exportFailed: "Export failed: {message}", filter: "Filter", filterByValue: "Filter by This Value", filterExcludeValue: "Exclude This Value", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index dc9e8c081..d01bb09c1 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -80,6 +80,10 @@ export default { driverName: "驱动名称", driverNamePlaceholder: "厂商或环境名称", urlParams: "URL 参数", + connectionUrlPlaceholder: "postgresql://user:pass@host:5432/db?sslmode=require", + parseConnectionUrl: "解析连接 URL", + parseConnectionUrlApplied: "已应用连接 URL", + parseConnectionUrlFailed: "解析连接 URL 失败:{message}", mode: "连接方式", modeForm: "表单", searchDatabasePlaceholder: "搜索数据库类型", @@ -186,8 +190,11 @@ export default { clearSelection: "清除选区", export: "导出", exportCsv: "导出 CSV", + exportXlsx: "导出 XLSX", exportJson: "导出 JSON", exportMarkdown: "导出 Markdown", + exported: "导出完成", + exportFailed: "导出失败:{message}", filter: "筛选", filterByValue: "筛选此值", filterExcludeValue: "排除此值", diff --git a/src/lib/connectionUrl.ts b/src/lib/connectionUrl.ts new file mode 100644 index 000000000..1b1eb2c83 --- /dev/null +++ b/src/lib/connectionUrl.ts @@ -0,0 +1,141 @@ +import type { ConnectionConfig, DatabaseType } from "@/types/database"; + +export interface ParsedConnectionUrl { + dbType: DatabaseType; + driverProfile: string; + driverLabel: string; + host: string; + port: number; + username: string; + password: string; + database?: string; + urlParams: string; + ssl: boolean; + connectionString?: string; + useMongoUrl?: boolean; +} + +type ConnectionProfile = { + type: DatabaseType; + profile: string; + label: string; + defaultPort: number; +}; + +const SCHEME_PROFILES: Record = { + mysql: { type: "mysql", profile: "mysql", label: "MySQL", defaultPort: 3306 }, + mariadb: { type: "mysql", profile: "mariadb", label: "MariaDB", defaultPort: 3306 }, + postgres: { type: "postgres", profile: "postgres", label: "PostgreSQL", defaultPort: 5432 }, + postgresql: { type: "postgres", profile: "postgres", label: "PostgreSQL", defaultPort: 5432 }, + redshift: { type: "redshift", profile: "redshift", label: "Redshift", defaultPort: 5439 }, + redis: { type: "redis", profile: "redis", label: "Redis", defaultPort: 6379 }, + rediss: { type: "redis", profile: "redis", label: "Redis", defaultPort: 6379 }, + mongodb: { type: "mongodb", profile: "mongodb", label: "MongoDB", defaultPort: 27017 }, + "mongodb+srv": { type: "mongodb", profile: "mongodb", label: "MongoDB", defaultPort: 27017 }, + clickhouse: { type: "clickhouse", profile: "clickhouse", label: "ClickHouse", defaultPort: 8123 }, + sqlserver: { type: "sqlserver", profile: "sqlserver", label: "SQL Server", defaultPort: 1433 }, + mssql: { type: "sqlserver", profile: "sqlserver", label: "SQL Server", defaultPort: 1433 }, + oracle: { type: "oracle", profile: "oracle", label: "Oracle", defaultPort: 1521 }, + elasticsearch: { type: "elasticsearch", profile: "elasticsearch", label: "Elasticsearch", defaultPort: 9200 }, + dm: { type: "dameng", profile: "dm", label: "DM (Dameng)", defaultPort: 5236 }, + dameng: { type: "dameng", profile: "dm", label: "DM (Dameng)", defaultPort: 5236 }, + gaussdb: { type: "gaussdb", profile: "gaussdb", label: "GaussDB", defaultPort: 5432 }, + opengauss: { type: "gaussdb", profile: "opengauss", label: "openGauss", defaultPort: 5432 }, +}; + +const HTTP_SELECTED_PROFILES: Record = { + clickhouse: SCHEME_PROFILES.clickhouse, + elasticsearch: SCHEME_PROFILES.elasticsearch, +}; + +function decodeUrlPart(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function databaseFromPath(pathname: string): string | undefined { + const value = pathname.replace(/^\/+/, ""); + if (!value) return undefined; + return decodeUrlPart(value.split("/")[0]); +} + +function profileForScheme(scheme: string, preferredProfile?: string): ConnectionProfile | undefined { + if ((scheme === "http" || scheme === "https") && preferredProfile) { + return HTTP_SELECTED_PROFILES[preferredProfile]; + } + return SCHEME_PROFILES[scheme]; +} + +export function parseConnectionUrl(value: string, preferredProfile?: string): ParsedConnectionUrl { + const source = value.trim(); + if (!source) { + throw new Error("Connection URL is empty"); + } + + let parsed: URL; + try { + parsed = new URL(source); + } catch { + throw new Error("Invalid connection URL"); + } + + const scheme = parsed.protocol.replace(/:$/, "").toLowerCase(); + const profile = profileForScheme(scheme, preferredProfile); + if (!profile) { + throw new Error(`Unsupported connection URL scheme: ${scheme}`); + } + + const urlParams = parsed.search.replace(/^\?/, ""); + if (profile.type === "mongodb") { + return { + dbType: profile.type, + driverProfile: profile.profile, + driverLabel: profile.label, + host: parsed.hostname, + port: parsed.port ? Number(parsed.port) : profile.defaultPort, + username: decodeUrlPart(parsed.username), + password: decodeUrlPart(parsed.password), + database: databaseFromPath(parsed.pathname), + urlParams, + ssl: scheme === "mongodb+srv", + connectionString: source, + useMongoUrl: true, + }; + } + + return { + dbType: profile.type, + driverProfile: profile.profile, + driverLabel: profile.label, + host: parsed.hostname, + port: parsed.port ? Number(parsed.port) : profile.defaultPort, + username: decodeUrlPart(parsed.username), + password: decodeUrlPart(parsed.password), + database: databaseFromPath(parsed.pathname), + urlParams, + ssl: scheme === "rediss" || scheme === "https", + }; +} + +export function applyParsedConnectionUrl( + config: Omit, + parsed: ParsedConnectionUrl, +): Omit { + return { + ...config, + db_type: parsed.dbType, + driver_profile: parsed.driverProfile, + driver_label: parsed.driverLabel, + host: parsed.host, + port: parsed.port, + username: parsed.username, + password: parsed.password, + database: parsed.database, + url_params: parsed.urlParams, + ssl: parsed.ssl, + connection_string: parsed.connectionString, + }; +} diff --git a/src/lib/xlsxExport.ts b/src/lib/xlsxExport.ts new file mode 100644 index 000000000..e022c5184 --- /dev/null +++ b/src/lib/xlsxExport.ts @@ -0,0 +1,275 @@ +export type XlsxCellValue = string | number | boolean | null | undefined; + +export interface XlsxWorksheetData { + sheetName?: string; + columns: readonly string[]; + rows: readonly (readonly XlsxCellValue[])[]; +} + +type ZipEntry = { + path: string; + data: Uint8Array; + crc: number; + localHeaderOffset: number; +}; + +const encoder = new TextEncoder(); +const CRC_TABLE = buildCrcTable(); + +function buildCrcTable(): number[] { + const table: number[] = []; + for (let i = 0; i < 256; i++) { + let c = i; + for (let j = 0; j < 8; j++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table.push(c >>> 0); + } + return table; +} + +function crc32(data: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of data) { + crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function escapeXml(value: string): string { + return value + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function columnName(index: number): string { + let value = ""; + let n = index + 1; + while (n > 0) { + const rem = (n - 1) % 26; + value = String.fromCharCode(65 + rem) + value; + n = Math.floor((n - 1) / 26); + } + return value; +} + +function cellRef(rowIndex: number, colIndex: number): string { + return `${columnName(colIndex)}${rowIndex + 1}`; +} + +function sheetRange(columnCount: number, rowCount: number): string { + if (columnCount === 0 || rowCount === 0) return "A1"; + return `A1:${columnName(columnCount - 1)}${rowCount}`; +} + +function normalizeSheetName(value?: string): string { + const name = (value || "Sheet1").replace(/[\[\]:*?/\\]/g, " ").trim() || "Sheet1"; + return name.slice(0, 31); +} + +function estimateColumnWidths(columns: readonly string[], rows: readonly (readonly XlsxCellValue[])[]): number[] { + return columns.map((column, colIndex) => { + const values = rows.slice(0, 100).map((row) => row[colIndex]); + const maxLen = [column, ...values.map((value) => (value == null ? "" : String(value)))] + .map((value) => Math.min(value.length, 60)) + .reduce((max, length) => Math.max(max, length), 8); + return Math.max(10, Math.min(60, maxLen + 2)); + }); +} + +function cellXml(value: XlsxCellValue, rowIndex: number, colIndex: number, style?: number): string { + const ref = cellRef(rowIndex, colIndex); + const styleAttr = style == null ? "" : ` s="${style}"`; + if (value == null) return ``; + if (typeof value === "number" && Number.isFinite(value)) { + return `${value}`; + } + if (typeof value === "boolean") { + return `${value ? 1 : 0}`; + } + return `${escapeXml(String(value))}`; +} + +function worksheetXml(data: XlsxWorksheetData): string { + const columns = data.columns; + const rows = data.rows; + const totalRows = rows.length + 1; + const range = sheetRange(columns.length, totalRows); + const widths = estimateColumnWidths(columns, rows); + const colsXml = widths + .map((width, index) => ``) + .join(""); + const headerXml = `${columns.map((column, index) => cellXml(column, 0, index, 1)).join("")}`; + const bodyXml = rows + .map((row, rowIndex) => { + const excelRowIndex = rowIndex + 2; + const cells = columns.map((_, colIndex) => cellXml(row[colIndex], excelRowIndex - 1, colIndex)).join(""); + return `${cells}`; + }) + .join(""); + + return ` + + + + + ${colsXml} + ${headerXml}${bodyXml} + +`; +} + +function contentTypesXml(): string { + return ` + + + + + + +`; +} + +function rootRelsXml(): string { + return ` + + +`; +} + +function workbookXml(sheetName: string): string { + return ` + + +`; +} + +function workbookRelsXml(): string { + return ` + + + +`; +} + +function stylesXml(): string { + return ` + + + + + + + +`; +} + +function uint16(value: number): Uint8Array { + const bytes = new Uint8Array(2); + const view = new DataView(bytes.buffer); + view.setUint16(0, value, true); + return bytes; +} + +function uint32(value: number): Uint8Array { + const bytes = new Uint8Array(4); + const view = new DataView(bytes.buffer); + view.setUint32(0, value >>> 0, true); + return bytes; +} + +function concatBytes(parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, part) => sum + part.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +function createZip(files: Array<{ path: string; content: string }>): Uint8Array { + const entries: ZipEntry[] = []; + const localParts: Uint8Array[] = []; + let offset = 0; + + for (const file of files) { + const path = encoder.encode(file.path); + const data = encoder.encode(file.content); + const crc = crc32(data); + const localHeader = concatBytes([ + uint32(0x04034b50), + uint16(20), + uint16(0), + uint16(0), + uint16(0), + uint16(0), + uint32(crc), + uint32(data.length), + uint32(data.length), + uint16(path.length), + uint16(0), + path, + ]); + entries.push({ path: file.path, data, crc, localHeaderOffset: offset }); + localParts.push(localHeader, data); + offset += localHeader.length + data.length; + } + + const centralParts: Uint8Array[] = []; + for (const entry of entries) { + const path = encoder.encode(entry.path); + centralParts.push( + concatBytes([ + uint32(0x02014b50), + uint16(20), + uint16(20), + uint16(0), + uint16(0), + uint16(0), + uint16(0), + uint32(entry.crc), + uint32(entry.data.length), + uint32(entry.data.length), + uint16(path.length), + uint16(0), + uint16(0), + uint16(0), + uint16(0), + uint32(0), + uint32(entry.localHeaderOffset), + path, + ]), + ); + } + + const central = concatBytes(centralParts); + const end = concatBytes([ + uint32(0x06054b50), + uint16(0), + uint16(0), + uint16(entries.length), + uint16(entries.length), + uint32(central.length), + uint32(offset), + uint16(0), + ]); + + return concatBytes([...localParts, central, end]); +} + +export function buildXlsxWorkbook(data: XlsxWorksheetData): Uint8Array { + const sheetName = normalizeSheetName(data.sheetName); + return createZip([ + { path: "[Content_Types].xml", content: contentTypesXml() }, + { path: "_rels/.rels", content: rootRelsXml() }, + { path: "xl/workbook.xml", content: workbookXml(sheetName) }, + { path: "xl/_rels/workbook.xml.rels", content: workbookRelsXml() }, + { path: "xl/styles.xml", content: stylesXml() }, + { path: "xl/worksheets/sheet1.xml", content: worksheetXml(data) }, + ]); +} diff --git a/tests/connectionUrl.test.ts b/tests/connectionUrl.test.ts new file mode 100644 index 000000000..f4e1981b4 --- /dev/null +++ b/tests/connectionUrl.test.ts @@ -0,0 +1,59 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { parseConnectionUrl } from "../src/lib/connectionUrl.ts"; + +test("parses postgres connection URLs", () => { + assert.deepEqual(parseConnectionUrl("postgresql://alice:secret@db.example.com:5433/app?sslmode=require"), { + dbType: "postgres", + driverProfile: "postgres", + driverLabel: "PostgreSQL", + host: "db.example.com", + port: 5433, + username: "alice", + password: "secret", + database: "app", + urlParams: "sslmode=require", + ssl: false, + }); +}); + +test("parses mysql URLs with encoded credentials", () => { + const parsed = parseConnectionUrl("mysql://root:p%40ss@127.0.0.1/shop?charset=utf8mb4"); + + assert.equal(parsed.dbType, "mysql"); + assert.equal(parsed.driverProfile, "mysql"); + assert.equal(parsed.host, "127.0.0.1"); + assert.equal(parsed.port, 3306); + assert.equal(parsed.username, "root"); + assert.equal(parsed.password, "p@ss"); + assert.equal(parsed.database, "shop"); + assert.equal(parsed.urlParams, "charset=utf8mb4"); +}); + +test("keeps MongoDB URLs as connection strings", () => { + const source = "mongodb+srv://reader:secret@cluster.example.com/app?retryWrites=true"; + const parsed = parseConnectionUrl(source); + + assert.equal(parsed.dbType, "mongodb"); + assert.equal(parsed.driverProfile, "mongodb"); + assert.equal(parsed.host, "cluster.example.com"); + assert.equal(parsed.port, 27017); + assert.equal(parsed.database, "app"); + assert.equal(parsed.connectionString, source); + assert.equal(parsed.useMongoUrl, true); + assert.equal(parsed.ssl, true); +}); + +test("uses selected HTTP-compatible profile for HTTP URLs", () => { + const parsed = parseConnectionUrl("https://search.example.com:9243", "elasticsearch"); + + assert.equal(parsed.dbType, "elasticsearch"); + assert.equal(parsed.driverProfile, "elasticsearch"); + assert.equal(parsed.host, "search.example.com"); + assert.equal(parsed.port, 9243); + assert.equal(parsed.ssl, true); +}); + +test("rejects unsupported URL schemes", () => { + assert.throws(() => parseConnectionUrl("ftp://example.com"), /Unsupported connection URL scheme/); +}); diff --git a/tests/xlsxExport.test.ts b/tests/xlsxExport.test.ts new file mode 100644 index 000000000..261263fe8 --- /dev/null +++ b/tests/xlsxExport.test.ts @@ -0,0 +1,35 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { buildXlsxWorkbook } from "../src/lib/xlsxExport.ts"; + +test("builds an xlsx workbook zip with worksheet data", () => { + const workbook = buildXlsxWorkbook({ + sheetName: "Users", + columns: ["id", "name", "active"], + rows: [ + [1, "Ada & Bob", true], + [2, null, false], + ], + }); + const text = new TextDecoder().decode(workbook); + + assert.equal(workbook[0], 0x50); + assert.equal(workbook[1], 0x4b); + assert.match(text, /\[Content_Types\]\.xml/); + assert.match(text, /xl\/worksheets\/sheet1\.xml/); + assert.match(text, /name="Users"/); + assert.match(text, /1<\/v><\/c>/); + assert.match(text, /Ada & Bob/); + assert.match(text, /1<\/v><\/c>/); +}); + +test("sanitizes invalid sheet names", () => { + const workbook = buildXlsxWorkbook({ + sheetName: "bad/name:with*chars?and-a-very-long-tail", + columns: ["value"], + rows: [["ok"]], + }); + const text = new TextDecoder().decode(workbook); + + assert.match(text, /name="bad name with chars and-a-very-"/); +});