Skip to content

Commit

Permalink
Merge pull request #1135 from salesforcecli/cd/data-query-file-output
Browse files Browse the repository at this point in the history
feat(data:query): add `--output-file` flag
  • Loading branch information
soridalac authored Dec 6, 2024
2 parents ef2ce82 + 08e2c06 commit b212d0c
Show file tree
Hide file tree
Showing 8 changed files with 101 additions and 31 deletions.
1 change: 1 addition & 0 deletions command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@
"flags-dir",
"json",
"loglevel",
"output-file",
"perflog",
"query",
"result-format",
Expand Down
3 changes: 0 additions & 3 deletions messages/reporter.md

This file was deleted.

8 changes: 6 additions & 2 deletions messages/soql.query.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ When using --bulk, the command waits 3 minutes by default for the query to compl

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

- Read the SOQL query from a file called "query.txt"; the command uses the org with alias "my-scratch":
- 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":

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

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

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

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

# flags.output-file.summary

File where records are written; only CSV and JSON output formats are supported.

# displayQueryRecordsRetrieved

Total number of records retrieved: %s.
Expand Down
40 changes: 35 additions & 5 deletions src/commands/data/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,14 @@ import { BulkQueryRequestCache } from '../../bulkDataRequestCache.js';
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('@salesforce/plugin-data', 'soql.query');

export class DataSoqlQueryCommand extends SfCommand<unknown> {
export type DataQueryResult = {
records: jsforceRecord[];
totalSize: number;
done: boolean;
outputFile?: string;
};

export class DataSoqlQueryCommand extends SfCommand<DataQueryResult> {
public static readonly summary = messages.getMessage('summary');
public static readonly description = messages.getMessage('description');
public static readonly examples = messages.getMessages('examples');
Expand Down Expand Up @@ -81,6 +88,22 @@ export class DataSoqlQueryCommand extends SfCommand<unknown> {
}),
'result-format': resultFormatFlag(),
perflog: perflogFlag,
'output-file': Flags.file({
summary: messages.getMessage('flags.output-file.summary'),
relationships: [
{
type: 'some',
flags: [
{
name: 'result-format',
// eslint-disable-next-line @typescript-eslint/require-await
when: async (flags): Promise<boolean> =>
flags['result-format'] === 'csv' || flags['result-format'] === 'json',
},
],
},
],
}),
};

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

