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
10 changes: 10 additions & 0 deletions licenses.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions web-console/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions web-console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
131 changes: 121 additions & 10 deletions web-console/src/utils/download.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<br>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 | | <br>Loves\ttabs |
| 1970-01-01T00:00:00.000Z | 李四 (Li Si) | 27 | 北京 (Beijing) | 喜欢编程和阅读 |
`);
});
});
});
134 changes: 74 additions & 60 deletions web-console/src/utils/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FileFormat, string> = {
csv: 'text/csv',
tsv: 'text/tab-separated-values',
json: 'application/json',
sql: 'text/plain',
markdown: 'text/markdown',
};

export const FILE_FORMAT_TO_LABEL: Record<FileFormat, string> = {
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, '<br>');
}

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();
Expand All @@ -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}`);
}
Expand All @@ -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');
}
Loading
Loading