Skip to content

Commit b212d0c

Browse files
authored
Merge pull request #1135 from salesforcecli/cd/data-query-file-output
feat(data:query): add `--output-file` flag
2 parents ef2ce82 + 08e2c06 commit b212d0c

File tree

8 files changed

+101
-31
lines changed

8 files changed

+101
-31
lines changed

command-snapshot.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@
213213
"flags-dir",
214214
"json",
215215
"loglevel",
216+
"output-file",
216217
"perflog",
217218
"query",
218219
"result-format",

messages/reporter.md

Lines changed: 0 additions & 3 deletions
This file was deleted.

messages/soql.query.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ When using --bulk, the command waits 3 minutes by default for the query to compl
1616

1717
<%= config.bin %> <%= command.id %> --query "SELECT Id, Name, Account.Name FROM Contact"
1818

19-
- Read the SOQL query from a file called "query.txt"; the command uses the org with alias "my-scratch":
19+
- Read the SOQL query from a file called "query.txt" and write the CSV-formatted output to a file; the command uses the org with alias "my-scratch":
2020

21-
<%= config.bin %> <%= command.id %> --file query.txt --target-org my-scratch
21+
<%= config.bin %> <%= command.id %> --file query.txt --output-file output.csv --result-format csv --target-org my-scratch
2222

2323
- Use Tooling API to run a query on the ApexTrigger Tooling API object:
2424

@@ -56,6 +56,10 @@ Include deleted records. By default, deleted records are not returned.
5656

5757
Time to wait for the command to finish, in minutes.
5858

59+
# flags.output-file.summary
60+
61+
File where records are written; only CSV and JSON output formats are supported.
62+
5963
# displayQueryRecordsRetrieved
6064

6165
Total number of records retrieved: %s.

src/commands/data/query.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,14 @@ import { BulkQueryRequestCache } from '../../bulkDataRequestCache.js';
2929
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
3030
const messages = Messages.loadMessages('@salesforce/plugin-data', 'soql.query');
3131

32-
export class DataSoqlQueryCommand extends SfCommand<unknown> {
32+
export type DataQueryResult = {
33+
records: jsforceRecord[];
34+
totalSize: number;
35+
done: boolean;
36+
outputFile?: string;
37+
};
38+
39+
export class DataSoqlQueryCommand extends SfCommand<DataQueryResult> {
3340
public static readonly summary = messages.getMessage('summary');
3441
public static readonly description = messages.getMessage('description');
3542
public static readonly examples = messages.getMessages('examples');
@@ -81,6 +88,22 @@ export class DataSoqlQueryCommand extends SfCommand<unknown> {
8188
}),
8289
'result-format': resultFormatFlag(),
8390
perflog: perflogFlag,
91+
'output-file': Flags.file({
92+
summary: messages.getMessage('flags.output-file.summary'),
93+
relationships: [
94+
{
95+
type: 'some',
96+
flags: [
97+
{
98+
name: 'result-format',
99+
// eslint-disable-next-line @typescript-eslint/require-await
100+
when: async (flags): Promise<boolean> =>
101+
flags['result-format'] === 'csv' || flags['result-format'] === 'json',
102+
},
103+
],
104+
},
105+
],
106+
}),
84107
};
85108

