From 324c5a4c920bccfa8664e642dcbb3584d3f59166 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez Date: Thu, 28 Nov 2024 12:49:03 -0300 Subject: [PATCH 1/4] feat(data:query): add `--output-file` flag --- messages/reporter.md | 3 -- messages/soql.query.md | 4 +++ src/commands/data/query.ts | 40 +++++++++++++++++++++++---- src/queryUtils.ts | 6 ++-- src/reporters/query/csvReporter.ts | 12 ++++++-- src/reporters/query/reporters.ts | 28 ++++++++----------- test/commands/data/query/query.nut.ts | 34 +++++++++++++++++++++++ 7 files changed, 98 insertions(+), 29 deletions(-) delete mode 100644 messages/reporter.md diff --git a/messages/reporter.md b/messages/reporter.md deleted file mode 100644 index cacfd643..00000000 --- a/messages/reporter.md +++ /dev/null @@ -1,3 +0,0 @@ -# bulkV2Result - -Job %s | Status %s | Records processed %d | Records failed %d diff --git a/messages/soql.query.md b/messages/soql.query.md index 2ef6cded..e278fd53 100644 --- a/messages/soql.query.md +++ b/messages/soql.query.md @@ -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. + # displayQueryRecordsRetrieved Total number of records retrieved: %s. diff --git a/src/commands/data/query.ts b/src/commands/data/query.ts index f82f9076..b19c7952 100644 --- a/src/commands/data/query.ts +++ b/src/commands/data/query.ts @@ -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 { +export type DataQueryResult = { + records: jsforceRecord[]; + totalSize: number; + done: boolean; + outputFile?: string; +}; + +export class DataSoqlQueryCommand extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); public static readonly examples = messages.getMessages('examples'); @@ -81,6 +88,22 @@ export class DataSoqlQueryCommand extends SfCommand { }), '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 => + flags['result-format'] === 'csv' || flags['result-format'] === 'json', + }, + ], + }, + ], + }), }; private logger!: Logger; @@ -100,7 +123,7 @@ export class DataSoqlQueryCommand extends SfCommand { * the command, which are necessary for reporter selection. * */ - public async run(): Promise { + public async run(): Promise { this.logger = await Logger.child('data:soql:query'); const flags = (await this.parse(DataSoqlQueryCommand)).flags; @@ -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(); } diff --git a/src/queryUtils.ts b/src/queryUtils.ts index 2f789402..141fc1e9 100644 --- a/src/queryUtils.ts +++ b/src/queryUtils.ts @@ -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 diff --git a/src/reporters/query/csvReporter.ts b/src/reporters/query/csvReporter.ts index 956f2f06..1021d2c6 100644 --- a/src/reporters/query/csvReporter.ts +++ b/src/reporters/query/csvReporter.ts @@ -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'; @@ -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 { @@ -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))); } } diff --git a/src/reporters/query/reporters.ts b/src/reporters/query/reporters.ts index a448dd3d..19a07b46 100644 --- a/src/reporters/query/reporters.ts +++ b/src/reporters/query/reporters.ts @@ -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[] = []; @@ -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; diff --git a/test/commands/data/query/query.nut.ts b/test/commands/data/query/query.nut.ts index 64c40f39..c46d7299 100644 --- a/test/commands/data/query/query.nut.ts +++ b/test/commands/data/query/query.nut.ts @@ -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; @@ -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( + '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 }); From 334f50b632bbb86005a8369935972fcc4b240667 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez Date: Thu, 28 Nov 2024 13:11:56 -0300 Subject: [PATCH 2/4] fix: update command snapshot --- command-snapshot.json | 1 + 1 file changed, 1 insertion(+) diff --git a/command-snapshot.json b/command-snapshot.json index a2be070f..7bfbe7c0 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -213,6 +213,7 @@ "flags-dir", "json", "loglevel", + "output-file", "perflog", "query", "result-format", From 2c0e86f932828c9235c7bbd585b4869a2962452f Mon Sep 17 00:00:00 2001 From: Cristian Dominguez <6853656+cristiand391@users.noreply.github.com> Date: Tue, 3 Dec 2024 11:03:21 -0300 Subject: [PATCH 3/4] Update messages/soql.query.md Co-authored-by: Juliet Shackell <63259011+jshackell-sfdc@users.noreply.github.com> --- messages/soql.query.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/soql.query.md b/messages/soql.query.md index e278fd53..29028abe 100644 --- a/messages/soql.query.md +++ b/messages/soql.query.md @@ -58,7 +58,7 @@ Time to wait for the command to finish, in minutes. # flags.output-file.summary -File where records are written. +File where records are written; only CSV and JSON output formats are supported. # displayQueryRecordsRetrieved From 08e2c062ed430273d7ec00269a6ff12bdc826d4b Mon Sep 17 00:00:00 2001 From: Cristian Dominguez Date: Tue, 3 Dec 2024 11:08:10 -0300 Subject: [PATCH 4/4] chore: updata `data query` example --- messages/soql.query.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/messages/soql.query.md b/messages/soql.query.md index 29028abe..f85ba3fe 100644 --- a/messages/soql.query.md +++ b/messages/soql.query.md @@ -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: @@ -58,7 +58,7 @@ 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. +File where records are written; only CSV and JSON output formats are supported. # displayQueryRecordsRetrieved