diff --git a/licenses.yaml b/licenses.yaml index 8253cdd34881..9b7370e22e13 100644 --- a/licenses.yaml +++ b/licenses.yaml @@ -6038,6 +6038,16 @@ license_file_path: licenses/bin/lower-case.MIT --- +name: "markdown-table-ts" +license_category: binary +module: web-console +license_name: MIT License +copyright: Jiri Hajek +version: 1.0.3 +license_file_path: licenses/bin/markdown-table-ts.MIT + +--- + name: "memoize-one" license_category: binary module: web-console diff --git a/web-console/package-lock.json b/web-console/package-lock.json index 97a1d3e0538e..b36595a9c73e 100644 --- a/web-console/package-lock.json +++ b/web-console/package-lock.json @@ -37,6 +37,7 @@ "json-bigint-native": "^1.2.0", "lodash.debounce": "^4.0.8", "lodash.escape": "^4.0.1", + "markdown-table-ts": "^1.0.3", "memoize-one": "^5.2.1", "numeral": "^2.0.6", "react": "^18.3.1", @@ -12748,6 +12749,12 @@ "tmpl": "1.0.5" } }, + "node_modules/markdown-table-ts": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/markdown-table-ts/-/markdown-table-ts-1.0.3.tgz", + "integrity": "sha512-lYrp7FXmBqpmGmsEF92WnSukdgYvLm15FPIODZOx9+3nobkxJxjBYcszqZf5VqTjBtISPSNC7zjU9o3zwpL6AQ==", + "license": "MIT" + }, "node_modules/mathml-tag-names": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", @@ -27438,6 +27445,11 @@ "tmpl": "1.0.5" } }, + "markdown-table-ts": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/markdown-table-ts/-/markdown-table-ts-1.0.3.tgz", + "integrity": "sha512-lYrp7FXmBqpmGmsEF92WnSukdgYvLm15FPIODZOx9+3nobkxJxjBYcszqZf5VqTjBtISPSNC7zjU9o3zwpL6AQ==" + }, "mathml-tag-names": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", diff --git a/web-console/package.json b/web-console/package.json index 37e67a7bcd58..789faccd4984 100644 --- a/web-console/package.json +++ b/web-console/package.json @@ -78,6 +78,7 @@ "json-bigint-native": "^1.2.0", "lodash.debounce": "^4.0.8", "lodash.escape": "^4.0.1", + "markdown-table-ts": "^1.0.3", "memoize-one": "^5.2.1", "numeral": "^2.0.6", "react": "^18.3.1", diff --git a/web-console/src/utils/download.spec.ts b/web-console/src/utils/download.spec.ts index 71c051500d0d..502d361325e8 100644 --- a/web-console/src/utils/download.spec.ts +++ b/web-console/src/utils/download.spec.ts @@ -16,18 +16,129 @@ * limitations under the License. */ -import { formatForFormat } from './download'; +import { QueryResult, sane } from 'druid-query-toolkit'; + +import { queryResultsToString, stringifyCsvValue, stringifyTsvValue } from './download'; describe('download', () => { - it('.formatForFormat', () => { - expect(formatForFormat(null, 'csv')).toEqual(''); - expect(formatForFormat(null, 'tsv')).toEqual(''); - expect(formatForFormat('', 'csv')).toEqual('""'); - expect(formatForFormat('null', 'csv')).toEqual('"null"'); - expect(formatForFormat('hello\nworld', 'csv')).toEqual('"hello world"'); - expect(formatForFormat(123, 'csv')).toEqual('"123"'); - expect(formatForFormat(new Date('2021-01-02T03:04:05.678Z'), 'csv')).toEqual( - '"2021-01-02T03:04:05.678Z"', + it('stringifyCsvValue', () => { + expect(stringifyCsvValue(null)).toEqual(''); + expect(stringifyCsvValue('')).toEqual(''); + expect(stringifyCsvValue('null')).toEqual('null'); + expect(stringifyCsvValue('hello\nworld')).toEqual('hello world'); + expect(stringifyCsvValue('Jane "JD" Smith')).toEqual('"Jane ""JD"" Smith"'); + expect(stringifyCsvValue(123)).toEqual('123'); + expect(stringifyCsvValue(new Date('2021-01-02T03:04:05.678Z'))).toEqual( + '2021-01-02T03:04:05.678Z', + ); + }); + + it('stringifyTsvValue', () => { + expect(stringifyTsvValue(null)).toEqual(''); + expect(stringifyTsvValue('')).toEqual(''); + expect(stringifyTsvValue('null')).toEqual('null'); + expect(stringifyTsvValue('hello\nworld')).toEqual('hello world'); + expect(stringifyTsvValue(123)).toEqual('123'); + expect(stringifyTsvValue(new Date('2021-01-02T03:04:05.678Z'))).toEqual( + '2021-01-02T03:04:05.678Z', ); }); + + describe('queryResultsToString', () => { + /* + Data for testing: + ================= + {"name": "John, Doe", "age": 29, "city": "New York", "bio": "Loves coding\nand coffee"} + {"name": "Jane \"JD\" Smith", "age": 34, "city": "Los Angeles", "bio": "Enjoys \"live music\" and travel"} + {"name": "Michael, O'Connor", "age": 41, "city": "Chicago", "bio": "Expert in AI\\ML"} + {"name": "李四 (Li Si)", "age": 27, "city": "北京 (Beijing)", "bio": "喜欢编程和阅读"} + {"name": "O'Reilly, Patrick", "age": 45, "city": null, "bio": "\nLoves\ttabs"} + */ + + const queryResult = QueryResult.fromRawResult( + [ + ['__time', 'name', 'age', 'city', 'bio'], + ['LONG', 'STRING', 'LONG', 'STRING', 'STRING'], + ['TIMESTAMP', 'VARCHAR', 'BIGINT', 'VARCHAR', 'VARCHAR'], + [ + '1970-01-01T00:00:00.000Z', + 'Jane "JD" Smith', + 34, + 'Los Angeles', + 'Enjoys "live music" and travel', + ], + ['1970-01-01T00:00:00.000Z', 'John, Doe', 29, 'New York', 'Loves coding\nand coffee'], + ['1970-01-01T00:00:00.000Z', "Michael, O'Connor", 41, 'Chicago', 'Expert in AI\\ML'], + ['1970-01-01T00:00:00.000Z', "O'Reilly, Patrick", 45, null, '\nLoves\ttabs'], + ['1970-01-01T00:00:00.000Z', '李四 (Li Si)', 27, '北京 (Beijing)', '喜欢编程和阅读'], + ], + true, + true, + true, + true, + ); + + it('works with CSV', () => { + expect(queryResultsToString(queryResult, 'csv').replaceAll('\t', '\\t')).toEqual(sane` + __time,name,age,city,bio + 1970-01-01T00:00:00.000Z,"Jane ""JD"" Smith",34,Los Angeles,"Enjoys ""live music"" and travel" + 1970-01-01T00:00:00.000Z,"John, Doe",29,New York,Loves coding and coffee + 1970-01-01T00:00:00.000Z,"Michael, O'Connor",41,Chicago,Expert in AI\\ML + 1970-01-01T00:00:00.000Z,"O'Reilly, Patrick",45,," Loves\ttabs" + 1970-01-01T00:00:00.000Z,李四 (Li Si),27,北京 (Beijing),喜欢编程和阅读 + `); + }); + + it('works with TSV', () => { + expect(queryResultsToString(queryResult, 'tsv').replaceAll('\t', '\\t')).toEqual(sane` + __time\tname\tage\tcity\tbio + 1970-01-01T00:00:00.000Z\tJane "JD" Smith\t34\tLos Angeles\tEnjoys "live music" and travel + 1970-01-01T00:00:00.000Z\tJohn, Doe\t29\tNew York\tLoves coding and coffee + 1970-01-01T00:00:00.000Z\tMichael, O'Connor\t41\tChicago\tExpert in AI\\ML + 1970-01-01T00:00:00.000Z\tO'Reilly, Patrick\t45\t\t Loves tabs + 1970-01-01T00:00:00.000Z\t李四 (Li Si)\t27\t北京 (Beijing)\t喜欢编程和阅读 + `); + }); + + it('works with JSON', () => { + expect(queryResultsToString(queryResult, 'json')).toEqual(sane` + {"__time":"1970-01-01T00:00:00.000Z","name":"Jane \\"JD\\" Smith","age":34,"city":"Los Angeles","bio":"Enjoys \\"live music\\" and travel"} + {"__time":"1970-01-01T00:00:00.000Z","name":"John, Doe","age":29,"city":"New York","bio":"Loves coding\\nand coffee"} + {"__time":"1970-01-01T00:00:00.000Z","name":"Michael, O'Connor","age":41,"city":"Chicago","bio":"Expert in AI\\\\ML"} + {"__time":"1970-01-01T00:00:00.000Z","name":"O'Reilly, Patrick","age":45,"city":null,"bio":"\\nLoves\\ttabs"} + {"__time":"1970-01-01T00:00:00.000Z","name":"李四 (Li Si)","age":27,"city":"北京 (Beijing)","bio":"喜欢编程和阅读"} + `); + }); + + it('works with SQL', () => { + expect(queryResultsToString(queryResult, 'sql')).toEqual(sane` + SELECT + CAST("c1" AS TIMESTAMP) AS "__time", + CAST("c2" AS VARCHAR) AS "name", + CAST("c3" AS BIGINT) AS "age", + CAST("c4" AS VARCHAR) AS "city", + CAST("c5" AS VARCHAR) AS "bio" + FROM ( + VALUES + ('1970-01-01T00:00:00.000Z', 'Jane "JD" Smith', 34, 'Los Angeles', 'Enjoys "live music" and travel'), + ('1970-01-01T00:00:00.000Z', 'John, Doe', 29, 'New York', U&'Loves coding\000aand coffee'), + ('1970-01-01T00:00:00.000Z', 'Michael, O''Connor', 41, 'Chicago', 'Expert in AI\ML'), + ('1970-01-01T00:00:00.000Z', 'O''Reilly, Patrick', 45, NULL, U&'\000aLoves\0009tabs'), + ('1970-01-01T00:00:00.000Z', '李四 (Li Si)', 27, '北京 (Beijing)', '喜欢编程和阅读') + ) AS "t" ("c1", "c2", "c3", "c4", "c5") + `); + }); + + it('works with Markdown', () => { + expect(queryResultsToString(queryResult, 'markdown').replaceAll('\t', '\\t')).toEqual(sane` + | __time | name | age | city | bio | + | :----------------------- | :---------------- | --: | :----------- | :----------------------------- | + | 1970-01-01T00:00:00.000Z | Jane "JD" Smith | 34 | Los Angeles | Enjoys "live music" and travel | + | 1970-01-01T00:00:00.000Z | John, Doe | 29 | New York | Loves coding
and coffee | + | 1970-01-01T00:00:00.000Z | Michael, O'Connor | 41 | Chicago | Expert in AI\\ML | + | 1970-01-01T00:00:00.000Z | O'Reilly, Patrick | 45 | |
Loves\ttabs | + | 1970-01-01T00:00:00.000Z | 李四 (Li Si) | 27 | 北京 (Beijing) | 喜欢编程和阅读 | + `); + }); + }); }); diff --git a/web-console/src/utils/download.ts b/web-console/src/utils/download.ts index 40b0d95e8b91..af97801012c0 100644 --- a/web-console/src/utils/download.ts +++ b/web-console/src/utils/download.ts @@ -19,78 +19,80 @@ import type { QueryResult } from 'druid-query-toolkit'; import FileSaver from 'file-saver'; import * as JSONBig from 'json-bigint-native'; +import { Align, getMarkdownTable } from 'markdown-table-ts'; import { copyAndAlert, stringifyValue } from './general'; import { queryResultToValuesQuery } from './values-query'; -export type Format = 'csv' | 'tsv' | 'json' | 'sql'; - -export function downloadUrl(url: string, filename: string) { - // Create a link and set the URL using `createObjectURL` - const link = document.createElement('a'); - link.style.display = 'none'; - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - - // To make this work on Firefox we need to wait - // a little while before removing it. - setTimeout(() => { - if (!link.parentNode) return; - link.parentNode.removeChild(link); - }, 0); -} - -export function formatForFormat(s: null | string | number | Date, format: 'csv' | 'tsv'): string { +export type FileFormat = 'csv' | 'tsv' | 'json' | 'sql' | 'markdown'; +export const FILE_FORMATS: FileFormat[] = ['csv', 'tsv', 'json', 'sql', 'markdown']; + +const FILE_FORMAT_TO_MIME_TYPE: Record = { + csv: 'text/csv', + tsv: 'text/tab-separated-values', + json: 'application/json', + sql: 'text/plain', + markdown: 'text/markdown', +}; + +export const FILE_FORMAT_TO_LABEL: Record = { + csv: 'CSV', + tsv: 'TSV', + json: 'JSON (new line delimited)', + sql: 'SQL (VALUES)', + markdown: 'Markdown table', +}; + +export function stringifyCsvValue(s: null | string | number | Date): string { if (s == null) return ''; + const str = stringifyValue(s).replace(/\r?\n/g, ' ').replace(/"/g, '""'); - // stringify and remove line break - const str = stringifyValue(s).replace(/(?:\r\n|\r|\n)/g, ' '); - - if (format === 'csv') { - // csv: single quote => double quote, handle ',' - return `"${str.replace(/"/g, '""')}"`; - } else { - // tsv: single quote => double quote, \t => '' - return str.replace(/\t/g, '').replace(/"/g, '""'); + if (/["\n\t,]/.test(str)) { + return `"${str}"`; } + + return str; } -export function downloadFile(text: string, type: string, filename: string): void { - let blobType; - switch (type) { - case 'json': - blobType = 'application/json'; - break; - case 'tsv': - blobType = 'text/tab-separated-values'; - break; - default: - // csv - blobType = `text/${type}`; - break; - } +export function stringifyTsvValue(s: null | string | number | Date): string { + if (s == null) return ''; + return stringifyValue(s).replace(/\r?\n|\t/g, ' '); +} - const blob = new Blob([text], { - type: blobType, - }); +export function stringifyMarkdownValue(s: null | string | number | Date): string { + if (s == null) return ''; + return stringifyValue(s).replace(/\r?\n/g, '
'); +} + +function queryResultToDsv( + queryResult: QueryResult, + delimiter: string, + valueFormatter: (v: any) => string, +): string { + return [ + queryResult.header.map(column => valueFormatter(column.name)).join(delimiter), + ...queryResult.rows.map(row => row.map(cell => valueFormatter(cell)).join(delimiter)), + ].join('\n'); +} - FileSaver.saveAs(blob, filename); +export function downloadFile(text: string, fileFormat: FileFormat, filename: string): void { + FileSaver.saveAs( + new Blob([text], { + type: FILE_FORMAT_TO_MIME_TYPE[fileFormat], + }), + filename, + ); } -function queryResultsToString(queryResult: QueryResult, format: Format): string { +export function queryResultsToString(queryResult: QueryResult, format: FileFormat): string { const { header, rows } = queryResult; switch (format) { case 'csv': - case 'tsv': { - const separator = format === 'csv' ? ',' : '\t'; - return [ - header.map(column => formatForFormat(column.name, format)).join(separator), - ...rows.map(r => r.map(cell => formatForFormat(cell, format)).join(separator)), - ].join('\n'); - } + return queryResultToDsv(queryResult, ',', stringifyCsvValue); + + case 'tsv': + return queryResultToDsv(queryResult, '\t', stringifyTsvValue); case 'sql': return queryResultToValuesQuery(queryResult).toString(); @@ -101,6 +103,15 @@ function queryResultsToString(queryResult: QueryResult, format: Format): string .map(r => JSONBig.stringify(r)) .join('\n'); + case 'markdown': + return getMarkdownTable({ + table: { + head: header.map(column => column.name), + body: rows.map(row => row.map(stringifyMarkdownValue)), + }, + alignment: header.map(column => (column.isNumeric() ? Align.Right : Align.Left)), + }); + default: throw new Error(`unknown format: ${format}`); } @@ -109,13 +120,16 @@ function queryResultsToString(queryResult: QueryResult, format: Format): string export function downloadQueryResults( queryResult: QueryResult, filename: string, - format: Format, + fileFormat: FileFormat, ): void { - const resultString: string = queryResultsToString(queryResult, format); - downloadFile(resultString, format, filename); + const resultString: string = queryResultsToString(queryResult, fileFormat); + downloadFile(resultString, fileFormat, filename); } -export function copyQueryResultsToClipboard(queryResult: QueryResult, format: Format): void { - const resultString: string = queryResultsToString(queryResult, format); +export function copyQueryResultsToClipboard( + queryResult: QueryResult, + fileFormat: FileFormat, +): void { + const resultString: string = queryResultsToString(queryResult, fileFormat); copyAndAlert(resultString, 'Query results copied to clipboard'); } diff --git a/web-console/src/views/workbench-view/destination-pages-pane/destination-pages-pane.tsx b/web-console/src/views/workbench-view/destination-pages-pane/destination-pages-pane.tsx index 0ffa3125985f..d3e4648e2910 100644 --- a/web-console/src/views/workbench-view/destination-pages-pane/destination-pages-pane.tsx +++ b/web-console/src/views/workbench-view/destination-pages-pane/destination-pages-pane.tsx @@ -24,15 +24,7 @@ import ReactTable from 'react-table'; import type { Execution } from '../../../druid-models'; import { SMALL_TABLE_PAGE_SIZE } from '../../../react-table'; import { Api, UrlBaser } from '../../../singletons'; -import { - clamp, - downloadUrl, - formatBytes, - formatInteger, - pluralIfNeeded, - tickIcon, - wait, -} from '../../../utils'; +import { clamp, formatBytes, formatInteger, pluralIfNeeded, tickIcon } from '../../../utils'; import './destination-pages-pane.scss'; @@ -77,6 +69,7 @@ export const DestinationPagesPane = React.memo(function DestinationPagesPane( const destination = execution.destination; const pages = execution.destinationPages; if (!pages) return null; + const numPages = pages.length; const id = Api.encodePath(execution.id); const numTotalRows = destination?.numTotalRows; @@ -85,23 +78,21 @@ export const DestinationPagesPane = React.memo(function DestinationPagesPane( return UrlBaser.base( `/druid/v2/sql/statements/${id}/results?${ pageIndex < 0 ? '' : `page=${pageIndex}&` - }resultFormat=${desiredResultFormat}`, + }resultFormat=${desiredResultFormat}&filename=${getPageFilename(pageIndex)}`, ); } - function getPageFilename(pageIndex: number, numPages: number) { + function getFilenamePageInfo(pageIndex: number) { + if (pageIndex < 0) return 'all_data'; const numPagesString = String(numPages); const pageNumberString = String(pageIndex + 1).padStart(numPagesString.length, '0'); - return `${id}_page_${pageNumberString}_of_${numPagesString}.${desiredExtension}`; + return `page_${pageNumberString}_of_${numPagesString}`; } - async function downloadAllData() { - if (!pages) return; - downloadUrl(getResultUrl(-1), `${id}_all_data.${desiredExtension}`); - await wait(100); + function getPageFilename(pageIndex: number) { + return `${id}_${getFilenamePageInfo(pageIndex)}.${desiredExtension}`; } - const numPages = pages.length; return (

@@ -133,14 +124,13 @@ export const DestinationPagesPane = React.memo(function DestinationPagesPane( rightIcon={IconNames.CARET_DOWN} /> {' '} - {pages.length > 1 && ( -