86109
private logger!: Logger;
@@ -100,7 +123,7 @@ export class DataSoqlQueryCommand extends SfCommand<unknown> {
100123
* the command, which are necessary for reporter selection.
101124
*
102125
*/
103-
public async run(): Promise<unknown> {
126+
public async run(): Promise<DataQueryResult> {
104127
this.logger = await Logger.child('data:soql:query');
105128
const flags = (await this.parse(DataSoqlQueryCommand)).flags;
106129

@@ -134,10 +157,17 @@ You can safely remove \`--async\` (it never had any effect on the command withou
134157
this.configAggregator.getInfo('org-max-query-limit').value as number,
135158
flags['all-rows']
136159
);
137-
if (!this.jsonEnabled()) {
138-
displayResults({ ...queryResult }, flags['result-format']);
160+
161+
if (flags['output-file'] ?? !this.jsonEnabled()) {
162+
displayResults({ ...queryResult }, flags['result-format'], flags['output-file']);
163+
}
164+
165+
if (flags['output-file']) {
166+
this.log(`${queryResult.result.totalSize} records written to ${flags['output-file']}`);
167+
return { ...queryResult.result, outputFile: flags['output-file'] };
168+
} else {
169+
return queryResult.result;
139170
}
140-
return queryResult.result;
141171
} finally {
142172
if (flags['result-format'] !== 'json') this.spinner.stop();
143173
}

src/queryUtils.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,17 @@ import { FormatTypes, JsonReporter } from './reporters/query/reporters.js';
1010
import { CsvReporter } from './reporters/query/csvReporter.js';
1111
import { HumanReporter } from './reporters/query/humanReporter.js';
1212

13-
export const displayResults = (queryResult: SoqlQueryResult, resultFormat: FormatTypes): void => {
13+
export const displayResults = (queryResult: SoqlQueryResult, resultFormat: FormatTypes, outputFile?: string): void => {
1414
let reporter: HumanReporter | JsonReporter | CsvReporter;
1515
switch (resultFormat) {
1616
case 'human':
1717
reporter = new HumanReporter(queryResult, queryResult.columns);
1818
break;
1919
case 'json':
20-
reporter = new JsonReporter(queryResult, queryResult.columns);
20+
reporter = new JsonReporter(queryResult, queryResult.columns, outputFile);
2121
break;
2222
case 'csv':
23-
reporter = new CsvReporter(queryResult, queryResult.columns);
23+
reporter = new CsvReporter(queryResult, queryResult.columns, outputFile);
2424
break;
2525
}
2626
// delegate to selected reporter

src/reporters/query/csvReporter.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77
import { EOL } from 'node:os';
8+
import * as fs from 'node:fs';
89
import { Ux } from '@salesforce/sf-plugins-core';
910
import { get, getNumber, isString } from '@salesforce/ts-types';
1011
import type { Record as jsforceRecord } from '@jsforce/jsforce-node';
@@ -13,8 +14,11 @@ import { getAggregateAliasOrName, maybeMassageAggregates } from './reporters.js'
1314
import { QueryReporter, logFields, isSubquery, isAggregate } from './reporters.js';
1415

1516
export class CsvReporter extends QueryReporter {
16-
public constructor(data: SoqlQueryResult, columns: Field[]) {
17+
private outputFile: string | undefined;
18+
19+
public constructor(data: SoqlQueryResult, columns: Field[], outputFile?: string) {
1720
super(data, columns);
21+
this.outputFile = outputFile;
1822
}
1923

2024
public display(): void {
@@ -23,12 +27,16 @@ export class CsvReporter extends QueryReporter {
2327
const preppedData = this.data.result.records.map(maybeMassageAggregates(aggregates));
2428
const attributeNames = getColumns(preppedData)(fields);
2529
const ux = new Ux();
30+
31+
let fsWritable: fs.WriteStream | undefined;
32+
if (this.outputFile) fsWritable = fs.createWriteStream(this.outputFile);
33+
2634
[
2735
// header row
2836
attributeNames.map(escape).join(SEPARATOR),
2937
// data
3038
...preppedData.map((row): string => attributeNames.map(getFieldValue(row)).join(SEPARATOR)),
31-
].map((line) => ux.log(line));
39+
].forEach((line) => (fsWritable ? fsWritable.write(line + EOL) : ux.log(line)));
3240
}
3341
}
3442

src/reporters/query/reporters.ts

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,11 @@
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77

8-
import { Logger, Messages } from '@salesforce/core';
8+
import * as fs from 'node:fs';
9+
import { Logger } from '@salesforce/core';
910
import { Ux } from '@salesforce/sf-plugins-core';
10-
import { JobInfoV2 } from '@jsforce/jsforce-node/lib/api/bulk2.js';
11-
import { capitalCase } from 'change-case';
1211
import { Field, FieldType, GenericObject, SoqlQueryResult } from '../../types.js';
1312

14-
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
15-
const reporterMessages = Messages.loadMessages('@salesforce/plugin-data', 'reporter');
16-
1713
export abstract class QueryReporter {
1814
protected logger: Logger;
1915
protected columns: Field[] = [];
@@ -27,26 +23,26 @@ export abstract class QueryReporter {
2723
}
2824

2925
export class JsonReporter extends QueryReporter {
30-
public constructor(data: SoqlQueryResult, columns: Field[]) {
26+
private outputFile: string | undefined;
27+
28+
public constructor(data: SoqlQueryResult, columns: Field[], outputFile?: string) {
3129
super(data, columns);
30+
this.outputFile = outputFile;
3231
}
3332

3433
public display(): void {
35-
new Ux().styledJSON({ status: 0, result: this.data.result });
34+
if (this.outputFile) {
35+
const fsWritable = fs.createWriteStream(this.outputFile);
36+
fsWritable.write(JSON.stringify(this.data.result, null, 2));
37+
} else {
38+
new Ux().styledJSON({ status: 0, result: this.data.result });
39+
}
3640
}
3741
}
3842

3943
export const formatTypes = ['human', 'csv', 'json'] as const;
4044
export type FormatTypes = (typeof formatTypes)[number];
4145

42-
export const getResultMessage = (jobInfo: JobInfoV2): string =>
43-
reporterMessages.getMessage('bulkV2Result', [
44-
jobInfo.id,
45-
capitalCase(jobInfo.state),
46-
jobInfo.numberRecordsProcessed,
47-
jobInfo.numberRecordsFailed,
48-
]);
49-
5046
export const isAggregate = (field: Field): boolean => field.fieldType === FieldType.functionField;
5147
export const isSubquery = (field: Field): boolean => field.fieldType === FieldType.subqueryField;
5248
const getAggregateFieldName = (field: Field): string => field.alias ?? field.name;

test/commands/data/query/query.nut.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { strict as assert } from 'node:assert';
1010
import { Dictionary, getString } from '@salesforce/ts-types';
1111
import { config, expect } from 'chai';
1212
import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit';
13+
import { validateCsv } from '../../../testUtil.js';
14+
import { DataQueryResult } from '../../../../src/commands/data/query.js';
1315

1416
config.truncateThreshold = 0;
1517

@@ -278,6 +280,38 @@ describe('data:query command', () => {
278280
expect(metadataObject).to.have.property('description');
279281
});
280282
});
283+
describe('data:query --output-file', () => {
284+
it('should output JSON to file', async () => {
285+
const queryResult = execCmd<DataQueryResult>(
286+
'data:query -q "SELECT Id,Name from Account LIMIT 3" --output-file accounts.json --result-format json --json',
287+
{
288+
ensureExitCode: 0,
289+
}
290+
).jsonOutput;
291+
292+
expect(queryResult?.result.outputFile).equals('accounts.json');
293+
const file = JSON.parse(
294+
await fs.promises.readFile(path.join(testSession.project.dir, 'accounts.json'), 'utf8')
295+
) as DataQueryResult;
296+
297+
const { outputFile, ...result } = queryResult?.result as DataQueryResult;
298+
299+
expect(file).to.deep.equal(result);
300+
});
301+
302+
it('should output CSV to file', async () => {
303+
const queryResult = execCmd(
304+
'data:query -q "SELECT Id,Name from Account LIMIT 3" --output-file accounts.csv --result-format csv',
305+
{
306+
ensureExitCode: 0,
307+
}
308+
);
309+
310+
expect(queryResult.shellOutput.stdout).includes('3 records written to accounts.csv');
311+
await validateCsv(path.join(testSession.project.dir, 'accounts.csv'), 'COMMA', 3);
312+
});
313+
});
314+
281315
describe('data:query --bulk', () => {
282316
it('should return Lead.owner.name (multi-level relationships)', () => {
283317
execCmd('data:create:record -s Lead -v "Company=Salesforce LastName=Astro"', { ensureExitCode: 0 });

0 commit comments

Comments
 (0)