sfdx-hardis
Version:
Swiss-army-knife Toolbox for Salesforce. Allows you to define a complete CD/CD Pipeline. Orchestrate base commands and assist users with interactive wizards
627 lines (624 loc) • 32.2 kB
JavaScript
import path from 'path';
import { Flags, SfCommand, requiredOrgFlagWithDeprecations } from '@salesforce/sf-plugins-core';
import { Messages } from '@salesforce/core';
import c from 'chalk';
import sortArray from 'sort-array';
import { generateReports, isCI, uxLog, uxLogTable } from '../../../common/utils/index.js';
import { soqlQuery, soqlQueryTooling } from '../../../common/utils/apiUtils.js';
import { prompts } from '../../../common/utils/prompts.js';
import { createXlsxFromCsvFiles, generateCsvFile, generateReportPath } from '../../../common/utils/filesUtils.js';
import { WebSocketClient } from '../../../common/websocketClient.js';
import { listOrgSObjects } from '../../../common/utils/orgUtils.js';
import { isManagedApiName } from '../../../common/utils/projectUtils.js';
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages("sfdx-hardis", "org");
export default class HardisDocObjectFieldUsage extends SfCommand {
static description = `
## Command Behavior
**Analyzes how populated fields are for a specific Salesforce object.**
This command focuses on one or more sObjects and measures how many records populate each non-required field. It is useful for understanding data completeness before refactoring, cleaning up unused fields, or preparing migration plans.
- **Target Org:** Use \`--target-org\` to pick the org connection context.
- **Multiple sObjects:** Provide one or more API names via \`--objects\` (comma-separated) to analyze several objects in one run.
- **Per-field Counts:** Performs one overall record count and one per-field count with \`SELECT COUNT() FROM <sObject> WHERE <field> != null\`, skipping required or non-filterable fields.
- **Field Distributions:** Combine \`--objects <singleObject>\` with \`--fields FieldA,FieldB\` to group by those fields and list distinct values with their record counts and usage percentages.
- **Reporting:** Generates CSV/XLSX reports and prints a summary table with per-field population rates.
`;
static examples = [
'$ sf hardis:doc:object-field-usage',
'$ sf hardis:doc:object-field-usage --objects Account,Contact',
'$ sf hardis:doc:object-field-usage --target-org myOrgAlias --objects CustomObject__c',
'$ sf hardis:doc:object-field-usage --objects Account --fields SalesRegionAcct__c,Region__c',
];
static flags = {
'target-org': requiredOrgFlagWithDeprecations,
objects: Flags.string({
char: 'o',
description: 'Comma-separated API names of the sObjects to analyze (e.g. Account,CustomObject__c). If omitted, an interactive prompt will list available objects.',
required: false,
}),
fields: Flags.string({
char: 'f',
description: 'Comma-separated API names of fields to analyze (requires exactly one --objects value)',
required: false,
}),
websocket: Flags.string({
description: messages.getMessage("websocket"),
}),
skipauth: Flags.boolean({
description: "Skip authentication check when a default username is required",
}),
};
identifierRegex = /^[A-Za-z][A-Za-z0-9_]*$/;
compositeBatchSize = 5;
toolingCustomFieldBatchSize = 80;
objectsPromptSentinel = '__PROMPT_OBJECTS__';
formatDateOnly(value) {
if (!value) {
return 'N/A';
}
try {
return new Date(value).toISOString().split('T')[0];
}
catch {
return 'N/A';
}
}
async fetchFieldCreatedDates(connection, sObjectName, fieldApiNames) {
const createdDateByField = {};
if (!fieldApiNames.length) {
return createdDateByField;
}
// We only analyze custom fields in this command, and Tooling CustomField exposes CreatedDate.
// FieldDefinition does NOT expose CreatedDate in Tooling API.
try {
const objectDeveloperName = sObjectName.endsWith('__c') ? sObjectName.replace(/__c$/, '') : sObjectName;
const customObjectRes = await soqlQueryTooling(`SELECT Id FROM CustomObject WHERE DeveloperName = '${objectDeveloperName.replace(/'/g, "\\'")}' LIMIT 1`, connection);
const tableEnumOrId = customObjectRes?.records?.[0]?.Id || sObjectName;
const requestedApiNames = fieldApiNames.filter((name) => typeof name === 'string' && name.length > 0);
const developerNames = Array.from(new Set(requestedApiNames
.filter((name) => name.endsWith('__c'))
.map((name) => {
if (isManagedApiName(name)) {
// ns__Field__c -> DeveloperName = Field
const parts = name.split('__');
return parts[1] || name.replace(/__c$/, '');
}
return name.replace(/__c$/, '');
})));
for (let i = 0; i < developerNames.length; i += this.toolingCustomFieldBatchSize) {
const chunk = developerNames.slice(i, i + this.toolingCustomFieldBatchSize);
const inClause = chunk.map((name) => `'${name.replace(/'/g, "\\'")}'`).join(',');
const query = `SELECT DeveloperName, NamespacePrefix, CreatedDate ` +
`FROM CustomField ` +
`WHERE TableEnumOrId = '${String(tableEnumOrId).replace(/'/g, "\\'")}' ` +
`AND DeveloperName IN (${inClause})`;
const res = await soqlQueryTooling(query, connection);
for (const record of res?.records || []) {
const devName = record?.DeveloperName;
if (!devName) {
continue;
}
const ns = record?.NamespacePrefix;
const apiName = `${ns ? ns + '__' : ''}${devName}__c`;
createdDateByField[apiName] = this.formatDateOnly(record?.CreatedDate);
}
}
}
catch (error) {
uxLog('warning', this, c.yellow(`Unable to query Tooling CustomField for created dates on ${sObjectName}. ` +
`Created Date column will be set to N/A. Error: ${error?.message || error}`));
}
return createdDateByField;
}
validateIdentifier(identifier, label) {
if (!this.identifierRegex.test(identifier)) {
throw new Error(`Invalid ${label} name: ${identifier}`);
}
}
extractCount(result) {
if (!result) {
return 0;
}
if (typeof result.totalSize === 'number') {
return Number(result.totalSize);
}
const records = result.records || [];
if (records.length > 0) {
const firstRecord = records[0];
if (firstRecord && typeof firstRecord.expr0 !== 'undefined') {
return Number(firstRecord.expr0);
}
const firstKey = Object.keys(firstRecord || {})[0];
if (firstKey && typeof firstRecord[firstKey] !== 'undefined') {
return Number(firstRecord[firstKey]);
}
}
return 0;
}
async countRecords(connection, sObjectName, useTooling, fieldName) {
const whereClause = fieldName ? ` WHERE ${fieldName} != null` : '';
const query = `SELECT COUNT() FROM ${sObjectName}${whereClause}`;
const result = useTooling ? await soqlQueryTooling(query, connection) : await soqlQuery(query, connection);
return this.extractCount(result);
}
async countFieldsWithComposite(connection, sObjectName, fields, useTooling, skippedFields, batchSize = this.compositeBatchSize) {
const counts = {};
if (!fields.length) {
return counts;
}
const apiVersion = connection.getApiVersion();
const basePath = useTooling ? `/services/data/v${apiVersion}/tooling` : `/services/data/v${apiVersion}`;
const compositeEndpoint = `${basePath}/composite`;
const totalBatches = Math.ceil(fields.length / batchSize) || 1;
for (let i = 0; i < fields.length; i += batchSize) {
const batch = fields.slice(i, i + batchSize);
const referenceMap = {};
uxLog("log", this, c.grey(`Processing ${sObjectName}: batch ${Math.floor(i / batchSize) + 1}/${totalBatches} ` +
`(${i + 1}-${i + batch.length} / ${fields.length} fields)`));
const compositeRequest = batch.map((field, idx) => {
const referenceId = `ref${i + idx}`;
referenceMap[referenceId] = field.name;
const batchQuery = `SELECT COUNT() FROM ${sObjectName} WHERE ${field.name} != null`;
const encodedQuery = encodeURIComponent(batchQuery);
return {
method: 'GET',
url: `${basePath}/query/?q=${encodedQuery}`,
referenceId,
};
});
const payload = { compositeRequest };
try {
const response = await connection.request({
method: 'POST',
url: compositeEndpoint,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
const compositeResponse = response?.compositeResponse || [];
compositeResponse.forEach((item) => {
const fieldName = referenceMap[item.referenceId];
if (!fieldName) {
return;
}
if (item.httpStatusCode >= 200 && item.httpStatusCode < 300) {
counts[fieldName] = this.extractCount(item.body);
}
else {
const errorMessage = item.body?.message ||
item.body?.[0]?.message ||
(item.errors && item.errors[0]?.message) ||
'Unknown error';
const warning = `Composite query failed for ${fieldName}: ${errorMessage}`;
uxLog("warning", this, c.yellow(warning));
skippedFields.push({ sObjectName, fieldName, reason: warning });
}
});
}
catch (error) {
const warning = `Composite request batch starting at index ${i} failed: ${error.message}`;
uxLog("warning", this, c.yellow(warning));
batch.forEach((field) => skippedFields.push({ sObjectName, fieldName: field.name, reason: warning }));
}
}
return counts;
}
async queryFieldDistribution(connection, sObjectName, fieldName, useTooling) {
const query = `SELECT ${fieldName}, COUNT(Id) FROM ${sObjectName} GROUP BY ${fieldName} ORDER BY COUNT(Id) DESC LIMIT 1000`;
return useTooling ? soqlQueryTooling(query, connection) : soqlQuery(query, connection);
}
isInvalidTypeError(error) {
const message = (error?.message || '').toLowerCase();
const name = (error?.name || error?.code || '').toLowerCase();
return (name === 'invalid_type' ||
message.includes('sobject type') ||
message.includes('unknown type') ||
message.includes('is not supported') ||
message.includes('invalid type'));
}
async describeTarget(connection, sObjectName) {
try {
const describeResult = await connection.describe(sObjectName);
return { describeResult, useTooling: false };
}
catch (error) {
if (!this.isInvalidTypeError(error)) {
throw error;
}
uxLog("warning", this, c.yellow(`Standard API describe failed for ${sObjectName}. Retrying with Tooling API...`));
const describeResult = await connection.tooling.describe(sObjectName);
return { describeResult, useTooling: true };
}
}
async processSingleFieldAnalysis(connection, sObjectName, fieldDescribe, useTooling) {
const fieldName = fieldDescribe.name;
uxLog("action", this, c.cyan(`Computing distribution for ${sObjectName}.${fieldName}...`));
const totalRecords = await this.countRecords(connection, sObjectName, useTooling);
const distributionResult = await this.queryFieldDistribution(connection, sObjectName, fieldName, useTooling);
const rows = (distributionResult.records || []).map((record) => {
const rawValue = typeof record[fieldName] === 'undefined' ? null : record[fieldName];
const recordCount = Number(record.valueCount ?? record.expr0 ?? 0);
const percentage = totalRecords === 0 ? 0 : (recordCount / totalRecords) * 100;
return {
sObjectName,
fieldApiName: fieldName,
fieldLabel: fieldDescribe.label || fieldName,
fieldValue: rawValue === null ? '(null)' : rawValue,
recordCount,
percentageNumeric: percentage,
percentage: `${percentage.toFixed(2)}%`,
};
});
const columns = [
{ key: 'fieldValue', header: 'Field Value' },
{ key: 'recordCount', header: 'Record Count' },
{ key: 'percentage', header: 'Percentage' },
];
uxLog("log", this, c.cyan(`Found ${rows.length} distinct values for ${fieldName}.`));
uxLog("log", this, c.cyan(`Total ${sObjectName} records: ${totalRecords}.`));
// uxLogTable(this, rows, columns.map((col) => col.key));
const reportFiles = await generateReports(rows, columns, this, {
logFileName: `object-field-distribution-${sObjectName}-${fieldName}`,
logLabel: `Field distribution for ${sObjectName}.${fieldName}`,
});
return {
outputString: `Processed value distribution for ${sObjectName}.${fieldName}.`,
result: rows,
reportFiles,
totalRecords,
};
}
async processObjectFieldUsage(connection, sObjectName, eligibleFields, useTooling) {
if (eligibleFields.length === 0) {
uxLog("warning", this, c.yellow(`No eligible fields found on ${sObjectName}; skipping.`));
return { rows: [], totalRecords: 0, skippedFields: [] };
}
uxLog("log", this, c.cyan(`Counting total ${sObjectName} records...`));
const totalRecords = await this.countRecords(connection, sObjectName, useTooling);
const skippedFields = [];
const fieldCreatedDates = await this.fetchFieldCreatedDates(connection, sObjectName, eligibleFields.map((f) => f.name).filter((name) => typeof name === 'string' && name.length > 0));
const fieldCounts = await this.countFieldsWithComposite(connection, sObjectName, eligibleFields, useTooling, skippedFields);
const rows = [];
for (const field of eligibleFields) {
const populatedRecords = fieldCounts[field.name];
if (typeof populatedRecords !== 'number') {
const warning = `Skipping field ${field.name} on ${sObjectName} because composite response had no count.`;
uxLog("warning", this, c.yellow(warning));
skippedFields.push({ sObjectName, fieldName: field.name, reason: warning });
continue;
}
const percentage = totalRecords === 0 ? 0 : (populatedRecords / totalRecords) * 100;
rows.push({
sObjectName,
fieldApiName: field.name,
fieldLabel: field.label || field.name,
fieldCreatedDate: fieldCreatedDates[field.name] || 'N/A',
totalRecords,
populatedRecords,
populatedPercentageNumeric: percentage,
populatedPercentage: `${percentage.toFixed(2)}%`,
});
}
// const columns = [
// { key: 'fieldApiName', header: 'Field API Name' },
// { key: 'fieldLabel', header: 'Field Label' },
// { key: 'populatedRecords', header: 'Populated Records' },
// { key: 'populatedPercentage', header: 'Populated %' },
// ];
const resultSorted = sortArray(rows, {
by: ['populatedPercentageNumeric'],
order: ['desc'],
});
uxLog("log", this, c.grey(`Computed population metrics for ${resultSorted.length} fields on ${sObjectName}.`));
uxLog("log", this, c.grey(`Total records for ${sObjectName}: ${totalRecords}.`));
//uxLogTable(this, resultSorted, columns.map((col) => col.key));
return { rows: resultSorted, totalRecords, skippedFields };
}
async generateFieldUsageCsvReport(rows, reportName, fileTitle) {
const csvPath = await generateReportPath(reportName, '', { withDate: true });
const csvData = rows.map((row) => ({
sObjectName: row.sObjectName,
fieldApiName: row.fieldApiName,
fieldLabel: row.fieldLabel,
fieldCreatedDate: row.fieldCreatedDate,
totalRecords: row.totalRecords,
populatedRecords: row.populatedRecords,
populatedPercentage: row.populatedPercentage,
}));
await generateCsvFile(csvData, csvPath, {
fileTitle,
noExcel: true,
skipNotifyToWebSocket: true,
});
return csvPath;
}
filterDescribeFields(fields) {
if (!Array.isArray(fields)) {
return [];
}
return fields.filter((field) => {
if (!field?.name || typeof field.name !== 'string') {
return false;
}
if (!this.identifierRegex.test(field.name)) {
return false;
}
if (!field.custom) {
return false;
}
if (field.calculated || field.deprecatedAndHidden) {
return false;
}
if (!field.nillable || !field.filterable) {
return false;
}
if (field.compoundFieldName && field.compoundFieldName !== field.name) {
return false;
}
return true;
});
}
estimateApiCalls(contexts, fieldFilters = []) {
if (contexts.length === 0) {
return 0;
}
if (fieldFilters.length > 0) {
return contexts.length > 0 ? fieldFilters.length * 2 : 0; // total count + grouped count per field
}
return contexts.reduce((sum, context) => {
const eligibleCount = context.eligibleFields.length || 0;
if (eligibleCount === 0) {
return sum;
}
const compositeCalls = Math.ceil(eligibleCount / this.compositeBatchSize);
const createdDateCalls = Math.ceil(eligibleCount / this.toolingCustomFieldBatchSize) || 1;
return sum + 1 + createdDateCalls + compositeCalls; // total count + CustomField chunks + composite batches
}, 0);
}
async confirmApiUsage(plannedCalls, objectNames) {
if (plannedCalls === 0 || isCI) {
return true;
}
const confirm = await prompts({
type: 'confirm',
name: 'value',
initial: true,
message: c.cyanBright(`About to execute approximately ${plannedCalls} API call(s) for ${objectNames.length} object(s). Continue?`),
description: 'Confirm API usage',
});
return confirm.value === true;
}
injectObjectsPromptSentinel() {
const argv = this.argv || [];
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--objects' || argv[i] === '-o') {
const next = argv[i + 1];
if (!next || next.startsWith('-')) {
argv.splice(i + 1, 0, this.objectsPromptSentinel);
}
}
}
}
async run() {
this.injectObjectsPromptSentinel();
const { flags } = await this.parse(HardisDocObjectFieldUsage);
const connection = flags['target-org'].getConnection();
let uniqueObjects = [];
const hasPromptSentinel = flags.objects === this.objectsPromptSentinel;
if (flags.objects && !hasPromptSentinel) {
const objectsInput = flags.objects
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0);
uniqueObjects = Array.from(new Set(objectsInput));
uniqueObjects.forEach((name) => this.validateIdentifier(name, 'sObject'));
}
const objectContexts = [];
if (uniqueObjects.length === 0 || hasPromptSentinel) {
const availableSObjects = await listOrgSObjects(connection);
const sObjectApiNames = (availableSObjects?.records || [])
.map((record) => record?.QualifiedApiName)
.filter((apiName) => typeof apiName === 'string' && apiName.length > 0)
.filter((apiName) => !apiName.endsWith('__Share') && !apiName.endsWith('__ChangeEvent'));
sortArray(sObjectApiNames);
const promptObjectsRes = await prompts({
type: 'multiselect',
name: 'value',
message: 'Select the SObjects to analyze:',
description: "Exclude objects you don't want to analyze.",
choices: sObjectApiNames.map((apiName) => ({ title: apiName, value: apiName })),
initial: sObjectApiNames,
});
const selectedObjects = promptObjectsRes.value || [];
if (!selectedObjects.length) {
const outputString = 'No objects selected; aborting.';
uxLog("warning", this, c.yellow(outputString));
return { outputString, cancelled: true };
}
uniqueObjects = selectedObjects;
uxLog("log", this, `${uniqueObjects.length} SObjects selected for analysis.`);
}
const fieldsInput = flags.fields
? Array.from(new Set(flags.fields
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0)))
: [];
if (fieldsInput.length > 0) {
fieldsInput.forEach((fieldName) => this.validateIdentifier(fieldName, 'field'));
if (uniqueObjects.length !== 1) {
throw new Error('--fields can only be used when a single object is specified or selected.');
}
}
WebSocketClient.sendProgressStartMessage(`Describing ${uniqueObjects.length} objects...`);
let counter = 0;
for (const sObjectName of uniqueObjects) {
uxLog("log", this, c.grey(`Describing ${sObjectName}...`));
const context = await this.describeTarget(connection, sObjectName);
uxLog("log", this, c.grey(`Using ${context.useTooling ? 'Tooling' : 'standard'} API for ${sObjectName}.`));
const eligibleFields = this.filterDescribeFields(context.describeResult?.fields || []);
// Filter eligible fields to remove those with namespaces
const eligibleFieldsFiltered = eligibleFields.filter((field) => {
if (isManagedApiName(field.name) && !fieldsInput.includes(field.name)) {
return false;
}
return true;
});
if (eligibleFieldsFiltered.length > 0) {
objectContexts.push({ sObjectName, ...context, eligibleFields: eligibleFieldsFiltered });
}
WebSocketClient.sendProgressStepMessage(counter + 1, uniqueObjects.length);
counter++;
}
WebSocketClient.sendProgressEndMessage(uniqueObjects.length);
const plannedApiCalls = this.estimateApiCalls(objectContexts, fieldsInput);
const proceed = await this.confirmApiUsage(plannedApiCalls, uniqueObjects);
if (!proceed) {
const outputString = 'Operation cancelled by user.';
uxLog("warning", this, c.yellow(outputString));
return { outputString, cancelled: true };
}
if (fieldsInput.length > 0) {
const context = objectContexts[0];
const aggregatedResults = [];
const aggregatedReportFiles = [];
let totalRecords = null;
for (const fieldName of fieldsInput) {
const targetField = (context.describeResult?.fields || []).find((field) => field.name === fieldName);
if (!targetField) {
throw new Error(`Field ${fieldName} not found on ${context.sObjectName}.`);
}
if (targetField.groupable === false) {
throw new Error(`Field ${fieldName} on ${context.sObjectName} is not groupable; cannot compute distribution.`);
}
const singleResult = await this.processSingleFieldAnalysis(connection, context.sObjectName, targetField, context.useTooling);
aggregatedResults.push(...(singleResult.result || []));
aggregatedReportFiles.push(...(singleResult.reportFiles || []));
totalRecords = singleResult.totalRecords ?? totalRecords;
}
uxLog("action", this, c.cyan(`Summary:`));
const fieldSummaryRows = fieldsInput.map((fieldApiName) => {
const distinctValues = aggregatedResults.filter((r) => r?.fieldApiName === fieldApiName).length;
return {
sObjectName: context.sObjectName,
fieldApiName,
distinctValues,
totalRecords: totalRecords ?? 'N/A',
};
});
if (fieldSummaryRows.length > 0) {
uxLogTable(this, fieldSummaryRows, ['sObjectName', 'fieldApiName', 'distinctValues', 'totalRecords']);
}
if (aggregatedReportFiles.length > 0) {
uxLog("log", this, c.grey('Report files:'));
for (const rf of aggregatedReportFiles) {
uxLog("log", this, c.grey(`- ${rf?.type || 'file'}: ${rf?.file || ''}`));
}
}
return {
outputString: `Processed value distribution for ${context.sObjectName} fields: ${fieldsInput.join(', ')}.`,
result: aggregatedResults,
reportFiles: aggregatedReportFiles,
totalRecords,
};
}
const aggregatedRows = [];
const perObjectRows = {};
const aggregatedSkipped = [];
const totalRecordsMap = {};
WebSocketClient.sendProgressStartMessage(`Processing field usage for ${objectContexts.length} objects...`);
counter = 0;
for (const context of objectContexts) {
const { rows, totalRecords, skippedFields } = await this.processObjectFieldUsage(connection, context.sObjectName, context.eligibleFields, context.useTooling);
aggregatedRows.push(...rows);
perObjectRows[context.sObjectName] = rows;
aggregatedSkipped.push(...skippedFields);
totalRecordsMap[context.sObjectName] = totalRecords;
WebSocketClient.sendProgressStepMessage(counter + 1, objectContexts.length);
counter++;
}
WebSocketClient.sendProgressEndMessage(objectContexts.length);
uxLog("action", this, c.cyan(`Generating reports...`));
const reportFiles = [];
const csvFilesForXlsx = [];
for (const context of objectContexts) {
const rows = perObjectRows[context.sObjectName] || [];
if (!rows.length) {
continue;
}
const csvPath = await this.generateFieldUsageCsvReport(rows, `object-field-usage-${context.sObjectName}`, `Field usage - ${context.sObjectName}`);
csvFilesForXlsx.push(csvPath);
reportFiles.push({ type: 'csv', file: csvPath });
}
if (aggregatedRows.length > 0) {
const summaryCsv = await this.generateFieldUsageCsvReport(aggregatedRows, 'object-field-usage-summary', 'Object field usage summary');
csvFilesForXlsx.unshift(summaryCsv);
reportFiles.push({ type: 'csv', file: summaryCsv });
}
if (aggregatedSkipped.length > 0) {
uxLog("warning", this, c.yellow(`${aggregatedSkipped.length} fields were skipped across analyzed objects. See skipped-fields report.`));
const skippedCsv = await generateReportPath('object-field-usage-skipped-fields', '', { withDate: true });
await generateCsvFile(aggregatedSkipped, skippedCsv, {
fileTitle: 'Object field usage - skipped fields',
noExcel: true,
});
csvFilesForXlsx.push(skippedCsv);
reportFiles.push({ type: 'csv', file: skippedCsv });
}
if (csvFilesForXlsx.length > 0) {
const consolidatedBase = await generateReportPath('object-field-usage', '', { withDate: true });
uxLog("action", this, c.cyan(`Generating consolidated XLSX report...`));
await createXlsxFromCsvFiles(csvFilesForXlsx, consolidatedBase, { fileTitle: 'Object field usage (all)' });
const consolidatedXlsx = path.join(path.dirname(consolidatedBase), 'xls', path.basename(consolidatedBase).replace('.csv', '.xlsx'));
reportFiles.push({ type: 'xlsx', file: consolidatedXlsx });
}
uxLog("action", this, c.cyan(`Summary:`));
const perObjectSummary = objectContexts.map((context) => {
const rows = perObjectRows[context.sObjectName] || [];
const skippedCount = aggregatedSkipped.filter((s) => s.sObjectName === context.sObjectName).length;
const neverUsedFields = rows.filter((r) => (r?.populatedPercentageNumeric ?? 0) === 0).length;
const mostPopulated = rows.length > 0 ? rows[0] : null;
const leastPopulated = rows.length > 0 ? rows[rows.length - 1] : null;
return {
sObjectName: context.sObjectName,
totalRecords: totalRecordsMap[context.sObjectName] ?? 0,
fieldsAnalyzed: rows.length,
neverUsedFields,
skippedFields: skippedCount,
mostPopulated: mostPopulated ? `${mostPopulated.fieldApiName} (${mostPopulated.populatedPercentage})` : 'N/A',
leastPopulated: leastPopulated ? `${leastPopulated.fieldApiName} (${leastPopulated.populatedPercentage})` : 'N/A',
};
});
perObjectSummary.sort((a, b) => {
if (b.neverUsedFields !== a.neverUsedFields) {
return b.neverUsedFields - a.neverUsedFields;
}
if (b.fieldsAnalyzed !== a.fieldsAnalyzed) {
return b.fieldsAnalyzed - a.fieldsAnalyzed;
}
return String(a.sObjectName).localeCompare(String(b.sObjectName));
});
const totalFieldsAnalyzed = aggregatedRows.length;
uxLog("log", this, c.grey(`Analyzed ${objectContexts.length} object(s), ${totalFieldsAnalyzed} field(s) ` +
`(${aggregatedSkipped.length} skipped).`));
if (perObjectSummary.length > 0) {
uxLogTable(this, perObjectSummary, [
'sObjectName',
'totalRecords',
'fieldsAnalyzed',
'neverUsedFields',
'skippedFields',
'mostPopulated',
'leastPopulated',
]);
}
const outputString = `Processed object field usage for ${objectContexts.length} object(s).`;
return {
outputString,
result: aggregatedRows,
reportFiles,
totalRecords: totalRecordsMap,
skippedFields: aggregatedSkipped,
};
}
}
//# sourceMappingURL=object-field-usage.js.map