Skip to content

Commit

Permalink
Multi-org reports hardis:org:multi-org-query (#858)
Browse files Browse the repository at this point in the history
New command hardis:org:multi-org-query allowing to execute a SOQL Bulk Query in multiple orgs and aggregate the results in a single CSV / XLS report
  • Loading branch information
nvuillam authored Nov 2, 2024
1 parent 3ecbde8 commit e153dff
Show file tree
Hide file tree
Showing 3 changed files with 234 additions and 3 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

Note: Can be used with `sfdx plugins:install sfdx-hardis@beta` and docker image `hardisgroupcom/sfdx-hardis@beta`

- New command hardis:org:multi-org-query allowing to execute a SOQL Bulk Query in multiple orgs and aggregate the results in a single CSV / XLS report

## [5.3.0] 2024-10-24

- Update default Monitoring workflow for GitHub
Expand Down
198 changes: 198 additions & 0 deletions src/commands/hardis/org/multi-org-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/* jscpd:ignore-start */
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
import { AuthInfo, Connection, Messages } from '@salesforce/core';
import { AnyJson } from '@salesforce/ts-types';
import c from "chalk";
import { makeSureOrgIsConnected, promptOrgList } from '../../../common/utils/orgUtils.js';
import { uxLog } from '../../../common/utils/index.js';
import { bulkQuery } from '../../../common/utils/apiUtils.js';
import { generateCsvFile, generateReportPath } from '../../../common/utils/filesUtils.js';
import { prompts } from '../../../common/utils/prompts.js';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('sfdx-hardis', 'org');

export default class MultiOrgQuery extends SfCommand<any> {
public static title = 'Multiple Orgs SOQL Query';

public static description = `Executes a SOQL query in multiple orgs and generate a single report from it`;

public static examples = [
'$ sf hardis:org:multi-org-query',
'$ sf hardis:org:multi-org-query --query "SELECT Id,Username FROM User"',
'$ sf hardis:org:multi-org-query --query "SELECT Id,Username FROM User" --target-orgs [email protected] [email protected] [email protected]'
];

public static flags: any = {
query: Flags.string({
char: 'q',
description: 'SOQL Query to run on multiple orgs',
}),
"target-orgs": Flags.string({
char: "x",
description: "List of org usernames or aliases.",
multiple: true
}),
outputfile: Flags.string({
char: 'f',
description: 'Force the path and name of output report file. Must end with .csv',
}),
debug: Flags.boolean({
char: 'd',
default: false,
description: messages.getMessage('debugMode'),
}),
websocket: Flags.string({
description: messages.getMessage('websocket'),
}),
skipauth: Flags.boolean({
description: 'Skip authentication check when a default username is required',
}),
};

protected query: string;
protected targetOrgsIds: string[] = [];
protected targetOrgs: any[] = []
protected outputFile;
protected debugMode = false;
protected allRecords: any[] = [];
protected successOrgs: any[] = [];
protected errorOrgs: any[] = [];

/* jscpd:ignore-end */

public async run(): Promise<AnyJson> {
const { flags } = await this.parse(MultiOrgQuery);
this.query = flags.query || null;
this.targetOrgsIds = flags["target-orgs"] || [];
this.outputFile = flags.outputfile || null;
this.debugMode = flags.debug || false;

// Prompt query if not specified as input argument
await this.defineSoqlQuery();

// List org if not sent as input parameter
await this.manageSelectOrgs();

// Perform the request on orgs
await this.performQueries();

// Display results
this.displayResults();

// Generate output CSV & XLS
this.outputFile = await generateReportPath('multi-org-query', this.outputFile);
const outputFilesRes = await generateCsvFile(this.allRecords, this.outputFile);

return {
allRecords: this.allRecords,
successOrgs: this.successOrgs,
errorOrgs: this.errorOrgs,
csvLogFile: this.outputFile,
xlsxLogFile: outputFilesRes.xlsxFile,
};
}

private displayResults() {
if (this.successOrgs.length > 0) {
uxLog(this, c.green(`Successfully performed query on ${this.successOrgs.length} orgs`));
for (const org of this.successOrgs) {
uxLog(this, c.grey(`- ${org.instanceUrl}`));
}
}
if (this.errorOrgs.length > 0) {
uxLog(this, c.green(`Error while performing query on ${this.errorOrgs.length} orgs`));
for (const org of this.successOrgs) {
uxLog(this, c.grey(`- ${org.instanceUrl}: ${org?.error?.message}`));
}
}
}

private async performQueries() {
for (const orgId of this.targetOrgsIds) {
const matchOrgs = this.targetOrgs.filter(org => (org.username === orgId || org.alias === orgId) && org.accessToken);
if (matchOrgs.length === 0) {
uxLog(this, c.yellow(`Skipped ${orgId}: Unable to find authentication. Run "sf org login web" to authenticate.`));
continue;
}
const accessToken = matchOrgs[0].accessToken;
const username = matchOrgs[0].username;
const instanceUrl = matchOrgs[0].instanceUrl;
const loginUrl = matchOrgs[0].loginUrl || instanceUrl;
uxLog(this, c.yellow(`Performing query on ${orgId}...`));
try {
const authInfo = await AuthInfo.create({
username: username
});
const connectionConfig: any = {
loginUrl: loginUrl,
instanceUrl: instanceUrl,
accessToken: accessToken
};
const conn = await Connection.create({ authInfo: authInfo, connectionOptions: connectionConfig });
const bulkQueryRes = await bulkQuery(this.query, conn, 5);
// Add org info to results
const records = bulkQueryRes.records.map(record => {
record.orgInstanceUrl = matchOrgs[0].instanceUrl;
record.orgAlias = matchOrgs[0].alias || "";
record.orgUser = matchOrgs[0].username || "";
return record;
});
this.allRecords.push(...records);
this.successOrgs.push({ orgId: orgId, instanceUrl: instanceUrl, username: username })
} catch (e: any) {
uxLog(this, c.red(`Error while querying ${orgId}: ${e.message}`));
this.errorOrgs.push({ org: orgId, error: e })
}

}
}

private async manageSelectOrgs() {
if (this.targetOrgsIds.length === 0) {
this.targetOrgs = await promptOrgList();
this.targetOrgsIds = this.targetOrgs.map(org => org.alias || org.username);
}

// Check orgs are connected
for (const orgId of this.targetOrgsIds) {
const matchOrgs = this.targetOrgs.filter(org => (org.username === orgId || org.alias === orgId) && org.accessToken && org.connectedStatus === 'Connected');
if (matchOrgs.length === 0) {
const orgRes = await makeSureOrgIsConnected(orgId);
this.targetOrgs.push(orgRes);
}
}
}

private async defineSoqlQuery() {
if (this.query == null) {
const activeUsersQuery = `SELECT Id, LastLoginDate, User.LastName, User.Firstname, Profile.UserLicense.Name, Profile.Name, Username, Profile.UserLicense.LicenseDefinitionKey, IsActive, CreatedDate FROM User WHERE IsActive = true ORDER BY Username ASC`;
const baseQueryPromptRes = await prompts({
type: "select",
message: "Please select a predefined query, or custom option",
choices: [
{
title: "Active users",
description: activeUsersQuery,
value: activeUsersQuery
},
{
title: "Custom SOQL Query",
description: "Enter a custom SOQL query to run",
value: "custom"
}
]
});
if (baseQueryPromptRes.value === "custom") {
const queryPromptRes = await prompts({
type: 'text',
message: 'Please input the SOQL Query to run in multiple orgs',
});
this.query = queryPromptRes.value;
}
else {
this.query = baseQueryPromptRes.value;
}
}
}
}
37 changes: 34 additions & 3 deletions src/common/utils/orgUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export async function promptOrg(
}

// Prompt user
/* jscpd:ignore-start */
const orgResponse = await prompts({
type: 'select',
name: 'org',
Expand All @@ -168,6 +169,7 @@ export async function promptOrg(
};
}),
});
/* jscpd:ignore-end */

