diff --git a/command-snapshot.json b/command-snapshot.json index 7bfbe7c0..c74cce4f 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -103,14 +103,6 @@ ], "plugin": "@salesforce/plugin-data" }, - { - "alias": [], - "command": "data:export:legacy:tree", - "flagAliases": ["apiversion", "outputdir", "targetusername", "u"], - "flagChars": ["d", "o", "p", "q", "x"], - "flags": ["api-version", "flags-dir", "json", "loglevel", "output-dir", "plan", "prefix", "query", "target-org"], - "plugin": "@salesforce/plugin-data" - }, { "alias": [], "command": "data:export:resume", @@ -120,7 +112,7 @@ "plugin": "@salesforce/plugin-data" }, { - "alias": ["force:data:tree:export", "data:export:beta:tree"], + "alias": ["force:data:tree:export"], "command": "data:export:tree", "flagAliases": ["apiversion", "outputdir", "targetusername", "u"], "flagChars": ["d", "o", "p", "q", "x"], @@ -165,24 +157,6 @@ ], "plugin": "@salesforce/plugin-data" }, - { - "alias": [], - "command": "data:import:legacy:tree", - "flagAliases": ["apiversion", "confighelp", "contenttype", "sobjecttreefiles", "targetusername", "u"], - "flagChars": ["c", "f", "o", "p"], - "flags": [ - "api-version", - "config-help", - "content-type", - "files", - "flags-dir", - "json", - "loglevel", - "plan", - "target-org" - ], - "plugin": "@salesforce/plugin-data" - }, { "alias": [], "command": "data:import:resume", @@ -192,7 +166,7 @@ "plugin": "@salesforce/plugin-data" }, { - "alias": ["force:data:tree:import", "data:import:beta:tree"], + "alias": ["force:data:tree:import"], "command": "data:import:tree", "flagAliases": ["apiversion", "sobjecttreefiles", "targetusername", "u"], "flagChars": ["f", "o", "p"], diff --git a/messages/importApi.md b/messages/importApi.md index f76b57cb..b06c76da 100644 --- a/messages/importApi.md +++ b/messages/importApi.md @@ -1,51 +1,11 @@ -# dataFileNotProvided - -Provide a data plan or file(s). - -# dataFileNotFound - -Can't find data file. Indicate a valid path: %s. - -# unknownContentType - -Unable to determine content type for file: %s. - -# dataFileUnsupported - -Content type: %s not supported. - # dataFileEmpty Data file is empty: %s. -# dataFileInvalidJson - -Data file is invalid JSON: %s - -# dataFileNoRefId - -Found references in file, but no reference-id content found (%s). Were parent SObjects saved first? - -# tooManyFiles - -Specify either sObject tree files or a plan definition file, but not both. - # dataImportFailed Import failed from file: %s. Results: %s. -# dataPlanValidationError - -Data plan file %s did not validate against the schema. Errors: %s. - -# dataPlanValidationErrorActions - -- Did you run the "sf data export tree" command with the --plan flag? - -- Make sure you're importing a plan definition file. - -- Get help with the import plan schema by running "sf data import tree --config-help". - # FlsError We couldn't process your request because you don't have access to %s on %s. To learn more about field-level security, visit Tips and Hints for Page Layouts and Field-Level Security in our Developer Documentation. @@ -62,12 +22,6 @@ Data plan file %s did not validate against the schema. Errors: %s. - Get help with the import plan schema by running "sf data import beta tree --help". -# saveResolveRefsIgnored - -The plan contains the 'saveRefs' and/or 'resolveRefs' properties. -These properties will be ignored and can be removed. -In the future, the `tree export` command will not produce them. - # error.NonStringFiles The `files` property of the plan objects must contain only strings diff --git a/messages/tree.export.md b/messages/tree.export.md index d7c09308..0aad035d 100644 --- a/messages/tree.export.md +++ b/messages/tree.export.md @@ -30,7 +30,7 @@ Directory in which to generate the JSON files; default is current directory. - Export records retrieved with the specified SOQL query into a single JSON file in the current directory; the command uses your default org: - <%= config.bin %> <%= command.id %> --query "SELECT Id, Name, (SELECT Name, Address__c FROM Properties__r) FROM Broker__c" + <%= config.bin %> <%= command.id %> --query "SELECT Id, Name, (SELECT Name, Address**c FROM Properties**r) FROM Broker\_\_c" - Export data using a SOQL query in the "query.txt" file and generate JSON files for each object and a plan that aggregates them: @@ -43,11 +43,3 @@ Directory in which to generate the JSON files; default is current directory. # PrefixSlashError `--prefix` cannot contain a forward slash or backslash. - -# PlanJsonWarning - -Starting on Nov 10, 2024, the JSON output for `--plan` will no longer include the `saveRefs` and `resolveRefs` properties. - -# LegacyDeprecation - -Starting on Nov 10, 2024, this command will no longer be available. Use `data export tree` instead. diff --git a/messages/tree.import.legacy.md b/messages/tree.import.legacy.md deleted file mode 100644 index 070e545f..00000000 --- a/messages/tree.import.legacy.md +++ /dev/null @@ -1,60 +0,0 @@ -# summary - -Import data from one or more JSON files into an org. - -# description - -The JSON files that contain the data are in sObject tree format, which is a collection of nested, parent-child records with a single root record. Use the "<%= config.bin %> data export tree" command to generate these JSON files. - -If you used the --plan flag when exporting the data to generate a plan definition file, use the --plan flag to reference the file when you import. If you're not using a plan, use the --files flag to list the files. If you specify multiple JSON files that depend on each other in a parent-child relationship, be sure you list them in the correct order. - -The sObject Tree API supports requests that contain up to 200 records. For more information, see the REST API Developer Guide. (https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_composite_sobject_tree.htm) - -# flags.files.summary - -Comma-separated and in-order JSON files that contain the records, in sObject tree format, that you want to insert. - -# flags.plan.summary - -Plan definition file to insert multiple data files. - -# flags.content-type.summary - -Content type of import files if their extention is not .json. - -# flags.content-type.deprecation - -The `config-type` flag is deprecated and will be moved to a `legacy` command after July 10, 2024. It will be completely removed after Nov 10, 2024. Use the new `data tree beta import` command. - -# flags.config-help.summary - -Display schema information for the --plan configuration file to stdout; if you specify this flag, all other flags except --json are ignored. - -# flags.config-help.deprecation - -The `config-help` flag is deprecated and will be moved to a `legacy` command after July 10, 2024. It will be completely removed after Nov 10, 2024. Use the new `data tree beta import` command. - -# examples - -- Import the records contained in two JSON files into the org with alias "my-scratch": - - <%= config.bin %> <%= command.id %> --files Contact.json,Account.json --target-org my-scratch - -- Import records using a plan definition file into your default org: - - <%= config.bin %> <%= command.id %> --plan Account-Contact-plan.json - -# schema-help - -schema(array) - Data Import Plan: Schema for SFDX Toolbelt's data import plan JSON. - -- items(object) - SObject Type: Definition of records to be insert per SObject Type - - sobject(string) - Name of SObject: Child file references must have SObject roots of this type - - saveRefs(boolean) - Save References: Post-save, save references (Name/ID) to be used for reference replacement in subsequent saves. Applies to all data files for this SObject type. - - resolveRefs(boolean) - Resolve References: Pre-save, replace @ with ID from previous save. Applies to all data files for this SObject type. - - files(array) - Files: An array of files paths to load - - items(string|object) - Filepath: Filepath string or object to point to a JSON or XML file having data defined in SObject Tree format. - -# deprecation - -After Nov 10, 2024, this command will no longer be available. Use `data export tree`. diff --git a/src/api/data/tree/importApi.ts b/src/api/data/tree/importApi.ts deleted file mode 100644 index f1c66a85..00000000 --- a/src/api/data/tree/importApi.ts +++ /dev/null @@ -1,465 +0,0 @@ -/* - * 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 util from 'node:util'; -import fs from 'node:fs'; -import path, { dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { AnyJson, Dictionary, getString, JsonMap } from '@salesforce/ts-types'; -import { Logger, Messages, Org, SchemaValidator, SfError } from '@salesforce/core'; -import { DataPlanPart, hasNestedRecords, isAttributesElement, SObjectTreeInput } from '../../../types.js'; -import { TreeResponse, ResponseRefs, ImportResults } from './importTypes.js'; - -Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); -const messages = Messages.loadMessages('@salesforce/plugin-data', 'importApi'); - -const importPlanSchemaFile = path.join( - dirname(fileURLToPath(import.meta.url)), - '..', - '..', - '..', - '..', - 'schema', - 'dataImportPlanSchema.json' -); - -const sobjectTreeApiPartPattern = '%s/services/data/v%s/composite/tree/%s'; -const jsonContentType = 'application/json'; -const xmlContentType = 'application/xml'; -const jsonRefRegex = /[.]*["|'][A-Z0-9_]*["|'][ ]*:[ ]*["|']@([A-Z0-9_]*)["|'][.]*/gim; -const xmlRefRegex = /[.]*<[A-Z0-9_]*>@([A-Z0-9_]*)<\/[A-Z0-9_]*[ID]>[.]*/gim; - -const INVALID_DATA_IMPORT_ERR_NAME = 'InvalidDataImport'; - -type DataImportComponents = { - instanceUrl: string; - saveRefs?: boolean; - resolveRefs?: boolean; - refMap: Map; - filepath: string; - contentType?: string; -}; - -export type ImportConfig = { - contentType?: string; - sobjectTreeFiles?: string[]; - plan?: string; -}; - -type RequestMeta = { - refRegex: RegExp; - isJson: boolean; - headers: Dictionary; -}; - -/** - * Imports data into an org that was exported to files using the export API. - */ -export class ImportApi { - private logger: Logger; - private responseRefs: ResponseRefs[] = []; - private sobjectUrlMap = new Map(); - private schemaValidator: SchemaValidator; - private sobjectTypes: Record = {}; - private config!: ImportConfig; - private importPlanConfig: DataPlanPart[] = []; - - public constructor(private readonly org: Org) { - this.logger = Logger.childFromRoot(this.constructor.name); - this.schemaValidator = new SchemaValidator(this.logger, importPlanSchemaFile); - } - - /** - * Inserts given SObject Tree content into given target Org. - * - * @param config - */ - public async import(config: ImportConfig): Promise { - const importResults: ImportResults = {}; - const instanceUrl = this.org.getField(Org.Fields.INSTANCE_URL); - - this.config = await this.validate(config); - - const refMap = new Map(); - - const { contentType, plan, sobjectTreeFiles = [] } = this.config; - - try { - // original version of this did files sequentially. Not sure what happens if you did it in parallel - // so this still awaits each file individually - if (plan) { - await this.getPlanPromises({ plan, contentType, refMap, instanceUrl }); - } else { - for (const promise of sobjectTreeFiles.map((file) => - this.importSObjectTreeFile({ - instanceUrl, - refMap, - filepath: path.resolve(process.cwd(), file), - contentType, - }) - )) { - // eslint-disable-next-line no-await-in-loop - await promise; - } - } - - importResults.responseRefs = this.responseRefs; - importResults.sobjectTypes = this.sobjectTypes; - } catch (err) { - if (!(err instanceof SfError)) { - throw err; - } - const error = err as Error; - if (getString(error, 'errorCode') === 'ERROR_HTTP_400' && error.message != null) { - try { - const msg = JSON.parse(error.message) as { hasErrors?: boolean; results?: [] }; - if (msg.hasErrors && msg.results && msg.results.length > 0) { - importResults.errors = msg.results; - } - } catch (e2) { - // throw original - } - } - - throw SfError.wrap(error); - } - - return importResults; - } - - public getSchema(): JsonMap { - return this.schemaValidator.loadSync(); - } - - // Does some basic validation on the filepath and returns some file metadata such as - // isJson, refRegex, and headers. - // eslint-disable-next-line class-methods-use-this - public getSObjectTreeFileMeta(filepath: string, contentType?: string): RequestMeta { - const meta: RequestMeta = { - isJson: false, - headers: {} as Dictionary, - refRegex: new RegExp(/./), - }; - let tmpContentType; - - // explicitly validate filepath so, if not found, we can return friendly error message - try { - fs.statSync(filepath); - } catch (e) { - throw new SfError(messages.getMessage('dataFileNotFound', [filepath]), INVALID_DATA_IMPORT_ERR_NAME); - } - - // determine content type - if (filepath.endsWith('.json')) { - tmpContentType = jsonContentType; - meta.isJson = true; - meta.refRegex = jsonRefRegex; - } else if (filepath.endsWith('.xml')) { - tmpContentType = xmlContentType; - meta.refRegex = xmlRefRegex; - } - - // unable to determine content type from extension, was a global content type provided? - if (!tmpContentType) { - if (!contentType) { - throw new SfError(messages.getMessage('unknownContentType', [filepath]), INVALID_DATA_IMPORT_ERR_NAME); - } else if (contentType.toUpperCase() === 'JSON') { - tmpContentType = jsonContentType; - meta.isJson = true; - meta.refRegex = jsonRefRegex; - } else if (contentType.toUpperCase() === 'XML') { - tmpContentType = xmlContentType; - meta.refRegex = xmlRefRegex; - } else { - throw new SfError(messages.getMessage('dataFileUnsupported', [contentType]), INVALID_DATA_IMPORT_ERR_NAME); - } - } - - meta.headers['content-type'] = tmpContentType; - - return meta; - } - - private async getPlanPromises({ - plan, - contentType, - refMap, - instanceUrl, - }: { - plan: string; - contentType?: string; - refMap: Map; - instanceUrl: string; - }): Promise { - // REVIEWME: support both files and plan in same invocation? - const importPlanRootPath = dirname(plan); - for (const sobjectConfig of this.importPlanConfig) { - const globalSaveRefs = sobjectConfig.saveRefs ?? false; - const globalResolveRefs = sobjectConfig.resolveRefs ?? false; - for (const fileDef of sobjectConfig.files) { - let filepath: string; - let saveRefs = globalSaveRefs; - let resolveRefs = globalResolveRefs; - - // file definition can be just a filepath or an object that - // has a filepath and overriding global config - if (typeof fileDef === 'string') { - filepath = fileDef; - } else if (fileDef.file) { - filepath = fileDef.file; - - // override save references, if set - saveRefs = fileDef.saveRefs ?? globalSaveRefs; - - // override resolve references, if set - resolveRefs = fileDef.resolveRefs ?? globalResolveRefs; - } else { - throw new SfError('file definition format unknown.', 'InvalidDataImportPlan'); - } - - filepath = path.resolve(importPlanRootPath, filepath); - const importConfig: DataImportComponents = { - instanceUrl, - saveRefs, - resolveRefs, - refMap, - filepath, - contentType, - }; - // eslint-disable-next-line no-await-in-loop - await this.importSObjectTreeFile(importConfig); - } - } - } - - /** - * Validates the import configuration. If a plan is passed, validates - * the plan per the schema. - * - * @param config - The data import configuration. - * @returns Promise. - */ - private async validate(config: ImportConfig): Promise { - const { sobjectTreeFiles, plan } = config; - - // --sobjecttreefiles option is required when --plan option is unset - if (!sobjectTreeFiles && !plan) { - throw new SfError(messages.getMessage('dataFileNotProvided'), INVALID_DATA_IMPORT_ERR_NAME); - } - - // Prevent both --sobjecttreefiles and --plan option from being set - if (sobjectTreeFiles && plan) { - throw new SfError(messages.getMessage('tooManyFiles'), INVALID_DATA_IMPORT_ERR_NAME); - } - - if (plan) { - const planPath = path.resolve(process.cwd(), plan); - - if (!fs.existsSync(planPath)) { - throw new SfError(messages.getMessage('dataFileNotFound', [planPath]), INVALID_DATA_IMPORT_ERR_NAME); - } - - this.importPlanConfig = JSON.parse(fs.readFileSync(planPath, 'utf8')) as DataPlanPart[]; - try { - await this.schemaValidator.validate(this.importPlanConfig as unknown as AnyJson); - } catch (err) { - const error = err as Error; - if (error.name === 'ValidationSchemaFieldErrors') { - throw new SfError( - messages.getMessage('dataPlanValidationError', [planPath, error.message]), - INVALID_DATA_IMPORT_ERR_NAME, - messages.getMessages('dataPlanValidationErrorActions') - ); - } - throw SfError.wrap(error); - } - } - return config; - } - - /** - * Create a hash of sobject { ReferenceId: Type } assigned to this.sobjectTypes. - * Used to display the sobject type in the results. - * - * @param content The content string defined by the file(s). - * @param isJson - */ - private createSObjectTypeMap(content: string, isJson: boolean): void { - const getTypes = (records: SObjectTreeInput[]): void => { - records.forEach((record) => { - Object.entries(record).forEach(([key, val]) => { - if (key === 'attributes' && isAttributesElement(val)) { - this.sobjectTypes[val.referenceId] = val.type; - } else if (hasNestedRecords(val) && Array.isArray(val.records)) { - getTypes(val.records); - } - }); - }); - }; - - if (isJson) { - const contentJson = JSON.parse(content) as { records: SObjectTreeInput[] }; - if (Array.isArray(contentJson.records)) { - getTypes(contentJson.records); - } - } - } - - // Parse the SObject tree file, resolving any saved refs if specified. - // Return a promise with the contents of the SObject tree file and the type. - private async parseSObjectTreeFile( - filepath: string, - isJson: boolean, - refRegex: RegExp, - resolveRefs?: boolean, - refMap?: Map - ): Promise<{ contentStr: string; sobject: string }> { - let contentStr: string; - let match: RegExpExecArray | null; - let sobject = ''; - const foundRefs = new Set(); - - // call identity() so the access token can be auto-updated - const content = await fs.promises.readFile(filepath); - if (!content) { - throw messages.createError('dataFileEmpty', [filepath]); - } - - contentStr = content.toString(); - - if (isJson) { - // is valid json? (save round-trip to server) - try { - const contentJson = JSON.parse(contentStr) as { records: SObjectTreeInput[] }; - - // All top level records should be of the same sObject type so just grab the first one - sobject = contentJson.records[0].attributes.type.toLowerCase(); - } catch (e) { - throw messages.createError('dataFileInvalidJson', [filepath]); - } - } - - // if we're replacing references (@AcmeIncAccountId), find references in content and - // replace with reference found in previously saved records - if (resolveRefs && refMap) { - // find and stash all '@' references - while ((match = refRegex.exec(contentStr))) { - foundRefs.add(match[1]); - } - - if (foundRefs.size > 0 && refMap.size === 0) { - throw messages.createError('dataFileNoRefId', [filepath]); - } - - this.logger.debug(`Found references: ${Array.from(foundRefs).toString()}`); - - // loop thru found references and replace with id value - foundRefs.forEach((ref) => { - const value = refMap.get(ref.toLowerCase()); - if (value == null) { - // REVIEWME: fail? - this.logger.warn(`Reference '${ref}' not found in saved record references (${filepath})`); - } else { - contentStr = contentStr.replace(new RegExp(`(["'>])@${ref}(["'<])`, 'igm'), `$1${value}$2`); - } - }); - } - - // Create map of SObject { referenceId: type } to display the type in output - this.createSObjectTypeMap(contentStr, isJson); - - return { contentStr, sobject }; - } - - // generate REST API url: http:///v/composite/tree/ - // and send the request. - private async sendSObjectTreeRequest( - contentStr: string, - sobject: string, - instanceUrl: string, - headers: Dictionary - ): Promise { - const apiVersion = this.org.getConnection().getApiVersion(); - let sobjectTreeApiUrl = this.sobjectUrlMap.get(sobject); - - if (!sobjectTreeApiUrl) { - sobjectTreeApiUrl = util.format(sobjectTreeApiPartPattern, instanceUrl, apiVersion, sobject); - this.sobjectUrlMap.set(sobject, sobjectTreeApiUrl); - } - - this.logger.debug(`SObject Tree API URL: ${sobjectTreeApiUrl}`); - - // post request with to-be-insert sobject tree content - return this.org.getConnection().request({ - method: 'POST', - url: sobjectTreeApiUrl, - body: contentStr, - headers: headers as Record, - }); - } - - // Parse the response from the SObjectTree request and save refs if specified. - private parseSObjectTreeResponse( - response: TreeResponse, - filepath: string, - isJson: boolean, - saveRefs?: boolean, - refMap?: Map - ): TreeResponse { - if (isJson) { - this.logger.debug(`SObject Tree API results: ${JSON.stringify(response, null, 4)}`); - - if (response.hasErrors === true) { - throw messages.createError('dataImportFailed', [filepath, JSON.stringify(response.results, null, 4)]); - } - - if (Array.isArray(response.results)) { - // REVIEWME: include filepath from which record was define? - // store results to be output to stdout in aggregated tabular format - this.responseRefs = this.responseRefs.concat(response.results); - - // if enabled, save references to map to be used to replace references - // prior to subsequent saves - if (saveRefs && refMap) { - response.results.forEach((result) => { - refMap.set(result.referenceId.toLowerCase(), result.id); - }); - } - } - } else { - throw new SfError('SObject Tree API XML response parsing not implemented', 'FailedDataImport'); - } - - return response; - } - - // Imports the SObjectTree from the provided files/plan by making a POST request to the server. - private async importSObjectTreeFile(components: DataImportComponents): Promise { - // Get some file metadata - const { isJson, refRegex, headers } = this.getSObjectTreeFileMeta(components.filepath, components.contentType); - - this.logger.debug(`Importing SObject Tree data from file ${components.filepath}`); - try { - const { contentStr, sobject } = await this.parseSObjectTreeFile( - components.filepath, - isJson, - refRegex, - components.resolveRefs, - components.refMap - ); - const response = await this.sendSObjectTreeRequest(contentStr, sobject, components.instanceUrl, headers); - this.parseSObjectTreeResponse(response, components.filepath, isJson, components.saveRefs, components.refMap); - } catch (error) { - if (error instanceof Error && getString(error, 'errorCode') === 'INVALID_FIELD') { - const field = error.message.split("'")[1]; - const object = error.message.substr(error.message.lastIndexOf(' ') + 1, error.message.length); - throw messages.createError('FlsError', [field, object]); - } - throw SfError.wrap(error as Error); - } - } -} diff --git a/src/api/data/tree/importTypes.ts b/src/api/data/tree/importTypes.ts index 5924d89e..273a9fde 100644 --- a/src/api/data/tree/importTypes.ts +++ b/src/api/data/tree/importTypes.ts @@ -51,6 +51,9 @@ export type ImportResult = { id: string; }; /** like the original DataPlanPart but without the non-string options inside files */ -export type DataPlanPartFilesOnly = { sobject: string; files: string[] } & Partial< - Pick ->; +export type DataPlanPartFilesOnly = { + sobject: string; + files: string[]; + saveRefs: boolean; + resolveRefs: boolean; +} & Partial; diff --git a/src/commands/data/export/legacy/tree.ts b/src/commands/data/export/legacy/tree.ts deleted file mode 100644 index 97b7e8d5..00000000 --- a/src/commands/data/export/legacy/tree.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * 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 { Messages } from '@salesforce/core'; -import { SfCommand, Flags, Ux } from '@salesforce/sf-plugins-core'; -import { orgFlags } from '../../../../flags.js'; -import { ExportApi, ExportConfig } from '../../../../api/data/tree/exportApi.js'; -import type { DataPlanPart, SObjectTreeFileContents } from '../../../../types.js'; - -Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); -const messages = Messages.loadMessages('@salesforce/plugin-data', 'tree.export'); - -export default class Export extends SfCommand { - public static readonly summary = messages.getMessage('summary'); - public static readonly description = messages.getMessage('description'); - public static readonly examples = messages.getMessages('examples'); - public static readonly hidden = true; - public static readonly state = 'deprecated'; - public static readonly deprecationOptions = { - to: 'data tree export', - message: messages.getMessage('LegacyDeprecation'), - }; - public static readonly flags = { - ...orgFlags, - query: Flags.string({ - char: 'q', - summary: messages.getMessage('flags.query.summary'), - required: true, - }), - plan: Flags.boolean({ - char: 'p', - summary: messages.getMessage('flags.plan.summary'), - }), - prefix: Flags.string({ - char: 'x', - summary: messages.getMessage('flags.prefix.summary'), - }), - 'output-dir': Flags.directory({ - char: 'd', - summary: messages.getMessage('flags.output-dir.summary'), - aliases: ['outputdir'], - deprecateAliases: true, - }), - }; - - public async run(): Promise { - const { flags } = await this.parse(Export); - const ux = new Ux({ jsonEnabled: this.jsonEnabled() }); - const exportApi = new ExportApi(flags['target-org'], ux); - const exportConfig: ExportConfig = { - outputDir: flags['output-dir'], - plan: flags.plan, - prefix: flags.prefix, - query: flags.query, - }; - return exportApi.export(exportConfig); - } -} diff --git a/src/commands/data/export/tree.ts b/src/commands/data/export/tree.ts index 01d28df4..2e2ac35d 100644 --- a/src/commands/data/export/tree.ts +++ b/src/commands/data/export/tree.ts @@ -20,7 +20,7 @@ export default class Export extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); public static readonly examples = messages.getMessages('examples'); - public static readonly aliases = ['force:data:tree:export', 'data:export:beta:tree']; + public static readonly aliases = ['force:data:tree:export']; public static readonly deprecateAliases = true; public static readonly flags = { @@ -50,9 +50,6 @@ export default class Export extends SfCommand { public async run(): Promise { const { flags } = await this.parse(Export); - if (flags.plan) { - this.warn(messages.getMessage('PlanJsonWarning')); - } const exportConfig: ExportConfig = { outputDir: flags['output-dir'], plan: flags.plan, diff --git a/src/commands/data/import/legacy/tree.ts b/src/commands/data/import/legacy/tree.ts deleted file mode 100644 index 4cce75e2..00000000 --- a/src/commands/data/import/legacy/tree.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* - * 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 { Messages } from '@salesforce/core'; -import { getString, JsonMap } from '@salesforce/ts-types'; -import { SfCommand, Flags, arrayWithDeprecation } from '@salesforce/sf-plugins-core'; -import type { ImportResult } from '../../../../api/data/tree/importTypes.js'; -import { ImportApi, ImportConfig } from '../../../../api/data/tree/importApi.js'; -import { orgFlags } from '../../../../flags.js'; - -Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); -const messages = Messages.loadMessages('@salesforce/plugin-data', 'tree.import.legacy'); - -/** - * Command that provides data import capability via the SObject Tree Save API. - */ -export default class Import extends SfCommand { - public static readonly summary = messages.getMessage('summary'); - public static readonly description = messages.getMessage('description'); - public static readonly examples = messages.getMessages('examples'); - public static readonly hidden = true; - public static readonly state = 'deprecated'; - public static deprecationOptions = { - message: messages.getMessage('deprecation'), - }; - - public static readonly flags = { - ...orgFlags, - files: arrayWithDeprecation({ - char: 'f', - summary: messages.getMessage('flags.files.summary'), - exclusive: ['plan'], - aliases: ['sobjecttreefiles'], - deprecateAliases: true, - }), - plan: Flags.file({ - char: 'p', - summary: messages.getMessage('flags.plan.summary'), - exists: true, - }), - 'content-type': Flags.string({ - char: 'c', - summary: messages.getMessage('flags.content-type.summary'), - hidden: true, - aliases: ['contenttype'], - deprecateAliases: true, - deprecated: { message: messages.getMessage('flags.content-type.deprecation') }, - }), - // displays the schema for a data import plan - 'config-help': Flags.boolean({ - summary: messages.getMessage('flags.config-help.summary'), - aliases: ['confighelp'], - deprecateAliases: true, - hidden: true, - deprecated: { message: messages.getMessage('flags.config-help.deprecation') }, - }), - }; - - public async run(): Promise { - const { flags } = await this.parse(Import); - const importApi = new ImportApi(flags['target-org']); - - if (flags['config-help']) { - // Display config help and return - const schema = importApi.getSchema(); - this.log(messages.getMessage('schema-help')); - - return schema; - } - - const importConfig: ImportConfig = { - sobjectTreeFiles: flags.files, - contentType: flags['content-type'], - plan: flags.plan, - }; - - const importResults = await importApi.import(importConfig); - - const processedResult: ImportResult[] = (importResults.responseRefs ?? []).map((ref) => { - const type = getString(importResults.sobjectTypes, ref.referenceId, 'Unknown'); - return { refId: ref.referenceId, type, id: ref.id }; - }); - - this.table({ - data: processedResult, - columns: [{ key: 'refId', name: 'Reference ID' }, 'type', { key: 'id', name: 'ID' }], - title: 'Import Results', - }); - - return processedResult; - } -} diff --git a/src/commands/data/import/tree.ts b/src/commands/data/import/tree.ts index ae8e5d8d..9cbd94c2 100644 --- a/src/commands/data/import/tree.ts +++ b/src/commands/data/import/tree.ts @@ -22,7 +22,7 @@ export default class Import extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); public static readonly examples = messages.getMessages('examples'); - public static readonly aliases = ['force:data:tree:import', 'data:import:beta:tree']; + public static readonly aliases = ['force:data:tree:import']; public static readonly deprecateAliases = true; public static readonly flags = { diff --git a/src/export.ts b/src/export.ts index e08ffc71..e639efc2 100644 --- a/src/export.ts +++ b/src/export.ts @@ -22,7 +22,6 @@ import { SObjectTreeFileContents, SObjectTreeInput, } from './types.js'; -import { hasUnresolvedRefs } from './api/data/tree/functions.js'; import { ExportTreeResult } from './commands/data/export/tree.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); @@ -43,7 +42,11 @@ export type ExportConfig = { export type RefFromIdByType = Map>; /** only used internally, but a more useful structure than the original */ -type PlanFile = Omit & { contents: SObjectTreeFileContents; file: string; dir: string }; +type PlanFile = Omit & { + contents: SObjectTreeFileContents; + file: string; + dir: string; +}; export const runExport = async (configInput: ExportConfig): Promise => { const { outputDir, plan, queries, conn, prefix, ux } = validate(configInput); @@ -92,8 +95,6 @@ export const runExport = async (configInput: ExportConfig): Promise ({ sobject, contents: { records }, - saveRefs: shouldSaveRefs(records, [...planMap.values()].flat()), - resolveRefs: hasUnresolvedRefs(records), file: `${getPrefixedFileName(sobject, prefix)}.json`, dir: outputDir ?? '', }) @@ -143,18 +144,9 @@ export const runExport = async (configInput: ExportConfig): Promise { - const refs = new Set(recordsOfType.map((r) => `@${r.attributes.referenceId}`)); - return allRecords.some((r) => Object.values(r).some((v) => typeof v === 'string' && refs.has(v))); -}; - /** convert between types. DataPlanPart is exported and part of the command's return type and file structure so we're stuck with it */ -const planFileToDataPartPlan = (p: PlanFile): DataPlanPart => ({ +const planFileToDataPartPlan = (p: PlanFile): Omit => ({ sobject: p.sobject, - saveRefs: p.saveRefs, - resolveRefs: p.resolveRefs, files: [p.file], }); diff --git a/src/types.ts b/src/types.ts index 396563c4..06b669ac 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,8 +53,6 @@ export type SObjectTreeInput = Omit & { }; export type DataPlanPart = { sobject: string; - saveRefs: boolean; - resolveRefs: boolean; files: Array; }; diff --git a/test/api/data/tree/importApi.test.ts b/test/api/data/tree/importApi.test.ts deleted file mode 100644 index eb63da01..00000000 --- a/test/api/data/tree/importApi.test.ts +++ /dev/null @@ -1,666 +0,0 @@ -/* - * 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 - */ - -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/no-var-requires */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ - -import fs from 'node:fs'; -import path, { dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { expect } from 'chai'; -import sinon from 'sinon'; -import { Connection, Messages, Org } from '@salesforce/core'; -import { ImportApi, ImportConfig } from '../../../../src/api/data/tree/importApi.js'; -import { SObjectTreeInput } from '../../../../src/types.js'; -import { transformRecordTypeEntries } from '../../../../src/api/data/tree/importCommon.js'; -// Json files -const accountsContactsTreeJSON = JSON.parse( - fs.readFileSync('test/api/data/tree/test-files/accounts-contacts-tree.json', 'utf-8') -); -const accountsContactsPlanJSON = JSON.parse( - fs.readFileSync('test/api/data/tree/test-files/accounts-contacts-plan.json', 'utf-8') -); -const dataImportPlanSchema = JSON.parse(fs.readFileSync('schema/dataImportPlanSchema.json', 'utf-8')); - -const sampleSObjectTypes = { - SampleAccountRef: 'Account', - SampleAcct2Ref: 'Account', - PresidentSmithRef: 'Contact', - VPEvansRef: 'Contact', -}; - -const jsonRefRegex = /[.]*["|'][A-Z0-9_]*["|'][ ]*:[ ]*["|']@([A-Z0-9_]*)["|'][.]*/gim; - -describe('ImportApi', () => { - const sandbox = sinon.createSandbox(); - - Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); - const messages = Messages.loadMessages('@salesforce/plugin-data', 'importApi'); - - afterEach(() => { - sandbox.restore(); - }); - - describe('validate', () => { - let context: any; - let config: ImportConfig; - - beforeEach(() => { - context = { - schemaValidator: { - validate: sandbox.stub(), - }, - }; - config = {}; - }); - - it('should throw an InvalidDataImport error when both --sobjecttreefiles and --plan ARE NOT set', async () => { - try { - // @ts-ignore private method `validate` - const rv = await ImportApi.prototype.validate.call(context, config); - - // this should never execute but if it does it will cause the test to fail - expect(rv).to.throw('InvalidDataImport'); - } catch (err) { - const error = err as Error; - expect(error.name).to.equal('InvalidDataImport'); - expect(error.message).to.equal(messages.getMessage('dataFileNotProvided')); - } - }); - - it('should throw an InvalidDataImport error when both --sobjecttreefiles and --plan ARE set', async () => { - config = { - sobjectTreeFiles: ['test_file.json'], - plan: 'test_plan.json', - }; - try { - // @ts-ignore - const rv = await ImportApi.prototype.validate.call(context, config); - - // this should never execute but if it does it will cause the test to fail - expect(rv).to.throw('InvalidDataImport'); - } catch (err) { - const error = err as Error; - expect(error.name).to.equal('InvalidDataImport'); - expect(error.message).to.equal(messages.getMessage('tooManyFiles')); - } - }); - - it('should validate a plan', async () => { - config = { - plan: path.join(dirname(fileURLToPath(import.meta.url)), 'test-files', 'accounts-contacts-plan.json'), - }; - context.schemaValidator.validate.returns(Promise.resolve()); - // @ts-ignore - await ImportApi.prototype.validate.call(context, config); - expect(context.schemaValidator.validate.calledWith(accountsContactsPlanJSON)).to.equal(true); - }); - - it('should throw an InvalidDataImport error with invalid path to plan file', async () => { - config = { - plan: './test/unit/data/non-existant-plan.json', - }; - // @ts-ignore - const expectedFilePath = path.resolve(process.cwd(), config.plan); - try { - // @ts-ignore - const rv = await ImportApi.prototype.validate.call(context, config); - - // this should never execute but if it does it will cause the test to fail - expect(rv).to.throw('InvalidDataImport'); - } catch (err) { - const error = err as Error; - expect(error.name).to.equal('InvalidDataImport'); - expect(error.message).to.equal(messages.getMessage('dataFileNotFound', [expectedFilePath])); - } - }); - - it('should return a promise resolved with config with data file as input', async () => { - config = { - sobjectTreeFiles: [ - path.join(dirname(fileURLToPath(import.meta.url)), 'test-files', 'accounts-contacts-tree.json'), - ], - }; - // @ts-ignore - const opts = await ImportApi.prototype.validate.call(context, config); - expect(opts).to.eql(config); - }); - - it('should return a promise resolved with config with plan file as input', async () => { - config = { - plan: path.join(dirname(fileURLToPath(import.meta.url)), 'test-files', 'accounts-contacts-plan.json'), - }; - context.schemaValidator.validate.returns(Promise.resolve()); - // @ts-ignore - const opts = await ImportApi.prototype.validate.call(context, config); - expect(opts).to.eql(config); - }); - }); - - describe('import', () => { - let config: ImportConfig; - const refMap = new Map(); - const instanceUrl = 'what is it'; - const filepath = 'the_filepath'; - const contentType = 'json'; - const context: any = { - importSObjectTreeFile: () => {}, - org: { - getField: () => instanceUrl, - }, - validate: sandbox.stub(), - }; - - beforeEach(() => { - config = { contentType }; - sandbox.stub(context, 'importSObjectTreeFile').resolves({}); - sandbox.stub(path, 'resolve').callsFake(() => filepath); - sandbox.stub(path, 'dirname').callsFake(() => filepath); - context.validate.resolves(config); - }); - - it('should call importSObjectTreeFile once with correct args for single file import', async () => { - config.sobjectTreeFiles = ['data_file1.json']; - await ImportApi.prototype.import.call(context, config); - expect(context.importSObjectTreeFile.calledOnce).to.be.true; - const expectedArgs = { instanceUrl, refMap, filepath, contentType }; - expect(context.importSObjectTreeFile.firstCall.args[0]).to.deep.equal(expectedArgs); - }); - - it('should call importSObjectTreeFile twice with correct args when importing 2 files, comma delimited', async () => { - config.sobjectTreeFiles = ['data_file1.json', 'data_file2.json']; - await ImportApi.prototype.import.call(context, config); - expect(context.importSObjectTreeFile.calledTwice).to.be.true; - const expectedArgs = { instanceUrl, refMap, filepath, contentType }; - expect(context.importSObjectTreeFile.firstCall.args[0]).to.deep.equal(expectedArgs); - }); - - // says this.getPlanPromises is not a function...some weird test context? - it.skip('should call importSObjectTreeFile for plan import', async () => { - const saveRefs = true; - const resolveRefs = true; - config.plan = 'data_plan.json'; - context.importPlanConfig = [ - { - sobject: 'Broker__c', - saveRefs, - files: ['brokers-data.json'], - }, - { - sobject: 'Property__c', - resolveRefs, - files: ['properties-data.json'], - }, - ]; - await ImportApi.prototype.import.call(context, config); - - expect(context.importSObjectTreeFile.callCount).to.equal(2); - const expectedArgs1 = { - instanceUrl, - saveRefs, - resolveRefs: false, - refMap, - filepath, - contentType, - }; - const expectedArgs2 = { - instanceUrl, - saveRefs: false, - resolveRefs, - refMap, - filepath, - contentType, - }; - expect(context.importSObjectTreeFile.firstCall.args[0]).to.deep.equal(expectedArgs1); - expect(context.importSObjectTreeFile.secondCall.args[0]).to.deep.equal(expectedArgs2); - }); - }); - - describe('createSObjectTypeMap', () => { - const contentStr = JSON.stringify(accountsContactsTreeJSON); - let context: any; - - beforeEach(() => { - context = { - sobjectTypes: {}, - }; - }); - - it('should set this.sobjectTypes using JSON data', () => { - // @ts-ignore - ImportApi.prototype.createSObjectTypeMap.call(context, contentStr, true); - expect(context.sobjectTypes).to.eql(sampleSObjectTypes); - }); - - // This will need to be updated when XML importing is supported - it('should NOT set this.sobjectTypes using non-JSON data', () => { - // @ts-ignore - ImportApi.prototype.createSObjectTypeMap.call(context, contentStr, false); - expect(context.sobjectTypes).to.eql({}); - }); - }); - - describe('getSObjectTreeFileMeta', () => { - it('should return expected import file metadata for json file without specifying content-type', () => { - const expectedMeta = { - isJson: true, - refRegex: jsonRefRegex, - headers: { - 'content-type': 'application/json', - }, - }; - const filepath = path.join(dirname(fileURLToPath(import.meta.url)), 'test-files', 'contacts-only-2.json'); - const rv = ImportApi.prototype.getSObjectTreeFileMeta(filepath); - expect(rv).to.eql(expectedMeta); - }); - - it('should return expected import file metadata for json file with specifying content-type', () => { - const expectedMeta = { - isJson: true, - refRegex: jsonRefRegex, - headers: { - 'content-type': 'application/json', - }, - }; - const filepath = path.join(dirname(fileURLToPath(import.meta.url)), 'test-files', 'contacts-only-2.sdx'); - const rv = ImportApi.prototype.getSObjectTreeFileMeta(filepath, 'json'); - expect(rv).to.eql(expectedMeta); - }); - - it('should throw an InvalidDataImport error with invalid path to data file', () => { - const filepath = path.join(dirname(fileURLToPath(import.meta.url)), 'test-files', 'invalid-data-file.json'); - try { - const rv = ImportApi.prototype.getSObjectTreeFileMeta(filepath); - // this should never execute but if it does it will cause the test to fail - expect(rv).to.throw('InvalidDataImport'); - } catch (err) { - const error = err as Error; - expect(error.name).to.equal('InvalidDataImport'); - expect(error.message).to.equal(messages.getMessage('dataFileNotFound', [filepath])); - } - }); - - it('should throw an InvalidDataImport error with unknown data file extention and no content-type', () => { - const filepath = path.join(dirname(fileURLToPath(import.meta.url)), 'test-files', 'contacts-only-2.sdx'); - try { - const rv = ImportApi.prototype.getSObjectTreeFileMeta(filepath); - // this should never execute but if it does it will cause the test to fail - expect(rv).to.throw('InvalidDataImport'); - } catch (err) { - const error = err as Error; - expect(error.name).to.equal('InvalidDataImport'); - expect(error.message).to.equal(`Unable to determine content type for file: ${filepath}.`); - } - }); - - it('should throw an InvalidDataImport error with unknown data file extension and unsupported content-type', () => { - const filepath = path.join(dirname(fileURLToPath(import.meta.url)), 'test-files', 'contacts-only-2.sdx'); - try { - const rv = ImportApi.prototype.getSObjectTreeFileMeta(filepath, 'txt'); - // this should never execute but if it does it will cause the test to fail - expect(rv).to.throw('InvalidDataImport'); - } catch (err) { - const error = err as Error; - expect(error.name).to.equal('InvalidDataImport'); - expect(error.message).to.equal('Content type: txt not supported.'); - } - }); - }); - - describe('parseSObjectTreeFile', () => { - const context: any = { - logger: { - warn: () => {}, - debug: () => {}, - }, - createSObjectTypeMap: sinon.spy(), - }; - let filepath: string; - let isJson: boolean; - let refRegex: RegExp; - let resolveRefs: boolean; - let refMap: any; - - beforeEach(() => { - filepath = path.join(dirname(fileURLToPath(import.meta.url)), 'test-files', 'accounts-contacts-tree.json'); - isJson = true; - refRegex = jsonRefRegex; - resolveRefs = false; - refMap = new Map(); - }); - - it('should call this.createSObjectTypeMap() with correct args', () => - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - ImportApi.prototype.parseSObjectTreeFile - .call(context, filepath, isJson, refRegex, resolveRefs, refMap) - .then(() => { - const actualArgs = context.createSObjectTypeMap.args[0]; - expect(JSON.parse(actualArgs[0] as string)).to.eql(accountsContactsTreeJSON); - expect(actualArgs[1]).to.eql(isJson); - })); - - it('should return a Promise resolved with the contentStr and sobject', () => - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - ImportApi.prototype.parseSObjectTreeFile - .call(context, filepath, isJson, refRegex, resolveRefs, refMap) - .then((rv) => { - expect(JSON.parse(rv.contentStr)).to.eql(accountsContactsTreeJSON); - expect(rv.sobject).to.eql('account'); - })); - - it('should return an FlsError type error, or catch and wrap the error', () => { - const SfError = messages.createError('FlsError', ['field__c', 'object__c']); - // @ts-ignore - sandbox.stub(ImportApi.prototype, 'parseSObjectTreeFile').throws(SfError); - - expect(() => - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - ImportApi.prototype.parseSObjectTreeFile.call(context, filepath, isJson, refRegex, resolveRefs, refMap) - ).to.throw( - "We couldn't process your request because you don't have access to field__c on object__c. To learn more about field-level security, visit Tips and Hints for Page Layouts and Field-Level Security in our Developer Documentation." - ); - }); - - it('should resolve saved references', () => { - resolveRefs = true; - filepath = path.join(dirname(fileURLToPath(import.meta.url)), 'test-files', 'contacts-only-1.json'); - refMap.set('sampleaccountref', 'test_account_id1'); - refMap.set('sampleacct2ref', 'test_account_id2'); - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return ImportApi.prototype.parseSObjectTreeFile - .call(context, filepath, isJson, refRegex, resolveRefs, refMap) - .then((rv) => { - const contentJson = JSON.parse(rv.contentStr); - expect(contentJson.records[0].AccountId).to.equal(refMap.get('sampleaccountref')); - expect(contentJson.records[1].AccountId).to.equal(refMap.get('sampleacct2ref')); - }); - }); - - it('should resolve saved references of custom objects', () => { - const fileContents = `{ - "records": [{ - "attributes": { - "type": "Property__c", - "referenceId": "Property__cRef1" - }, - "Name": "Seaport District Retreat", - "Price__c": 450000, - "Broker__c": "@CustomObj__cRef1" - },{ - "attributes": { - "type": "Property__c", - "referenceId": "Property__cRef2" - }, - "Name": "Brendom Docks", - "Price__c": 950000, - "Broker__c": "@CustomObj__cRef2" - },{ - "attributes": { - "type": "Property__c", - "referenceId": "Property__cRef20" - }, - "Name": "Brendom Docks", - "Price__c": 950000, - "Broker__c": "@CustomObj__cRef20" - }] - }`; - sandbox.stub(fs.promises, 'readFile').resolves(fileContents); - resolveRefs = true; - filepath = ''; - refMap.set('customobj__cref1', 'custom_id1'); - refMap.set('customobj__cref2', 'custom_id2'); - refMap.set('customobj__cref20', 'custom_id3'); - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return ImportApi.prototype.parseSObjectTreeFile - .call(context, filepath, isJson, refRegex, resolveRefs, refMap) - .then((rv) => { - const contentJson = JSON.parse(rv.contentStr); - expect(contentJson.records[0].Broker__c).to.equal(refMap.get('customobj__cref1')); - expect(contentJson.records[1].Broker__c).to.equal(refMap.get('customobj__cref2')); - expect(contentJson.records[2].Broker__c).to.equal(refMap.get('customobj__cref20')); - }); - }); - }); - - describe('sendSObjectTreeRequest', () => { - const requestStub = sinon.stub().resolves(); - const context = { - sobjectUrlMap: new Map(), - logger: { - debug: () => {}, - }, - org: { - getConnection: () => ({ - getApiVersion: () => '50.0', - request: requestStub, - }), - }, - }; - - const contentStr = 'test_content_str'; - const sobject = 'test_sobject'; - const headers = 'test_headers'; - - afterEach(() => { - requestStub.reset(); - }); - - it('should call request() with correct args', () => { - const sobjectTreeApiUrl = 'test_instance_url/services/data/v50.0/composite/tree/test_sobject'; - const expectedArgs = { - method: 'POST', - url: sobjectTreeApiUrl, - body: contentStr, - headers, - }; - // @ts-ignore - void ImportApi.prototype.sendSObjectTreeRequest.call(context, contentStr, sobject, 'test_instance_url', headers); - expect(requestStub.args[0][0]).to.eql(expectedArgs); - }); - - it('should call request() with correct args using sobjectUrlMap', () => { - const sobjectTreeApiUrl = 'test_sobject_tree_api_url'; - context.sobjectUrlMap.get = () => sobjectTreeApiUrl; - const expectedArgs = { - method: 'POST', - url: sobjectTreeApiUrl, - body: contentStr, - headers, - }; - // @ts-ignore - void ImportApi.prototype.sendSObjectTreeRequest.call(context, contentStr, sobject, 'test_instance_url', headers); - expect(requestStub.args[0][0]).to.eql(expectedArgs); - }); - }); - - describe('parseSObjectTreeResponse', () => { - let context: any; - let response: any; - let filepath: string; - let isJson: boolean; - let saveRefs: boolean; - let refMap: any; - - beforeEach(() => { - context = { - logger: { - debug: () => {}, - }, - responseRefs: [], - }; - response = { results: [] }; - filepath = 'test_file_path'; - isJson = true; - saveRefs = false; - refMap = new Map(); - }); - - it('should return a Promise resolved with the response', () => { - // @ts-ignore - const rv: any = ImportApi.prototype.parseSObjectTreeResponse.call( - context, - response, - filepath, - isJson, - saveRefs, - refMap - ); - expect(rv).to.eql(response); - }); - - it('should add the response results to the responseRefs array', () => { - response.results = [ - { referenceId: '1000x', id: 1 }, - { referenceId: '2000x', id: 2 }, - { referenceId: '3000x', id: 3 }, - ]; - context.responseRefs.push({ referenceId: 9 }); - // @ts-ignore - ImportApi.prototype.parseSObjectTreeResponse.call(context, response, filepath, isJson, saveRefs, refMap); - expect(context.responseRefs).to.eql([{ referenceId: 9 }, ...response.results]); - }); - - it('should save refs to the refMap if args.saveRefs is true', () => { - response.results = [ - { referenceId: '1000x', id: 1 }, - { referenceId: '2000x', id: 2 }, - { referenceId: '3000x', id: 3 }, - ]; - saveRefs = true; - const expectedRefMap = new Map(); - response.results.reduce( - (map: Map, res: any) => map.set(res.referenceId as string, res.id as number), - expectedRefMap - ); - // @ts-ignore - ImportApi.prototype.parseSObjectTreeResponse.call(context, response, filepath, isJson, saveRefs, refMap); - expect(refMap).to.eql(expectedRefMap); - }); - }); - - describe('importSObjectTreeFile', () => { - const testResponse = 'test_response'; - const testMeta = { - isJson: true, - refRegex: 'test_ref_regex', - headers: 'test_headers', - }; - const testParseResults = { - contentStr: 'test_content_str', - sobject: 'test_sobject', - }; - const context: any = { - logger: { - debug: () => {}, - }, - }; - - const args = { - instanceUrl: 'test_instance_url', - saveRefs: 'test_save_refs', - resolveRefs: 'test_resolve_refs', - refMap: 'test_ref_map', - filepath: 'test_file_path', - contentType: 'test_content_type', - }; - - beforeEach(() => { - context.getSObjectTreeFileMeta = sinon.stub().returns(testMeta); - context.parseSObjectTreeFile = sinon.stub().resolves(testParseResults); - context.sendSObjectTreeRequest = sinon.stub().resolves(testResponse); - context.parseSObjectTreeResponse = sinon.stub(); - }); - - it('should call getSObjectTreeFileMeta 1st with correct args', async () => { - // @ts-ignore - await ImportApi.prototype.importSObjectTreeFile.call(context, args); - expect(context.getSObjectTreeFileMeta.calledBefore(context.parseSObjectTreeFile)).to.equal(true); - expect(context.getSObjectTreeFileMeta.calledWith(args.filepath, args.contentType)).to.equal(true); - }); - - it('should call parseSObjectTreeFile 2nd with correct args', async () => { - // @ts-ignore - await ImportApi.prototype.importSObjectTreeFile.call(context, args); - expect(context.parseSObjectTreeFile.calledAfter(context.getSObjectTreeFileMeta)).to.equal(true); - expect(context.parseSObjectTreeFile.calledBefore(context.sendSObjectTreeRequest)).to.equal(true); - expect( - context.parseSObjectTreeFile.calledWith( - args.filepath, - testMeta.isJson, - testMeta.refRegex, - args.resolveRefs, - args.refMap - ) - ).to.equal(true); - }); - - it("should convert RecordType Name's to IDs", async () => { - const travelExpenseJson = JSON.parse( - fs.readFileSync('test/api/data/tree/test-files/travel-expense.json', 'utf-8') - ) as { records: SObjectTreeInput[] }; - sandbox.stub(Connection.prototype, 'singleRecordQuery').resolves({ Id: 'updatedIdHere' }); - const updated = await transformRecordTypeEntries(Connection.prototype, travelExpenseJson.records); - expect(updated.length).to.equal(3); - expect(updated.every((e) => e.RecordTypeId === 'updatedIdHere')).to.be.true; - expect(updated.every((e) => e.RecordType === undefined)).to.be.true; - }); - - it('should throw an error when RecordType.Name is not available', async () => { - const travelExpenseJson = JSON.parse( - fs.readFileSync('test/api/data/tree/test-files/travel-expense.json', 'utf-8') - ) as { records: SObjectTreeInput[] }; - // @ts-ignore - just delete the entry, regardless of types - delete travelExpenseJson.records[0].RecordType.Name; - try { - await transformRecordTypeEntries(Connection.prototype, travelExpenseJson.records); - } catch (e) { - expect((e as Error).message).to.equal( - '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.' - ); - } - }); - - it('should call sendSObjectTreeRequest 3rd with correct args', async () => { - // @ts-ignore - await ImportApi.prototype.importSObjectTreeFile.call(context, args); - const { contentStr, sobject } = testParseResults; - expect(context.sendSObjectTreeRequest.calledAfter(context.parseSObjectTreeFile)).to.equal(true); - expect(context.sendSObjectTreeRequest.calledBefore(context.parseSObjectTreeResponse)).to.equal(true); - expect( - context.sendSObjectTreeRequest.calledWith(contentStr, sobject, args.instanceUrl, testMeta.headers) - ).to.equal(true); - }); - - it('should call parseSObjectTreeResponse 4th with correct args', async () => { - // @ts-ignore - await ImportApi.prototype.importSObjectTreeFile.call(context, args); - const { filepath, saveRefs, refMap } = args; - expect(context.parseSObjectTreeResponse.calledAfter(context.sendSObjectTreeRequest)).to.be.true; - expect( - context.parseSObjectTreeResponse.calledWith(testResponse, filepath, testMeta.isJson, saveRefs, refMap) - ).to.equal(true); - }); - }); - - describe('getSchema', () => { - it('should return the schema', () => { - const org = new Org({ aliasOrUsername: 'import@test.org' }); - const importApi = new ImportApi(org); - expect(importApi.getSchema()).to.deep.equal(dataImportPlanSchema); - }); - }); -}); diff --git a/test/commands/data/export/legacy/tree.test.ts b/test/commands/data/export/legacy/tree.test.ts deleted file mode 100644 index ea51375f..00000000 --- a/test/commands/data/export/legacy/tree.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* - * 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 { resolve } from 'node:path'; -import { dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { AnyJson, ensureJsonMap, ensureString, isString } from '@salesforce/ts-types'; -import { expect } from 'chai'; -import { TestContext, MockTestOrgData } from '@salesforce/core/testSetup'; -import { Config } from '@oclif/core/config'; -import Export from '../../../../../src/commands/data/export/legacy/tree.js'; -const query = 'SELECT Id, Name from Account'; - -// Query response used by the connection stub. -const queryResponse = { - totalSize: 1, - done: true, - records: [ - { - attributes: { - type: 'Account', - url: '/services/data/v51.0/sobjects/Account/0019A00000GNvvAQAT', - }, - Id: '0019A00000GNvvAQAT', - Name: 'Sample Account for Entitlements', - }, - ], -}; - -// Abbreviated response of Account SObject metadata used by the connection stub. -const ACCOUNT_META = { - name: 'Account', - childRelationships: [ - { childSObject: 'Case', field: 'AccountId', relationshipName: 'Cases' }, - { - childSObject: 'Contact', - field: 'AccountId', - relationshipName: 'Contacts', - }, - ], - fields: [ - { name: 'Name', referenceTo: [], type: 'string' }, - { name: 'Type', referenceTo: [], type: 'picklist' }, - { name: 'Industry', referenceTo: [], type: 'picklist' }, - ], -}; - -type ExportResult = { - status: string; - message?: string; - result: AnyJson; -}; - -describe('data:export:legacy:tree', () => { - const $$ = new TestContext(); - const testOrg = new MockTestOrgData(); - const config = new Config({ - root: resolve(dirname(fileURLToPath(import.meta.url)), '../../../package.json'), - }); - - beforeEach(async () => { - await $$.stubAuths(testOrg); - await config.load(); - }); - - afterEach(async () => { - $$.restore(); - }); - - it('returns Account record', async () => { - $$.SANDBOX.stub(fs, 'writeFileSync').returns(); - $$.fakeConnectionRequest = (request: AnyJson): Promise => { - if (isString(request) && request.includes('sobjects/Account/describe')) { - return Promise.resolve(ACCOUNT_META); - } else { - const requestMap = ensureJsonMap(request); - if (ensureString(requestMap.url).includes('query?q=SELECT')) { - return Promise.resolve(queryResponse); - } - } - return Promise.resolve({}); - }; - const cmd = new Export(['--target-org', 'test@org.com', '--query', query, '--json'], config); - - const result = (await cmd.run()) as unknown as ExportResult; - expect(result).to.deep.equal({ - records: [ - { - attributes: { - type: 'Account', - referenceId: 'AccountRef1', - }, - Name: 'Sample Account for Entitlements', - }, - ], - }); - }); -}); diff --git a/test/commands/data/import/legacy/tree.test.ts b/test/commands/data/import/legacy/tree.test.ts deleted file mode 100644 index dcc75c3f..00000000 --- a/test/commands/data/import/legacy/tree.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -/* - * 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 { join as pathJoin, resolve, dirname } from 'node:path'; -import { strict as assert } from 'node:assert'; -import { fileURLToPath } from 'node:url'; -import fs from 'node:fs'; -import { AnyJson, ensureJsonMap, ensureString } from '@salesforce/ts-types'; -import { expect } from 'chai'; -import { TestContext, MockTestOrgData, shouldThrow } from '@salesforce/core/testSetup'; -import { Config } from '@oclif/core/config'; -import Import from '../../../../../src/commands/data/import/legacy/tree.js'; -// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -const dataImportPlanSchema = JSON.parse(fs.readFileSync('schema/dataImportPlanSchema.json', 'utf-8')); - -const expectedImportResult = [ - { - refId: 'SampleAccountRef', - type: 'Account', - id: '0019A00000GkC21QAF', - }, - { - refId: 'SampleAcct2Ref', - type: 'Account', - id: '0019A00000GkC22QAF', - }, - { - refId: 'PresidentSmithRef', - type: 'Contact', - id: '0039A00000Bzy8kQAB', - }, - { - refId: 'VPEvansRef', - type: 'Contact', - id: '0039A00000Bzy8lQAB', - }, -]; - -describe('data:tree:legacy:import', () => { - const $$ = new TestContext(); - const testOrg = new MockTestOrgData(); - const config = new Config({ - root: resolve(dirname(fileURLToPath(import.meta.url)), '../../../package.json'), - }); - - beforeEach(async () => { - await $$.stubAuths(testOrg); - await config.load(); - $$.fakeConnectionRequest = (request: AnyJson): Promise => { - const requestWithUrl = ensureJsonMap(request); - if (request && ensureString(requestWithUrl.url).includes('composite/tree/account')) { - return Promise.resolve({ - hasErrors: false, - results: [ - { referenceId: 'SampleAccountRef', id: '0019A00000GkC21QAF' }, - { referenceId: 'SampleAcct2Ref', id: '0019A00000GkC22QAF' }, - { referenceId: 'PresidentSmithRef', id: '0039A00000Bzy8kQAB' }, - { referenceId: 'VPEvansRef', id: '0039A00000Bzy8lQAB' }, - ], - }); - } else { - return Promise.resolve({}); - } - }; - }); - - afterEach(async () => { - $$.restore(); - }); - - it('returns 4 reference entries for an import file', async () => { - const cmd = new Import( - [ - '--target-org', - 'test@org.com', - '--files', - pathJoin( - dirname(fileURLToPath(import.meta.url)), - '..', - '..', - '..', - '..', - 'api', - 'data', - 'tree', - 'test-files', - 'accounts-contacts-tree.json' - ), - '--json', - ], - config - ); - - const result = await cmd.run(); - expect(result).to.deep.equal(expectedImportResult); - }); - - it('returns 4 reference entries for an import plan', async () => { - const cmd = new Import( - [ - '--target-org', - 'test@org.com', - '--plan', - pathJoin( - dirname(fileURLToPath(import.meta.url)), - '..', - '..', - '..', - '..', - 'api', - 'data', - 'tree', - 'test-files', - 'accounts-contacts-plan.json' - ), - '--json', - ], - config - ); - - const result = await cmd.run(); - expect(result).to.deep.equal(expectedImportResult); - }); - - it('should return the schema with --confighelp param', async () => { - const cmd = new Import(['--target-org', 'test@org.com', '--config-help', '--json'], config); - const result = await cmd.run(); - expect(result).to.deep.equal(dataImportPlanSchema); - }); - - it('should throw an error if data plan or file is not provided', async () => { - const cmd = new Import(['--target-org', 'test@org.com', '--json'], config); - try { - await shouldThrow(cmd.run()); - } catch (e) { - assert(e instanceof Error); - expect(e.name).to.equal('InvalidDataImport'); - // error happened, yay. - } - }); -});