Skip to content

Commit

Permalink
Merge pull request #1379 from github/aeisenberg/fix-bqrs-decode
Browse files Browse the repository at this point in the history
Fix quoting of string columns in csv
  • Loading branch information
aeisenberg authored Jun 20, 2022
2 parents 6b9410c + a3a0513 commit d061634
Show file tree
Hide file tree
Showing 5 changed files with 159 additions and 18 deletions.
1 change: 1 addition & 0 deletions extensions/ql-vscode/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

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

## 1.6.6 - 17 May 2022

Expand Down
7 changes: 7 additions & 0 deletions extensions/ql-vscode/src/pure/bqrs-cli-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,14 @@ export function transformBqrsResultSet(
};
}

type BqrsKind = 'String' | 'Float' | 'Integer' | 'String' | 'Boolean' | 'Date' | 'Entity';

interface BqrsColumn {
name: string;
kind: BqrsKind;
}
export interface DecodedBqrsChunk {
tuples: CellValue[][];
next?: number;
columns: BqrsColumn[];
}
4 changes: 2 additions & 2 deletions extensions/ql-vscode/src/query-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -956,11 +956,11 @@ export class QueryHistoryManager extends DisposableObject {
void this.tryOpenExternalFile(query.csvPath);
return;
}
await query.exportCsvResults(this.qs, query.csvPath, () => {
if (await query.exportCsvResults(this.qs, query.csvPath)) {
void this.tryOpenExternalFile(
query.csvPath
);
});
}
}