let org = orgResponse.org;

Expand Down Expand Up @@ -245,14 +247,42 @@ export async function promptOrg(
return orgResponse.org;
}

export async function promptOrgList(options: { promptMessage?: string } = {}) {
const orgListResult = await MetadataUtils.listLocalOrgs('any');
const orgListSorted = sortArray(orgListResult?.nonScratchOrgs || [], {
by: ['username', 'alias', 'instanceUrl'],
order: ['asc', 'asc', 'asc'],
});
// Prompt user
const orgResponse = await prompts({
type: 'multiselect',
name: 'orgs',
message: c.cyanBright(options.promptMessage || 'Please select orgs'),
choices: orgListSorted.map((org: any) => {
const title = org.username || org.alias || org.instanceUrl;
const description =
(title !== org.instanceUrl ? org.instanceUrl : '') +
(org.devHubUsername ? ` (Hub: ${org.devHubUsername})` : '-');
return {
title: c.cyan(title),
description: org.descriptionForUi ? org.descriptionForUi : description || '-',
value: org,
};
}),
});
return orgResponse.orgs;
}

export async function makeSureOrgIsConnected(targetOrg: string | any) {
// Get connected Status and instance URL
let connectedStatus;
let instanceUrl;
let orgResult: any;
if (typeof targetOrg !== 'string') {
instanceUrl = targetOrg.instanceUrl;
connectedStatus = targetOrg.connectedStatus;
targetOrg = targetOrg.username;
orgResult = targetOrg;
}
else {
const displayOrgCommand = `sf org display --target-org ${targetOrg}`;
Expand All @@ -262,17 +292,18 @@ export async function makeSureOrgIsConnected(targetOrg: string | any) {
});
connectedStatus = displayResult?.result?.connectedStatus || "error";
instanceUrl = displayResult?.result?.instanceUrl || "error";
orgResult = displayResult.result
}
// Org is connected
if (connectedStatus === "Connected") {
return;
return orgResult;
}
// Authentication is necessary
if (connectedStatus?.includes("expired")) {
uxLog(this, c.yellow("Your auth token is expired, you need to authenticate again"));
const loginCommand = 'sf org login web' + ` --instance-url ${instanceUrl}`;
await execSfdxJson(loginCommand, this, { fail: true, output: false });
return;
const loginRes = await execSfdxJson(loginCommand, this, { fail: true, output: false });
return loginRes.result;
}
// We shouldn't be here :)
uxLog(this, c.yellow("What are we doing here ? Please declare an issue with the following text: " + instanceUrl + ":" + connectedStatus));
Expand Down

0 comments on commit e153dff

Please sign in to comment.