Skip to content

Commit c970512

Browse files
authored
Merge pull request #1102 from salesforcecli/wr/imoprtRecordTypes
fix: add ability to import RecordTypeId when RecordType.Name is present
2 parents 76c7b7d + e9e1353 commit c970512

File tree

6 files changed

+109
-149
lines changed

6 files changed

+109
-149
lines changed

messages/importApi.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,7 @@ There are references in a data file %s that can't be resolved:
8181
# error.RefsInFiles
8282

8383
The file %s includes references (ex: '@AccountRef1'). Those are only supported with --plan, not --files.`
84+
85+
# error.noRecordTypeName
86+
87+
This file contains an unresolvable RecordType ID. Try exporting the data by specifying RecordType.Name in the SOQL query, and then run the data import again.

src/api/data/tree/importCommon.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77
import { Connection, SfError, Messages } from '@salesforce/core';
8+
import { getObject, getString } from '@salesforce/ts-types';
89
import type { SObjectTreeInput, SObjectTreeFileContents } from '../../../types.js';
910
import type { ResponseRefs, TreeResponse } from './importTypes.js';
1011

@@ -26,6 +27,30 @@ export const sendSObjectTreeRequest =
2627
},
2728
});
2829

30+
export const transformRecordTypeEntries = async (
31+
conn: Connection,
32+
records: SObjectTreeInput[]
33+
): Promise<SObjectTreeInput[]> => {
34+
await Promise.all(
35+
records.map(async (record) => {
36+
const recordName = getString(record, 'RecordType.Name');
37+
if (recordName) {
38+
const targetRecordTypeId = (
39+
await conn.singleRecordQuery<{ Id: string }>(
40+
`SELECT Id FROM RecordType WHERE Name = '${recordName}' AND SobjectType='${record.attributes.type}'`
41+
)
42+
).Id;
43+
delete record['RecordType'];
44+
record['RecordTypeId'] = targetRecordTypeId;
45+
} else if (getObject(record, 'RecordType') && !recordName) {
46+
throw messages.createError('error.noRecordTypeName');
47+
}
48+
})
49+
);
50+
51+
return records;
52+
};
53+
2954
/** handle an error throw by sendSObjectTreeRequest. Always throws */
3055
export const treeSaveErrorHandler = (error: unknown): never => {
3156
if (error instanceof Error && 'errorCode' in error && error.errorCode === 'INVALID_FIELD') {

src/api/data/tree/importFiles.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
treeSaveErrorHandler,
1515
parseDataFileContents,
1616
getResultsIfNoError,
17+
transformRecordTypeEntries,
1718
} from './importCommon.js';
1819
import type { ImportResult, ResponseRefs, TreeResponse } from './importTypes.js';
1920
import { hasUnresolvedRefs } from './functions.js';
@@ -28,9 +29,10 @@ export type FileInfo = {
2829
export const importFromFiles = async (conn: Connection, dataFilePaths: string[]): Promise<ImportResult[]> => {
2930
const logger = Logger.childFromRoot('data:import:tree:importSObjectTreeFile');
3031
const fileInfos = (await Promise.all(dataFilePaths.map(parseFile))).map(logFileInfo(logger)).map(validateNoRefs);
32+
await Promise.all(fileInfos.map(async (fi) => transformRecordTypeEntries(conn, fi.records)));
3133
const refMap = createSObjectTypeMap(fileInfos.flatMap((fi) => fi.records));
3234
const results = await Promise.allSettled(
33-
fileInfos.map((fi) => sendSObjectTreeRequest(conn)(fi.sobject)(fi.rawContents))
35+
fileInfos.map((fi) => sendSObjectTreeRequest(conn)(fi.sobject)(JSON.stringify({ records: fi.records })))
3436
);
3537
return results.map(getSuccessOrThrow).flatMap(getValueOrThrow(fileInfos)).map(addObjectTypes(refMap));
3638
};

src/api/data/tree/importPlan.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
getResultsIfNoError,
1919
parseDataFileContents,
2020
sendSObjectTreeRequest,
21+
transformRecordTypeEntries,
2122
treeSaveErrorHandler,
2223
} from './importCommon.js';
2324
import { isUnresolvedRef } from './functions.js';
@@ -109,6 +110,7 @@ const getResults =
109110
`Sending ${partWithRefsReplaced.filePath} (${partWithRefsReplaced.records.length} records for ${partWithRefsReplaced.sobject}) to the API`
110111
);
111112
try {
113+
partWithRefsReplaced.records = await transformRecordTypeEntries(conn, partWithRefsReplaced.records);
112114
const contents = JSON.stringify({ records: partWithRefsReplaced.records });
113115
const newResults = getResultsIfNoError(partWithRefsReplaced.filePath)(
114116
await sendSObjectTreeRequest(conn)(partWithRefsReplaced.sobject)(contents)

test/api/data/tree/importApi.test.ts

Lines changed: 29 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ import path, { dirname } from 'node:path';
1818
import { fileURLToPath } from 'node:url';
1919
import { expect } from 'chai';
2020
import sinon from 'sinon';
21-
import { Messages, Org } from '@salesforce/core';
21+
import { Connection, Messages, Org } from '@salesforce/core';
2222
import { ImportApi, ImportConfig } from '../../../../src/api/data/tree/importApi.js';
23+
import { SObjectTreeInput } from '../../../../src/types.js';
24+
import { transformRecordTypeEntries } from '../../../../src/api/data/tree/importCommon.js';
2325
// Json files
2426
const accountsContactsTreeJSON = JSON.parse(
2527
fs.readFileSync('test/api/data/tree/test-files/accounts-contacts-tree.json', 'utf-8')
@@ -29,29 +31,13 @@ const accountsContactsPlanJSON = JSON.parse(
2931
);
3032
const dataImportPlanSchema = JSON.parse(fs.readFileSync('schema/dataImportPlanSchema.json', 'utf-8'));
3133

32-
// Sample response data
33-
// const sampleResponseData = [
34-
// { referenceId: 'SampleAccountRef', id: '001xx000003DKpJAAW' },
35-
// { referenceId: 'SampleAcct2Ref', id: '001xx000003DKpKAAW' },
36-
// { referenceId: 'PresidentSmithRef', id: '003xx000004TtqCAAS' },
37-
// { referenceId: 'VPEvansRef', id: '003xx000004TtqIAAS' }
38-
// ];
39-
4034
const sampleSObjectTypes = {
4135
SampleAccountRef: 'Account',
4236
SampleAcct2Ref: 'Account',
4337
PresidentSmithRef: 'Contact',
4438
VPEvansRef: 'Contact',
4539
};
4640

47-
// Sample command display data
48-
// const sampleDisplayData = [
49-
// { refId: 'SampleAccountRef', type: 'Account', id: '001xx000003DKpJAAW' },
50-
// { refId: 'SampleAcct2Ref', type: 'Account', id: '001xx000003DKpKAAW' },
51-
// { refId: 'PresidentSmithRef', type: 'Contact', id: '003xx000004TtqCAAS' },
52-
// { refId: 'VPEvansRef', type: 'Contact', id: '003xx000004TtqIAAS' }
53-
// ];
54-
5541
const jsonRefRegex = /[.]*["|'][A-Z0-9_]*["|'][ ]*:[ ]*["|']@([A-Z0-9_]*)["|'][.]*/gim;
5642

5743
describe('ImportApi', () => {
@@ -622,6 +608,32 @@ describe('ImportApi', () => {
622608
).to.equal(true);
623609
});
624610

611+
it("should convert RecordType Name's to IDs", async () => {
612+
const travelExpenseJson = JSON.parse(
613+
fs.readFileSync('test/api/data/tree/test-files/travel-expense.json', 'utf-8')
614+
) as { records: SObjectTreeInput[] };
615+
sandbox.stub(Connection.prototype, 'singleRecordQuery').resolves({ Id: 'updatedIdHere' });
616+
const updated = await transformRecordTypeEntries(Connection.prototype, travelExpenseJson.records);
617+
expect(updated.length).to.equal(3);
618+
expect(updated.every((e) => e.RecordTypeId === 'updatedIdHere')).to.be.true;
619+
expect(updated.every((e) => e.RecordType === undefined)).to.be.true;
620+
});
621+
622+
it('should throw an error when RecordType.Name is not available', async () => {
623+
const travelExpenseJson = JSON.parse(
624+
fs.readFileSync('test/api/data/tree/test-files/travel-expense.json', 'utf-8')
625+
) as { records: SObjectTreeInput[] };
626+
// @ts-ignore - just delete the entry, regardless of types
627+
delete travelExpenseJson.records[0].RecordType.Name;
628+
try {
629+
await transformRecordTypeEntries(Connection.prototype, travelExpenseJson.records);
630+
} catch (e) {
631+
expect((e as Error).message).to.equal(
632+
'This file contains an unresolvable RecordType ID. Try exporting the data by specifying RecordType.Name in the SOQL query, and then run the data import again.'
633+
);
634+
}
635+
});
636+
625637
it('should call sendSObjectTreeRequest 3rd with correct args', async () => {
626638
// @ts-ignore
627639
await ImportApi.prototype.importSObjectTreeFile.call(context, args);
@@ -652,134 +664,3 @@ describe('ImportApi', () => {
652664
});
653665
});
654666
});
655-
656-
/**
657-
* Use these as the basis for integration tests.
658-
*/
659-
// describe('data:import', () => {
660-
// let force;
661-
// let org;
662-
663-
// before(() => {
664-
// workspace = new TestWorkspace();
665-
// org = new Org();
666-
// force = org.force;
667-
668-
// sandbox.stub(force, 'describeData').callsFake(() => Promise.resolve());
669-
670-
// // copy data files to workspace
671-
// fse.copySync(path.join(dir, 'data'), path.join(workspace.getWorkspacePath(), 'data'));
672-
673-
// // Some test require a scratch org config in the workspace
674-
// return workspace
675-
// .configureHubOrg()
676-
// .then(() => org.saveConfig(orgConfig))
677-
// .then(() => workspace.configureWorkspaceScratchOrg());
678-
// });
679-
680-
// after(() => {
681-
// workspace.clean();
682-
// });
683-
684-
// afterEach(() => {
685-
// sandbox.restore();
686-
// });
687-
688-
// describe('DataTreeImportCommand run()', () => {
689-
// it('should output the plan schema when confighelp flag specified', async () => {
690-
// const context = {
691-
// flags: {
692-
// confighelp: true,
693-
// json: true
694-
// },
695-
// org: new Org()
696-
// };
697-
// const dataTreeImportCommand = new DataTreeImportCommand([], null);
698-
// set(dataTreeImportCommand, 'ux', { log: sandbox.spy() });
699-
// sandbox.stub(dataTreeImportCommand as any, 'resolveLegacyContext').callsFake(() => Promise.resolve(context));
700-
701-
// const outputJson = await dataTreeImportCommand.run();
702-
// expect(outputJson).to.deep.equal(dataImportPlanSchema);
703-
// });
704-
// });
705-
706-
// // Test importing a single data file
707-
// describe('#File', () => {
708-
// beforeEach(() => {
709-
// sandbox.stub(force, 'request').callsFake(() =>
710-
// Promise.resolve({
711-
// hasErrors: false,
712-
// results: sampleResponseData
713-
// })
714-
// );
715-
// });
716-
717-
// it('API: Should insert Accounts and child Contacts', () => {
718-
// const dataImportCmd = new DataImportApi(org);
719-
// const config = {
720-
// json: true,
721-
// username,
722-
// sobjecttreefiles: './data/accounts-contacts-tree.json'
723-
// };
724-
725-
// return dataImportCmd.execute(config).then(result => {
726-
// expect(result).to.eql(sampleDisplayData);
727-
// });
728-
// });
729-
// });
730-
731-
// // test importing via plan
732-
// describe('#Plan', () => {
733-
// const contactsOnly1 = [
734-
// { referenceId: 'FrontDeskRef', id: '003xx000004TtqwAAC' },
735-
// { referenceId: 'ManagerRef', id: '003xx000004TtqxAAC' }
736-
// ];
737-
// const contactsOnly2 = [
738-
// { referenceId: 'JanitorRef', id: '003xx000004Ttr1AAC' },
739-
// { referenceId: 'DeveloperRef', id: '003xx000004Ttr2AAC' }
740-
// ];
741-
742-
// const sampleDisplayContacts1 = [
743-
// { refId: 'FrontDeskRef', type: 'Contact', id: '003xx000004TtqwAAC' },
744-
// { refId: 'ManagerRef', type: 'Contact', id: '003xx000004TtqxAAC' }
745-
// ];
746-
747-
// const sampleDisplayContacts2 = [
748-
// { refId: 'JanitorRef', type: 'Contact', id: '003xx000004Ttr1AAC' },
749-
// { refId: 'DeveloperRef', type: 'Contact', id: '003xx000004Ttr2AAC' }
750-
// ];
751-
752-
// let requestStub;
753-
754-
// beforeEach(() => {
755-
// requestStub = sandbox.stub(force, 'request');
756-
// requestStub.onCall(0).returns(Promise.resolve({ hasErrors: false, results: sampleResponseData }));
757-
// requestStub.onCall(1).returns(Promise.resolve({ hasErrors: false, results: contactsOnly1 }));
758-
// requestStub.onCall(2).returns(Promise.resolve({ hasErrors: false, results: contactsOnly2 }));
759-
// });
760-
761-
// it('API: Should insert Accounts and child Contacts', () => {
762-
// const dataImportCmd = new DataImportApi(org);
763-
// const config = {
764-
// json: true,
765-
// username,
766-
// plan: './data/accounts-contacts-plan.json',
767-
// importPlanConfig: accountsContactsPlanJSON
768-
// };
769-
// const expected = [...sampleDisplayData, ...sampleDisplayContacts1, ...sampleDisplayContacts2];
770-
771-
// return dataImportCmd.execute(config).then(result => {
772-
// expect(result).to.eql(expected);
773-
// });
774-
// });
775-
// });
776-
777-
// describe('DataImportConfigHelpCommand', () => {
778-
// it('should return the schema', () => {
779-
// const dataImportConfigHelpCommand = new DataImportConfigHelpCommand();
780-
// return dataImportConfigHelpCommand.execute({ org }).then(result => {
781-
// expect(result).to.deep.equal(dataImportPlanSchema);
782-
// });
783-
// });
784-
// });
785-
// });
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"records": [
3+
{
4+
"attributes": {
5+
"type": "Travel_Expense__c",
6+
"referenceId": "Travel_Expense__cRef1"
7+
},
8+
"RecordType": {
9+
"attributes": {
10+
"type": "RecordType",
11+
"url": "/services/data/v62.0/sobjects/RecordType/012D6000004PyzjIAC"
12+
},
13+
"Name": "food"
14+
},
15+
"Name": "lunch"
16+
},
17+
{
18+
"attributes": {
19+
"type": "Travel_Expense__c",
20+
"referenceId": "Travel_Expense__cRef2"
21+
},
22+
"RecordType": {
23+
"attributes": {
24+
"type": "RecordType",
25+
"url": "/services/data/v62.0/sobjects/RecordType/012D6000004PyztIAC"
26+
},
27+
"Name": "flights"
28+
},
29+
"Name": "BOI-STX"
30+
},
31+
{
32+
"attributes": {
33+
"type": "Travel_Expense__c",
34+
"referenceId": "Travel_Expense__cRef3"
35+
},
36+
"RecordType": {
37+
"attributes": {
38+
"type": "RecordType",
39+
"url": "/services/data/v62.0/sobjects/RecordType/012D6000004PyzjIAC"
40+
},
41+
"Name": "food"
42+
},
43+
"Name": "dinner"
44+
}
45+
]
46+
}

0 commit comments

Comments
 (0)