Expand Down Expand Up @@ -134,10 +157,17 @@ You can safely remove \`--async\` (it never had any effect on the command withou
this.configAggregator.getInfo('org-max-query-limit').value as number,
flags['all-rows']
);
if (!this.jsonEnabled()) {
displayResults({ ...queryResult }, flags['result-format']);

if (flags['output-file'] ?? !this.jsonEnabled()) {
displayResults({ ...queryResult }, flags['result-format'], flags['output-file']);
}

if (flags['output-file']) {
this.log(`${queryResult.result.totalSize} records written to ${flags['output-file']}`);
return { ...queryResult.result, outputFile: flags['output-file'] };
} else {
return queryResult.result;
}
return queryResult.result;
} finally {
if (flags['result-format'] !== 'json') this.spinner.stop();
}
Expand Down
6 changes: 3 additions & 3 deletions src/queryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@ import { FormatTypes, JsonReporter } from './reporters/query/reporters.js';
import { CsvReporter } from './reporters/query/csvReporter.js';
import { HumanReporter } from './reporters/query/humanReporter.js';

export const displayResults = (queryResult: SoqlQueryResult, resultFormat: FormatTypes): void => {
export const displayResults = (queryResult: SoqlQueryResult, resultFormat: FormatTypes, outputFile?: string): void => {
let reporter: HumanReporter | JsonReporter | CsvReporter;
switch (resultFormat) {
case 'human':
reporter = new HumanReporter(queryResult, queryResult.columns);
break;
case 'json':
reporter = new JsonReporter(queryResult, queryResult.columns);
reporter = new JsonReporter(queryResult, queryResult.columns, outputFile);
break;
case 'csv':
reporter = new CsvReporter(queryResult, queryResult.columns);
reporter = new CsvReporter(queryResult, queryResult.columns, outputFile);
break;
}
// delegate to selected reporter
Expand Down
12 changes: 10 additions & 2 deletions src/reporters/query/csvReporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import { EOL } from 'node:os';
import * as fs from 'node:fs';
import { Ux } from '@salesforce/sf-plugins-core';
import { get, getNumber, isString } from '@salesforce/ts-types';
import type { Record as jsforceRecord } from '@jsforce/jsforce-node';
Expand All @@ -13,8 +14,11 @@ import { getAggregateAliasOrName, maybeMassageAggregates } from './reporters.js'
import { QueryReporter, logFields, isSubquery, isAggregate } from './reporters.js';

export class CsvReporter extends QueryReporter {
public constructor(data: SoqlQueryResult, columns: Field[]) {
private outputFile: string | undefined;

public constructor(data: SoqlQueryResult, columns: Field[], outputFile?: string) {
super(data, columns);
this.outputFile = outputFile;
}

public display(): void {
Expand All @@ -23,12 +27,16 @@ export class CsvReporter extends QueryReporter {
const preppedData = this.data.result.records.map(maybeMassageAggregates(aggregates));
const attributeNames = getColumns(preppedData)(fields);
const ux = new Ux();

let fsWritable: fs.WriteStream | undefined;
if (this.outputFile) fsWritable = fs.createWriteStream(this.outputFile);

[
// header row
attributeNames.map(escape).join(SEPARATOR),
// data
...preppedData.map((row): string => attributeNames.map(getFieldValue(row)).join(SEPARATOR)),
].map((line) => ux.log(line));
].forEach((line) => (fsWritable ? fsWritable.write(line + EOL) : ux.log(line)));
}
}

Expand Down
28 changes: 12 additions & 16 deletions src/reporters/query/reporters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,11 @@
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

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

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const reporterMessages = Messages.loadMessages('@salesforce/plugin-data', 'reporter');

export abstract class QueryReporter {
protected logger: Logger;
protected columns: Field[] = [];
Expand All @@ -27,26 +23,26 @@ export abstract class QueryReporter {
}

export class JsonReporter extends QueryReporter {
public constructor(data: SoqlQueryResult, columns: Field[]) {
private outputFile: string | undefined;

public constructor(data: SoqlQueryResult, columns: Field[], outputFile?: string) {
super(data, columns);
this.outputFile = outputFile;
}

public display(): void {
new Ux().styledJSON({ status: 0, result: this.data.result });
if (this.outputFile) {
const fsWritable = fs.createWriteStream(this.outputFile);
fsWritable.write(JSON.stringify(this.data.result, null, 2));
} else {
new Ux().styledJSON({ status: 0, result: this.data.result });
}
}
}

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

export const getResultMessage = (jobInfo: JobInfoV2): string =>
reporterMessages.getMessage('bulkV2Result', [
jobInfo.id,
capitalCase(jobInfo.state),
jobInfo.numberRecordsProcessed,
jobInfo.numberRecordsFailed,
]);

export const isAggregate = (field: Field): boolean => field.fieldType === FieldType.functionField;
export const isSubquery = (field: Field): boolean => field.fieldType === FieldType.subqueryField;
const getAggregateFieldName = (field: Field): string => field.alias ?? field.name;
Expand Down
34 changes: 34 additions & 0 deletions test/commands/data/query/query.nut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { strict as assert } from 'node:assert';
import { Dictionary, getString } from '@salesforce/ts-types';
import { config, expect } from 'chai';
import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit';
import { validateCsv } from '../../../testUtil.js';
import { DataQueryResult } from '../../../../src/commands/data/query.js';

config.truncateThreshold = 0;

Expand Down Expand Up @@ -278,6 +280,38 @@ describe('data:query command', () => {
expect(metadataObject).to.have.property('description');
});
});
describe('data:query --output-file', () => {
it('should output JSON to file', async () => {
const queryResult = execCmd<DataQueryResult>(
'data:query -q "SELECT Id,Name from Account LIMIT 3" --output-file accounts.json --result-format json --json',
{
ensureExitCode: 0,
}
).jsonOutput;

expect(queryResult?.result.outputFile).equals('accounts.json');
const file = JSON.parse(
await fs.promises.readFile(path.join(testSession.project.dir, 'accounts.json'), 'utf8')
) as DataQueryResult;

const { outputFile, ...result } = queryResult?.result as DataQueryResult;

expect(file).to.deep.equal(result);
});

it('should output CSV to file', async () => {
const queryResult = execCmd(
'data:query -q "SELECT Id,Name from Account LIMIT 3" --output-file accounts.csv --result-format csv',
{
ensureExitCode: 0,
}
);

expect(queryResult.shellOutput.stdout).includes('3 records written to accounts.csv');
await validateCsv(path.join(testSession.project.dir, 'accounts.csv'), 'COMMA', 3);
});
});

describe('data:query --bulk', () => {
it('should return Lead.owner.name (multi-level relationships)', () => {
execCmd('data:create:record -s Lead -v "Company=Salesforce LastName=Astro"', { ensureExitCode: 0 });
Expand Down

0 comments on commit b212d0c

Please sign in to comment.