async handleViewCsvAlerts(
Expand Down
62 changes: 49 additions & 13 deletions extensions/ql-vscode/src/run-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,31 +341,67 @@ export class QueryEvaluationInfo {
/**
* Creates the CSV file containing the results of this query. This will only be called if the query
* does not have interpreted results and the CSV file does not already exist.
*
* @return Promise<true> if the operation creates the file. Promise<false> if the operation does
* not create the file.
*
* @throws Error if the operation fails.
*/
async exportCsvResults(qs: qsClient.QueryServerClient, csvPath: string, onFinish: () => void): Promise<void> {
async exportCsvResults(qs: qsClient.QueryServerClient, csvPath: string): Promise<boolean> {
const resultSet = await this.chooseResultSet(qs);
if (!resultSet) {
void showAndLogWarningMessage('Query has no result set.');
return false;
}
let stopDecoding = false;
const out = fs.createWriteStream(csvPath);
out.on('finish', onFinish);
out.on('error', () => {
if (!stopDecoding) {
stopDecoding = true;
void showAndLogErrorMessage(`Failed to write CSV results to ${csvPath}`);
}

const promise: Promise<boolean> = new Promise((resolve, reject) => {
out.on('finish', () => resolve(true));
out.on('error', () => {
if (!stopDecoding) {
stopDecoding = true;
reject(new Error(`Failed to write CSV results to ${csvPath}`));
}
});
});

let nextOffset: number | undefined = 0;
while (nextOffset !== undefined && !stopDecoding) {
const chunk: DecodedBqrsChunk = await qs.cliServer.bqrsDecode(this.resultsPaths.resultsPath, SELECT_QUERY_NAME, {
do {
const chunk: DecodedBqrsChunk = await qs.cliServer.bqrsDecode(this.resultsPaths.resultsPath, resultSet, {
pageSize: 100,
offset: nextOffset,
});
for (const tuple of chunk.tuples) {
out.write(tuple.join(',') + '\n');
}
chunk.tuples.forEach((tuple) => {
out.write(tuple.map((v, i) =>
chunk.columns[i].kind === 'String'
? `"${typeof v === 'string' ? v.replaceAll('"', '""') : v}"`
: v
).join(',') + '\n');
});
nextOffset = chunk.next;
}
} while (nextOffset && !stopDecoding);
out.end();

return promise;
}

/**
* Choose the name of the result set to run. If the `#select` set exists, use that. Otherwise,
* arbitrarily choose the first set. Most of the time, this will be correct.
*
* If the query has no result sets, then return undefined.
*/
async chooseResultSet(qs: qsClient.QueryServerClient) {
const resultSets = (await qs.cliServer.bqrsInfo(this.resultsPaths.resultsPath, 0))['result-sets'];
if (!resultSets.length) {
return undefined;
}
if (resultSets.find(r => r.name === SELECT_QUERY_NAME)) {
return SELECT_QUERY_NAME;
}
return resultSets[0].name;
}
/**
* Returns the path to the CSV alerts interpretation of this query results. If CSV results have
* not yet been produced, this will return first create the CSV results and then return the path.
Expand Down
103 changes: 100 additions & 3 deletions extensions/ql-vscode/src/vscode-tests/no-workspace/run-queries.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { expect } from 'chai';
import * as path from 'path';
import * as fs from 'fs-extra';
import * as sinon from 'sinon';
import { Uri } from 'vscode';

import { QueryEvaluationInfo } from '../../run-queries';
import { Severity, compileQuery } from '../../pure/messages';
import * as config from '../../config';
import { tmpDir } from '../../helpers';
import { QueryServerClient } from '../../queryserver-client';
import { CodeQLCliServer } from '../../cli';
import { SELECT_QUERY_NAME } from '../../contextual/locationFinder';

describe('run-queries', () => {
let sandbox: sinon.SinonSandbox;
Expand Down Expand Up @@ -53,6 +58,85 @@ describe('run-queries', () => {
expect(info.canHaveInterpretedResults()).to.eq(true);
});

[SELECT_QUERY_NAME, 'other'].forEach(resultSetName => {
it(`should export csv results for result set ${resultSetName}`, async () => {
const csvLocation = path.join(tmpDir.name, 'test.csv');
const qs = createMockQueryServerClient(
createMockCliServer({
bqrsInfo: [{ 'result-sets': [{ name: resultSetName }, { name: 'hucairz' }] }],
bqrsDecode: [{
columns: [{ kind: 'NotString' }, { kind: 'String' }],
tuples: [['a', 'b'], ['c', 'd']],
next: 1
}, {
// just for fun, give a different set of columns here
// this won't happen with the real CLI, but it's a good test
columns: [{ kind: 'String' }, { kind: 'NotString' }, { kind: 'StillNotString' }],
tuples: [['a', 'b', 'c']]
}]
})
);
const info = createMockQueryInfo();
const promise = info.exportCsvResults(qs, csvLocation);

const result = await promise;
expect(result).to.eq(true);

const csv = fs.readFileSync(csvLocation, 'utf8');
expect(csv).to.eq('a,"b"\nc,"d"\n"a",b,c\n');

// now verify that we are using the expected result set
expect((qs.cliServer.bqrsDecode as sinon.SinonStub).callCount).to.eq(2);
expect((qs.cliServer.bqrsDecode as sinon.SinonStub).getCall(0).args[1]).to.eq(resultSetName);
});
});

it('should export csv results with characters that need to be escaped', async () => {
const csvLocation = path.join(tmpDir.name, 'test.csv');
const qs = createMockQueryServerClient(
createMockCliServer({
bqrsInfo: [{ 'result-sets': [{ name: SELECT_QUERY_NAME }, { name: 'hucairz' }] }],
bqrsDecode: [{
columns: [{ kind: 'NotString' }, { kind: 'String' }],
// We only escape string columns. In practice, we will only see quotes in strings, but
// it is a good test anyway.
tuples: [
['"a"', '"b"'],
['c,xxx', 'd,yyy'],
['aaa " bbb', 'ccc " ddd'],
[true, false],
[123, 456],
[123.98, 456.99],
],
}]
})
);
const info = createMockQueryInfo();
const promise = info.exportCsvResults(qs, csvLocation);

const result = await promise;
expect(result).to.eq(true);

const csv = fs.readFileSync(csvLocation, 'utf8');
expect(csv).to.eq('"a","""b"""\nc,xxx,"d,yyy"\naaa " bbb,"ccc "" ddd"\ntrue,"false"\n123,"456"\n123.98,"456.99"\n');

// now verify that we are using the expected result set
expect((qs.cliServer.bqrsDecode as sinon.SinonStub).callCount).to.eq(1);
expect((qs.cliServer.bqrsDecode as sinon.SinonStub).getCall(0).args[1]).to.eq(SELECT_QUERY_NAME);
});

it('should handle csv exports for a query with no result sets', async () => {
const csvLocation = path.join(tmpDir.name, 'test.csv');
const qs = createMockQueryServerClient(
createMockCliServer({
bqrsInfo: [{ 'result-sets': [] }]
})
);
const info = createMockQueryInfo();
const result = await info.exportCsvResults(qs, csvLocation);
expect(result).to.eq(false);
});

describe('compile', () => {
it('should compile', async () => {
const info = createMockQueryInfo();
Expand Down Expand Up @@ -116,7 +200,7 @@ describe('run-queries', () => {
);
}

function createMockQueryServerClient() {
function createMockQueryServerClient(cliServer?: CodeQLCliServer): QueryServerClient {
return {
config: {
timeoutSecs: 5
Expand All @@ -131,7 +215,20 @@ describe('run-queries', () => {
})),
logger: {
log: sandbox.spy()
}
};
},
cliServer
} as unknown as QueryServerClient;
}

function createMockCliServer(mockOperations: Record<string, any[]>): CodeQLCliServer {
const mockServer: Record<string, any> = {};
for (const [operation, returns] of Object.entries(mockOperations)) {
mockServer[operation] = sandbox.stub();
returns.forEach((returnValue, i) => {
mockServer[operation].onCall(i).resolves(returnValue);
});
}

return mockServer as unknown as CodeQLCliServer;
}
});

0 comments on commit d061634

Please sign in to comment.