Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,14 @@
"flags": ["api-version", "batch-id", "flags-dir", "job-id", "json", "loglevel", "target-org"],
"plugin": "@salesforce/plugin-data"
},
{
"alias": [],
"command": "data:search",
"flagAliases": ["apiversion", "resultformat", "targetusername", "u"],
"flagChars": ["f", "o", "q", "r"],
"flags": ["api-version", "file", "flags-dir", "json", "loglevel", "query", "result-format", "target-org"],
"plugin": "@salesforce/plugin-data"
},
{
"alias": ["force:data:record:update"],
"command": "data:update:record",
Expand Down
43 changes: 43 additions & 0 deletions messages/data.search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# summary

Execute a SOSL text-based search query.

# description

Specify the SOSL query at the command line with the --query flag or read the query from a file with the --file flag.

By default, the results are written to the terminal in human-readable format. If you specify `--result-format csv`, the output is written to one or more CSV (comma-separated values) files. The file names correspond to the Salesforce objects in the results, such as Account.csv. Both `--result-format human` and `--result-format json` display only to the terminal.

# examples

- Specify a SOSL query at the command line; the command uses your default org:

<%= config.bin %> <%= command.id %> --query "FIND {Anna Jones} IN Name Fields RETURNING Contact (Name, Phone)"

- Read the SOSL query from a file called "query.txt"; the command uses the org with alias "my-scratch":

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

- Similar to the previous example, but write the results to one or more CSV files, depending on the Salesforce objects in the results:

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

# flags.query.summary

SOSL query to execute.

# flags.result-format.summary

Format to display the results, or to write to disk if you specify "csv".

# flags.file.summary

File that contains the SOSL query.

# displayQueryRecordsRetrieved

Total number of records retrieved: %s.

# queryRunningMessage

Querying Data
2 changes: 1 addition & 1 deletion src/bulkUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { IngestJobV2 } from '@jsforce/jsforce-node/lib/api/bulk2.js';
import { SfCommand, Spinner } from '@salesforce/sf-plugins-core';
import { Duration } from '@salesforce/kit';
import { capitalCase } from 'change-case';
import { getResultMessage } from './reporters/reporters.js';
import { getResultMessage } from './reporters/query/reporters.js';
import { BulkDataRequestCache } from './bulkDataRequestCache.js';
import type { BulkProcessedRecordV2, BulkRecordsV2 } from './types.js';

Expand Down
2 changes: 1 addition & 1 deletion src/commands/data/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export class DataSoqlQueryCommand extends SfCommand<unknown> {
'all-rows': Flags.boolean({
summary: messages.getMessage('flags.all-rows.summary'),
}),
'result-format': resultFormatFlag,
'result-format': resultFormatFlag(),
perflog: perflogFlag,
};

Expand Down
2 changes: 1 addition & 1 deletion src/commands/data/query/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export class BulkQueryReport extends SfCommand<unknown> {
'target-org': { ...optionalOrgFlagWithDeprecations, summary: queryMessages.getMessage('flags.targetOrg.summary') },
'api-version': orgApiVersionFlagWithDeprecations,
loglevel,
'result-format': resultFormatFlag,
'result-format': resultFormatFlag(),
'bulk-query-id': Flags.salesforceId({
length: 18,
char: 'i',
Expand Down
59 changes: 59 additions & 0 deletions src/commands/data/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2020, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import fs from 'node:fs';
import { Messages } from '@salesforce/core';
import type { SearchResult } from '@jsforce/jsforce-node';
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
import { orgFlags, resultFormatFlag } from '../../flags.js';
import { displaySearchResults } from '../../searchUtils.js';

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

export class DataSearchCommand extends SfCommand<SearchResult> {
public static readonly summary = messages.getMessage('summary');
public static readonly description = messages.getMessage('description');
public static readonly examples = messages.getMessages('examples');

public static readonly flags = {
...orgFlags,
query: Flags.string({
char: 'q',
summary: messages.getMessage('flags.query.summary'),
exactlyOne: ['query', 'file'],
}),
file: Flags.file({
char: 'f',
exists: true,
summary: messages.getMessage('flags.file.summary'),
exactlyOne: ['query', 'file'],
}),
'result-format': resultFormatFlag({
summary: messages.getMessage('flags.result-format.summary'),
exclusive: ['json'],
}),
};

public async run(): Promise<SearchResult> {
const flags = (await this.parse(DataSearchCommand)).flags;

try {
// --file will be present if flags.query isn't. Oclif exactlyOne isn't quite that clever
const queryString = flags.query ?? fs.readFileSync(flags.file as string, 'utf8');
const conn = flags['target-org'].getConnection(flags['api-version']);
if (flags['result-format'] !== 'json') this.spinner.start(messages.getMessage('queryRunningMessage'));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it the result format or the json-enabled-ness that matters for spinners?

ex: Imagine I want --result-format csv --json because I'm a CI script. I want to get that 0/1 and file location as json output BUT I want a csv file written. I wouldn't want a spinner.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is carried from data query, but this.spinner should be respecting the --json flag

const queryResult = await conn.search(queryString);
if (!this.jsonEnabled()) {
displaySearchResults(queryResult, flags['result-format']);
}
return queryResult;
} finally {
if (flags['result-format'] !== 'json') this.spinner.stop();
}
}
}
6 changes: 3 additions & 3 deletions src/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
orgApiVersionFlagWithDeprecations,
requiredOrgFlagWithDeprecations,
} from '@salesforce/sf-plugins-core';
import { formatTypes } from './reporters/reporters.js';
import { FormatTypes, formatTypes } from './reporters/query/reporters.js';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('@salesforce/plugin-data', 'messages');
Expand All @@ -32,14 +32,14 @@ export const orgFlags = {
loglevel,
};

export const resultFormatFlag = Flags.option({
export const resultFormatFlag = Flags.custom<FormatTypes>({
char: 'r',
summary: messages.getMessage('flags.resultFormat.summary'),
options: formatTypes,
default: 'human',
aliases: ['resultformat'],
deprecateAliases: true,
})();
});

export const prefixValidation = (i: string): Promise<string> => {
if (i.includes('/') || i.includes('\\')) {
Expand Down
6 changes: 3 additions & 3 deletions src/queryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
*/
import type { Record } from '@jsforce/jsforce-node';
import { Field, FieldType, SoqlQueryResult } from './types.js';
import { FormatTypes, JsonReporter } from './reporters/reporters.js';
import { CsvReporter } from './reporters/csvReporter.js';
import { HumanReporter } from './reporters/humanReporter.js';
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 => {
let reporter: HumanReporter | JsonReporter | CsvReporter;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { EOL } from 'node:os';
import { Ux } from '@salesforce/sf-plugins-core';
import { get, getNumber, isString } from '@salesforce/ts-types';
import type { Record as jsforceRecord } from '@jsforce/jsforce-node';
import type { Field, SoqlQueryResult } from '../types.js';
import type { Field, SoqlQueryResult } from '../../types.js';
import { getAggregateAliasOrName, maybeMassageAggregates } from './reporters.js';
import { QueryReporter, logFields, isSubquery, isAggregate } from './reporters.js';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import ansis from 'ansis';
import { get, getArray, isPlainObject, isString, Optional } from '@salesforce/ts-types';
import { Messages } from '@salesforce/core';
import type { Record as jsforceRecord } from '@jsforce/jsforce-node';
import { GenericEntry, GenericObject, Field, FieldType, SoqlQueryResult } from '../types.js';
import { GenericEntry, GenericObject, Field, FieldType, SoqlQueryResult } from '../../types.js';
import { QueryReporter, logFields, isSubquery, isAggregate, getAggregateAliasOrName } from './reporters.js';
import { maybeMassageAggregates } from './reporters.js';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Logger, Messages } 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';
import { Field, FieldType, GenericObject, SoqlQueryResult } from '../../types.js';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const reporterMessages = Messages.loadMessages('@salesforce/plugin-data', 'reporter');
Expand Down
29 changes: 29 additions & 0 deletions src/reporters/search/csvSearchReporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2023, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import os from 'node:os';
import fs from 'node:fs';
import { SearchResult } from '@jsforce/jsforce-node';
import { SearchReporter } from './reporter.js';

export class CsvSearchReporter extends SearchReporter {
public constructor(props: SearchResult) {
super(props);
}

public display(): void {
if (this.typeRecordsMap.size === 0) {
this.ux.log('No Records Found');
}
this.typeRecordsMap.forEach((records, type) => {
this.ux.log(`Written to ${type}.csv`);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wouldn't the empty string be really bad here?

const cols = Object.keys(records[0]).join(',');
const body = records.map((r) => Object.values(r).join(',')).join(os.EOL);

fs.writeFileSync(`${type}.csv`, [cols, body].join(os.EOL));
});
}
}
28 changes: 28 additions & 0 deletions src/reporters/search/humanSearchReporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2023, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import { SearchResult } from '@jsforce/jsforce-node';
import { SearchReporter } from './reporter.js';

export class HumanSearchReporter extends SearchReporter {
public constructor(props: SearchResult) {
super(props);
}

public display(): void {
if (this.typeRecordsMap.size === 0) {
this.ux.log('No Records Found');
}
this.typeRecordsMap.forEach((records, type) => {
// to find the columns of the query, parse the keys of the first record
this.ux.table(records, Object.fromEntries(Object.keys(records[0]).map((k) => [k, { header: k }])), {
'no-truncate': true,
title: type,
});
this.ux.log();
});
}
}
35 changes: 35 additions & 0 deletions src/reporters/search/reporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2023, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import { Ux } from '@salesforce/sf-plugins-core';
import { Record, SearchResult } from '@jsforce/jsforce-node';
import { ensureString } from '@salesforce/ts-types';
import { omit } from '@salesforce/kit';

export abstract class SearchReporter {
public typeRecordsMap: Map<string, Record[]> = new Map<string, Record[]>();
public ux = new Ux();
protected constructor(public result: SearchResult) {
this.result.searchRecords.map((r) => {
const type = ensureString(r.attributes?.type);
return this.typeRecordsMap.has(type)
? // the extra info in 'attributes' causes issues when creating generic csv/table columns
this.typeRecordsMap.get(type)!.push(omit(r, 'attributes'))
: this.typeRecordsMap.set(type, [omit(r, 'attributes')]);
});
}
public abstract display(): void;
}

export class JsonSearchReporter extends SearchReporter {
public constructor(props: SearchResult) {
super(props);
}

public display(): void {
this.ux.styledJSON(this.result);
}
}
28 changes: 28 additions & 0 deletions src/searchUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2023, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import type { SearchResult } from '@jsforce/jsforce-node';
import { HumanSearchReporter } from './reporters/search/humanSearchReporter.js';
import { JsonSearchReporter } from './reporters/search/reporter.js';
import { CsvSearchReporter } from './reporters/search/csvSearchReporter.js';

export const displaySearchResults = (queryResult: SearchResult, resultFormat: 'human' | 'json' | 'csv'): void => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you wanted to do this without classes, this would be a function that decides which reporter function(ux, results) to call.

then you wouldn't need all the new and display() etc both here and in the tests

let reporter: HumanSearchReporter | JsonSearchReporter | CsvSearchReporter;

switch (resultFormat) {
case 'human':
reporter = new HumanSearchReporter(queryResult);
break;
case 'csv':
reporter = new CsvSearchReporter(queryResult);
break;
case 'json':
reporter = new JsonSearchReporter(queryResult);
break;
}
// delegate to selected reporter
reporter.display();
};
3 changes: 0 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,6 @@ export type Field = {
*/
export type SoqlQueryResult = {
query: string;
// an id can be present when a bulk query times out
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore jsforce v2 types are too strict for running general queries
result: QueryResult<jsRecord> & { id?: string };
columns: Field[];
};
Expand Down
Loading