Skip to content

Commit d061634

Browse files
authored
Merge pull request #1379 from github/aeisenberg/fix-bqrs-decode
Fix quoting of string columns in csv
2 parents 6b9410c + a3a0513 commit d061634

5 files changed

Lines changed: 159 additions & 18 deletions

File tree

extensions/ql-vscode/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- Prints end-of-query evaluator log summaries to the Query Log. [#1349](https://github.com/github/vscode-codeql/pull/1349)
88
- Be consistent about casing in Query History menu. [#1369](https://github.com/github/vscode-codeql/pull/1369)
9+
- Fix quoting string columns in exported CSV results. [#1379](https://github.com/github/vscode-codeql/pull/1379)
910

1011
## 1.6.6 - 17 May 2022
1112

extensions/ql-vscode/src/pure/bqrs-cli-types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,14 @@ export function transformBqrsResultSet(
103103
};
104104
}
105105

106+
type BqrsKind = 'String' | 'Float' | 'Integer' | 'String' | 'Boolean' | 'Date' | 'Entity';
107+
108+
interface BqrsColumn {
109+
name: string;
110+
kind: BqrsKind;
111+
}
106112
export interface DecodedBqrsChunk {
107113
tuples: CellValue[][];
108114
next?: number;
115+
columns: BqrsColumn[];
109116
}

extensions/ql-vscode/src/query-history.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -956,11 +956,11 @@ export class QueryHistoryManager extends DisposableObject {
956956
void this.tryOpenExternalFile(query.csvPath);
957957
return;
958958
}
959-
await query.exportCsvResults(this.qs, query.csvPath, () => {
959+
if (await query.exportCsvResults(this.qs, query.csvPath)) {
960960
void this.tryOpenExternalFile(
961961
query.csvPath
962962
);
963-
});
963+
}
964964
}
965965

966966
async handleViewCsvAlerts(

extensions/ql-vscode/src/run-queries.ts

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -341,31 +341,67 @@ export class QueryEvaluationInfo {
341341
/**
342342
* Creates the CSV file containing the results of this query. This will only be called if the query
343343
* does not have interpreted results and the CSV file does not already exist.
344+
*
345+
* @return Promise<true> if the operation creates the file. Promise<false> if the operation does
346+
* not create the file.
347+
*
348+
* @throws Error if the operation fails.
344349
*/
345-
async exportCsvResults(qs: qsClient.QueryServerClient, csvPath: string, onFinish: () => void): Promise<void> {
350+
async exportCsvResults(qs: qsClient.QueryServerClient, csvPath: string): Promise<boolean> {
351+
const resultSet = await this.chooseResultSet(qs);
352+
if (!resultSet) {
353+
void showAndLogWarningMessage('Query has no result set.');
354+
return false;
355+
}
346356
let stopDecoding = false;
347357
const out = fs.createWriteStream(csvPath);
348-
out.on('finish', onFinish);
349-
out.on('error', () => {
350-
if (!stopDecoding) {
351-
stopDecoding = true;
352-
void showAndLogErrorMessage(`Failed to write CSV results to ${csvPath}`);
353-
}
358+
359+
const promise: Promise<boolean> = new Promise((resolve, reject) => {
360+
out.on('finish', () => resolve(true));
361+
out.on('error', () => {
362+
if (!stopDecoding) {
363+
stopDecoding = true;
364+
reject(new Error(`Failed to write CSV results to ${csvPath}`));
365+
}
366+
});
354367
});
368+
355369
let nextOffset: number | undefined = 0;
356-
while (nextOffset !== undefined && !stopDecoding) {
357-
const chunk: DecodedBqrsChunk = await qs.cliServer.bqrsDecode(this.resultsPaths.resultsPath, SELECT_QUERY_NAME, {
370+
do {
371+
const chunk: DecodedBqrsChunk = await qs.cliServer.bqrsDecode(this.resultsPaths.resultsPath, resultSet, {
358372
pageSize: 100,
359373
offset: nextOffset,
360374
});
361-
for (const tuple of chunk.tuples) {
362-
out.write(tuple.join(',') + '\n');
363-
}
375+
chunk.tuples.forEach((tuple) => {
376+
out.write(tuple.map((v, i) =>
377+
chunk.columns[i].kind === 'String'
378+
? `"${typeof v === 'string' ? v.replaceAll('"', '""') : v}"`
379+
: v
380+
).join(',') + '\n');
381+
});
364382
nextOffset = chunk.next;
365-
}
383+
} while (nextOffset && !stopDecoding);
366384
out.end();
385+
386+
return promise;
367387
}
368388

389+
/**
390+
* Choose the name of the result set to run. If the `#select` set exists, use that. Otherwise,
391+
* arbitrarily choose the first set. Most of the time, this will be correct.
392+
*
393+
* If the query has no result sets, then return undefined.
394+
*/
395+
async chooseResultSet(qs: qsClient.QueryServerClient) {
396+
const resultSets = (await qs.cliServer.bqrsInfo(this.resultsPaths.resultsPath, 0))['result-sets'];
397+
if (!resultSets.length) {
398+
return undefined;
399+
}
400+
if (resultSets.find(r => r.name === SELECT_QUERY_NAME)) {
401+
return SELECT_QUERY_NAME;
402+
}
403+
return resultSets[0].name;
404+
}
369405
/**
370406
* Returns the path to the CSV alerts interpretation of this query results. If CSV results have
371407
* not yet been produced, this will return first create the CSV results and then return the path.

extensions/ql-vscode/src/vscode-tests/no-workspace/run-queries.test.ts

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { expect } from 'chai';
22
import * as path from 'path';
3+
import * as fs from 'fs-extra';
34
import * as sinon from 'sinon';
45
import { Uri } from 'vscode';
56

67
import { QueryEvaluationInfo } from '../../run-queries';
78
import { Severity, compileQuery } from '../../pure/messages';
89
import * as config from '../../config';
10+
import { tmpDir } from '../../helpers';
11+
import { QueryServerClient } from '../../queryserver-client';
12+
import { CodeQLCliServer } from '../../cli';
13+
import { SELECT_QUERY_NAME } from '../../contextual/locationFinder';
914

1015
describe('run-queries', () => {
1116
let sandbox: sinon.SinonSandbox;
@@ -53,6 +58,85 @@ describe('run-queries', () => {
5358
expect(info.canHaveInterpretedResults()).to.eq(true);
5459
});
5560

61+
[SELECT_QUERY_NAME, 'other'].forEach(resultSetName => {
62+
it(`should export csv results for result set ${resultSetName}`, async () => {
63+
const csvLocation = path.join(tmpDir.name, 'test.csv');
64+
const qs = createMockQueryServerClient(
65+
createMockCliServer({
66+
bqrsInfo: [{ 'result-sets': [{ name: resultSetName }, { name: 'hucairz' }] }],
67+
bqrsDecode: [{
68+
columns: [{ kind: 'NotString' }, { kind: 'String' }],
69+
tuples: [['a', 'b'], ['c', 'd']],
70+
next: 1
71+
}, {
72+
// just for fun, give a different set of columns here
73+
// this won't happen with the real CLI, but it's a good test
74+
columns: [{ kind: 'String' }, { kind: 'NotString' }, { kind: 'StillNotString' }],
75+
tuples: [['a', 'b', 'c']]
76+
}]
77+
})
78+
);
79+
const info = createMockQueryInfo();
80+
const promise = info.exportCsvResults(qs, csvLocation);
81+
82+
const result = await promise;
83+
expect(result).to.eq(true);
84+
85+
const csv = fs.readFileSync(csvLocation, 'utf8');
86+
expect(csv).to.eq('a,"b"\nc,"d"\n"a",b,c\n');
87+
88+
// now verify that we are using the expected result set
89+
expect((qs.cliServer.bqrsDecode as sinon.SinonStub).callCount).to.eq(2);
90+
expect((qs.cliServer.bqrsDecode as sinon.SinonStub).getCall(0).args[1]).to.eq(resultSetName);
91+
});
92+
});
93+
94+
it('should export csv results with characters that need to be escaped', async () => {
95+
const csvLocation = path.join(tmpDir.name, 'test.csv');
96+
const qs = createMockQueryServerClient(
97+
createMockCliServer({
98+
bqrsInfo: [{ 'result-sets': [{ name: SELECT_QUERY_NAME }, { name: 'hucairz' }] }],
99+
bqrsDecode: [{
100+
columns: [{ kind: 'NotString' }, { kind: 'String' }],
101+
// We only escape string columns. In practice, we will only see quotes in strings, but
102+
// it is a good test anyway.
103+
tuples: [
104+
['"a"', '"b"'],
105+
['c,xxx', 'd,yyy'],
106+
['aaa " bbb', 'ccc " ddd'],
107+
[true, false],
108+
[123, 456],
109+
[123.98, 456.99],
110+
],
111+
}]
112+
})
113+
);
114+
const info = createMockQueryInfo();
115+
const promise = info.exportCsvResults(qs, csvLocation);
116+
117+
const result = await promise;
118+
expect(result).to.eq(true);
119+
120+
const csv = fs.readFileSync(csvLocation, 'utf8');
121+
expect(csv).to.eq('"a","""b"""\nc,xxx,"d,yyy"\naaa " bbb,"ccc "" ddd"\ntrue,"false"\n123,"456"\n123.98,"456.99"\n');
122+
123+
// now verify that we are using the expected result set
124+
expect((qs.cliServer.bqrsDecode as sinon.SinonStub).callCount).to.eq(1);
125+
expect((qs.cliServer.bqrsDecode as sinon.SinonStub).getCall(0).args[1]).to.eq(SELECT_QUERY_NAME);
126+
});
127+
128+
it('should handle csv exports for a query with no result sets', async () => {
129+
const csvLocation = path.join(tmpDir.name, 'test.csv');
130+
const qs = createMockQueryServerClient(
131+
createMockCliServer({
132+
bqrsInfo: [{ 'result-sets': [] }]
133+
})
134+
);
135+
const info = createMockQueryInfo();
136+
const result = await info.exportCsvResults(qs, csvLocation);
137+
expect(result).to.eq(false);
138+
});
139+
56140
describe('compile', () => {
57141
it('should compile', async () => {
58142
const info = createMockQueryInfo();
@@ -116,7 +200,7 @@ describe('run-queries', () => {
116200
);
117201
}
118202

119-
function createMockQueryServerClient() {
203+
function createMockQueryServerClient(cliServer?: CodeQLCliServer): QueryServerClient {
120204
return {
121205
config: {
122206
timeoutSecs: 5
@@ -131,7 +215,20 @@ describe('run-queries', () => {
131215
})),
132216
logger: {
133217
log: sandbox.spy()
134-
}
135-
};
218+
},
219+
cliServer
220+
} as unknown as QueryServerClient;
221+
}
222+
223+
function createMockCliServer(mockOperations: Record<string, any[]>): CodeQLCliServer {
224+
const mockServer: Record<string, any> = {};
225+
for (const [operation, returns] of Object.entries(mockOperations)) {
226+
mockServer[operation] = sandbox.stub();
227+
returns.forEach((returnValue, i) => {
228+
mockServer[operation].onCall(i).resolves(returnValue);
229+
});
230+
}
231+
232+
return mockServer as unknown as CodeQLCliServer;
136233
}
137234
});

0 commit comments

Comments
 (0)