Skip to content

Commit 8fd9c9e

Browse files
mshanemciowillhoit
andauthored
fix: manifests for custom object can omit parent (#1375)
* refactor: simplify and pseudocode for new section * feat: empty CustomObject don't go in manifest * fix: exclusions for preset/variant decomposed * fix: schema updates * refactor: type/fn reorganization * chore: variant details * test: expect only top-level in manifest for decomposed Workflow * chore: deps for xnuts --------- Co-authored-by: Eric Willhoit <[email protected]>
1 parent d174440 commit 8fd9c9e

File tree

18 files changed

+1299
-2358
lines changed

18 files changed

+1299
-2358
lines changed

CHANGELOG.md

Lines changed: 425 additions & 1600 deletions
Large diffs are not rendered by default.

METADATA_SUPPORT.md

Lines changed: 631 additions & 633 deletions
Large diffs are not rendered by default.

src/collections/componentSet.ts

Lines changed: 62 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
SfProject,
1818
} from '@salesforce/core';
1919
import { isString } from '@salesforce/ts-types';
20+
import { objectHasSomeRealValues } from '../utils/decomposed';
2021
import { MetadataApiDeploy, MetadataApiDeployOptions } from '../client/metadataApiDeploy';
2122
import { MetadataApiRetrieve } from '../client/metadataApiRetrieve';
2223
import type { MetadataApiRetrieveOptions } from '../client/types';
@@ -406,74 +407,60 @@ export class ComponentSet extends LazyCollection<MetadataComponent> {
406407
* @returns Object representation of a package manifest
407408
*/
408409
public async getObject(destructiveType?: DestructiveChangesType): Promise<PackageManifestObject> {
409-
const version = await this.getApiVersion();
410-
411410
// If this ComponentSet has components marked for delete, we need to
412411
// only include those components in a destructiveChanges.xml and
413412
// all other components in the regular manifest.
414-
let components = this.components;
415-
if (this.getTypesOfDestructiveChanges().length) {
416-
components = destructiveType ? this.destructiveComponents[destructiveType] : this.manifestComponents;
417-
}
413+
const components = this.getTypesOfDestructiveChanges().length
414+
? destructiveType
415+
? this.destructiveComponents[destructiveType]
416+
: this.manifestComponents
417+
: this.components;
418418

419-
const typeMap = new Map<string, string[]>();
419+
const typeMap = new Map<string, Set<string>>();
420420

421-
const addToTypeMap = (type: MetadataType, fullName: string): void => {
422-
if (type.isAddressable !== false) {
423-
const typeName = type.name;
424-
if (!typeMap.has(typeName)) {
425-
typeMap.set(typeName, []);
426-
}
427-
const typeEntry = typeMap.get(typeName);
428-
if (fullName === ComponentSet.WILDCARD && !type.supportsWildcardAndName && !destructiveType) {
429-
// if the type doesn't support mixed wildcards and specific names, overwrite the names to be a wildcard
430-
typeMap.set(typeName, [fullName]);
431-
} else if (
432-
typeEntry &&
433-
!typeEntry.includes(fullName) &&
434-
(!typeEntry.includes(ComponentSet.WILDCARD) || type.supportsWildcardAndName)
435-
) {
436-
// if the type supports both wildcards and names, add them regardless
437-
typeMap.get(typeName)?.push(fullName);
438-
}
439-
}
440-
};
441-
442-
for (const key of components.keys()) {
421+
[...components.entries()].map(([key, cmpMap]) => {
443422
const [typeId, fullName] = splitOnFirstDelimiter(key);
444-
let type = this.registry.getTypeByName(typeId);
445-
446-
if (type.folderContentType) {
447-
type = this.registry.getTypeByName(type.folderContentType);
448-
}
449-
addToTypeMap(
450-
type,
451-
// they're reassembled like CustomLabels.MyLabel
452-
this.registry.getParentType(type.name)?.strategies?.recomposition === 'startEmpty' && fullName.includes('.')
453-
? fullName.split('.')[1]
454-
: fullName
455-
);
423+
const type = this.registry.getTypeByName(typeId);
456424

457425
// Add children
458-
const componentMap = components.get(key);
459-
if (componentMap) {
460-
for (const comp of componentMap.values()) {
461-
for (const child of comp.getChildren()) {
462-
addToTypeMap(child.type, child.fullName);
463-
}
426+
[...(cmpMap?.values() ?? [])]
427+
.flatMap((c) => c.getChildren())
428+
.map((child) => addToTypeMap({ typeMap, type: child.type, fullName: child.fullName, destructiveType }));
429+
430+
// logic: if this is a decomposed type, skip its inclusion in the manifest if the parent is "empty"
431+
if (
432+
type.strategies?.transformer === 'decomposed' &&
433+
// exclude (ex: CustomObjectTranslation) where there are no addressable children
434+
Object.values(type.children?.types ?? {}).some((t) => t.unaddressableWithoutParent !== true) &&
435+
Object.values(type.children?.types ?? {}).some((t) => t.isAddressable !== false)
436+
) {
437+
const parentComp = [...(cmpMap?.values() ?? [])].find((c) => c.fullName === fullName);
438+
if (parentComp?.xml && !objectHasSomeRealValues(type)(parentComp.parseXmlSync())) {
439+
return;
464440
}
465441
}
466-
}
442+
443+
addToTypeMap({
444+
typeMap,
445+
type: type.folderContentType ? this.registry.getTypeByName(type.folderContentType) : type,
446+
fullName:
447+
this.registry.getParentType(type.name)?.strategies?.recomposition === 'startEmpty' && fullName.includes('.')
448+
? // they're reassembled like CustomLabels.MyLabel
449+
fullName.split('.')[1]
450+
: fullName,
451+
destructiveType,
452+
});
453+
});
467454

468455
const typeMembers = Array.from(typeMap.entries())
469-
.map(([typeName, members]) => ({ members: members.sort(), name: typeName }))
456+
.map(([typeName, members]) => ({ members: [...members].sort(), name: typeName }))
470457
.sort((a, b) => (a.name > b.name ? 1 : -1));
471458

472459
return {
473460
Package: {
474461
...{
475462
types: typeMembers,
476-
version,
463+
version: await this.getApiVersion(),
477464
},
478465
...(this.fullName ? { fullName: this.fullName } : {}),
479466
},
@@ -750,3 +737,28 @@ const splitOnFirstDelimiter = (input: string): [string, string] => {
750737
const indexOfSplitChar = input.indexOf(KEY_DELIMITER);
751738
return [input.substring(0, indexOfSplitChar), input.substring(indexOfSplitChar + 1)];
752739
};
740+
741+
/** side effect: mutates the typeMap property */
742+
const addToTypeMap = ({
743+
typeMap,
744+
type,
745+
fullName,
746+
destructiveType,
747+
}: {
748+
typeMap: Map<string, Set<string>>;
749+
type: MetadataType;
750+
fullName: string;
751+
destructiveType?: DestructiveChangesType;
752+
}): void => {
753+
if (type.isAddressable === false) return;
754+
if (fullName === ComponentSet.WILDCARD && !type.supportsWildcardAndName && !destructiveType) {
755+
// if the type doesn't support mixed wildcards and specific names, overwrite the names to be a wildcard
756+
typeMap.set(type.name, new Set([fullName]));
757+
return;
758+
}
759+
const existing = typeMap.get(type.name) ?? new Set<string>();
760+
if (!existing.has(ComponentSet.WILDCARD) || type.supportsWildcardAndName) {
761+
// if the type supports both wildcards and names, add them regardless
762+
typeMap.set(type.name, existing.add(fullName));
763+
}
764+
};

src/convert/convertContext/recompositionFinalizer.ts

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { join } from 'node:path';
88
import { JsonMap } from '@salesforce/ts-types';
99
import { Messages } from '@salesforce/core';
10-
import { extractUniqueElementValue, getXmlElement } from '../../utils/decomposed';
10+
import { extractUniqueElementValue, getXmlElement, unwrapAndOmitNS } from '../../utils/decomposed';
1111
import { MetadataComponent } from '../../resolve/types';
1212
import { XML_NS_KEY, XML_NS_URL } from '../../common/constants';
1313
import { ComponentSet } from '../../collections/componentSet';
@@ -199,19 +199,3 @@ const getXmlFromCache =
199199
}
200200
return xmlCache.get(key) ?? {};
201201
};
202-
203-
/** composed function, exported from module for test */
204-
export const unwrapAndOmitNS =
205-
(outerType: string) =>
206-
(xml: JsonMap): JsonMap =>
207-
omitNsKey(unwrapXml(outerType)(xml));
208-
209-
/** Remove the namespace key from the json object. Only the parent needs one */
210-
const omitNsKey = (obj: JsonMap): JsonMap =>
211-
Object.fromEntries(Object.entries(obj).filter(([key]) => key !== XML_NS_KEY)) as JsonMap;
212-
213-
const unwrapXml =
214-
(outerType: string) =>
215-
(xml: JsonMap): JsonMap =>
216-
// assert that the outerType is also a metadata type name (ex: CustomObject)
217-
(xml[outerType] as JsonMap) ?? xml;

src/convert/transformers/decomposedMetadataTransformer.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,18 @@ import { ensureArray } from '@salesforce/kit';
1212
import { Messages } from '@salesforce/core';
1313
import { calculateRelativePath } from '../../utils/path';
1414
import { ForceIgnore } from '../../resolve/forceIgnore';
15-
import { extractUniqueElementValue } from '../../utils/decomposed';
15+
import { extractUniqueElementValue, objectHasSomeRealValues } from '../../utils/decomposed';
1616
import type { MetadataComponent } from '../../resolve/types';
1717
import { DecompositionStrategy, type MetadataType } from '../../registry/types';
1818
import { SourceComponent } from '../../resolve/sourceComponent';
1919
import { JsToXml } from '../streams';
20-
import type { WriteInfo } from '../types';
20+
import type { WriteInfo, XmlObj } from '../types';
2121
import { META_XML_SUFFIX, XML_NS_KEY, XML_NS_URL } from '../../common/constants';
2222
import type { SourcePath } from '../../common/types';
2323
import { ComponentSet } from '../../collections/componentSet';
2424
import type { DecompositionState, DecompositionStateValue } from '../convertContext/decompositionFinalizer';
2525
import { BaseMetadataTransformer } from './baseMetadataTransformer';
2626

27-
type XmlObj = { [index: string]: { [XML_NS_KEY]: typeof XML_NS_URL } & JsonMap };
2827
type StateSetter = (forComponent: MetadataComponent, props: Partial<Omit<DecompositionStateValue, 'origin'>>) => void;
2928

3029
Messages.importMessagesDirectory(__dirname);
@@ -272,12 +271,6 @@ const tagToChildTypeId = ({ tagKey, type }: { tagKey: string; type: MetadataType
272271
Object.values(type.children?.types ?? {}).find((c) => c.xmlElementName === tagKey)?.id ??
273272
type.children?.directories?.[tagKey];
274273

275-
/** Ex: CustomObject: { '@_xmlns': 'http://soap.sforce.com/2006/04/metadata' } has no real values */
276-
const objectHasSomeRealValues =
277-
(type: MetadataType) =>
278-
(obj: XmlObj): boolean =>
279-
Object.keys(obj[type.name] ?? {}).length > 1;
280-
281274
const hasChildTypeId = (cm: ComposedMetadata): cm is Required<ComposedMetadata> => !!cm.childTypeId;
282275

283276
const addChildType = (cm: Required<ComposedMetadata>): ComposedMetadataWithChildType => {

src/convert/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77
import { Readable } from 'node:stream';
8+
import { JsonMap } from '@salesforce/ts-types';
9+
import { XML_NS_KEY, XML_NS_URL } from '../common/constants';
810
import { FileResponseSuccess } from '../client/types';
911
import { SourcePath } from '../common/types';
1012
import { MetadataComponent, SourceComponent } from '../resolve';
@@ -153,3 +155,4 @@ export type ReplacementEvent = {
153155
filename: string;
154156
replaced: string;
155157
};
158+
export type XmlObj = { [index: string]: { [XML_NS_KEY]: typeof XML_NS_URL } & JsonMap };

src/registry/presets/decomposeWorkflowBeta.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,48 +39,55 @@
3939
"workflowalert": {
4040
"directoryName": "workflowAlerts",
4141
"id": "workflowalert",
42+
"isAddressable": false,
4243
"name": "WorkflowAlert",
4344
"suffix": "workflowAlert",
4445
"xmlElementName": "alerts"
4546
},
4647
"workflowfieldupdate": {
4748
"directoryName": "workflowFieldUpdates",
4849
"id": "workflowfieldupdate",
50+
"isAddressable": false,
4951
"name": "WorkflowFieldUpdate",
5052
"suffix": "workflowFieldUpdate",
5153
"xmlElementName": "fieldUpdates"
5254
},
5355
"workflowknowledgepublish": {
5456
"directoryName": "workflowKnowledgePublishes",
5557
"id": "workflowknowledgepublish",
58+
"isAddressable": false,
5659
"name": "WorkflowKnowledgePublish",
5760
"suffix": "workflowKnowledgePublish",
5861
"xmlElementName": "knowledgePublishes"
5962
},
6063
"workflowoutboundmessage": {
6164
"directoryName": "workflowOutboundMessages",
6265
"id": "workflowoutboundmessage",
66+
"isAddressable": false,
6367
"name": "WorkflowOutboundMessage",
6468
"suffix": "workflowOutboundMessage",
6569
"xmlElementName": "outboundMessages"
6670
},
6771
"workflowrule": {
6872
"directoryName": "workflowRules",
6973
"id": "workflowrule",
74+
"isAddressable": false,
7075
"name": "WorkflowRule",
7176
"suffix": "workflowRule",
7277
"xmlElementName": "rules"
7378
},
7479
"workflowsend": {
7580
"directoryName": "workflowSends",
7681
"id": "workflowsend",
82+
"isAddressable": false,
7783
"name": "WorkflowSend",
7884
"suffix": "workflowSend",
7985
"xmlElementName": "send"
8086
},
8187
"workflowtask": {
8288
"directoryName": "workflowTasks",
8389
"id": "workflowtask",
90+
"isAddressable": false,
8491
"name": "WorkflowTask",
8592
"suffix": "workflowTask",
8693
"xmlElementName": "tasks"

src/utils/decomposed.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77
import { JsonMap, getString } from '@salesforce/ts-types';
8+
import { XmlObj } from '../convert/types';
9+
import { XML_NS_KEY } from '../common/constants';
810
import { MetadataType } from '../registry/types';
11+
912
/** handle wide-open reading of values from elements inside any metadata xml file...we don't know the type
1013
* Return the value of the matching element if supplied, or defaults `fullName` then `name` */
1114
export const extractUniqueElementValue = (xml: JsonMap, uniqueId?: string): string | undefined =>
@@ -16,3 +19,25 @@ const getStandardElements = (xml: JsonMap): string | undefined =>
1619

1720
/** @returns xmlElementName if specified, otherwise returns the directoryName */
1821
export const getXmlElement = (mdType: MetadataType): string => mdType.xmlElementName ?? mdType.directoryName;
22+
/** composed function, exported from module for test */
23+
24+
export const unwrapAndOmitNS =
25+
(outerType: string) =>
26+
(xml: JsonMap): JsonMap =>
27+
omitNsKey(unwrapXml(outerType)(xml));
28+
29+
/** Remove the namespace key from the json object. Only the parent needs one */
30+
const omitNsKey = (obj: JsonMap): JsonMap =>
31+
Object.fromEntries(Object.entries(obj).filter(([key]) => key !== XML_NS_KEY)) as JsonMap;
32+
33+
const unwrapXml =
34+
(outerType: string) =>
35+
(xml: JsonMap): JsonMap =>
36+
// assert that the outerType is also a metadata type name (ex: CustomObject)
37+
(xml[outerType] as JsonMap) ?? xml;
38+
39+
/** Ex: CustomObject: { '@_xmlns': 'http://soap.sforce.com/2006/04/metadata' } has no real values */
40+
export const objectHasSomeRealValues =
41+
(type: MetadataType) =>
42+
(obj: XmlObj): boolean =>
43+
Object.keys(obj[type.name] ?? {}).length > 1;

test/collections/componentSet.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup';
1010
import { assert, expect } from 'chai';
1111
import { SinonStub } from 'sinon';
1212
import { AuthInfo, ConfigAggregator, Connection, Lifecycle, Messages, SfProject } from '@salesforce/core';
13+
import {
14+
DECOMPOSED_CHILD_COMPONENT_1_EMPTY,
15+
DECOMPOSED_CHILD_COMPONENT_2_EMPTY,
16+
DECOMPOSED_COMPONENT_EMPTY,
17+
} from '../mock/type-constants/customObjectConstantEmptyObjectMeta';
1318
import {
1419
ComponentSet,
1520
ComponentSetBuilder,
@@ -975,6 +980,26 @@ describe('ComponentSet', () => {
975980
},
976981
]);
977982
});
983+
984+
it('omits empty parents from the package manifest', async () => {
985+
const set = new ComponentSet([
986+
DECOMPOSED_CHILD_COMPONENT_1_EMPTY,
987+
DECOMPOSED_CHILD_COMPONENT_2_EMPTY,
988+
DECOMPOSED_COMPONENT_EMPTY,
989+
]);
990+
const types = (await set.getObject()).Package.types;
991+
expect(types.map((type) => type.name)).to.not.include(DECOMPOSED_COMPONENT_EMPTY.type.name);
992+
expect((await set.getObject()).Package.types).to.deep.equal([
993+
{
994+
name: DECOMPOSED_CHILD_COMPONENT_1_EMPTY.type.name,
995+
members: [DECOMPOSED_CHILD_COMPONENT_1_EMPTY.fullName],
996+
},
997+
{
998+
name: DECOMPOSED_CHILD_COMPONENT_2_EMPTY.type.name,
999+
members: [DECOMPOSED_CHILD_COMPONENT_2_EMPTY.fullName],
1000+
},
1001+
]);
1002+
});
9781003
});
9791004

9801005
describe('getPackageXml', () => {

test/convert/convertContext/recomposition.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { join } from 'node:path';
88
import { expect } from 'chai';
99
import { createSandbox } from 'sinon';
10-
import { unwrapAndOmitNS } from '../../../src/convert/convertContext/recompositionFinalizer';
10+
import { unwrapAndOmitNS } from '../../../src/utils/decomposed';
1111
import { decomposed, nonDecomposed } from '../../mock';
1212
import { ConvertContext } from '../../../src/convert/convertContext/convertContext';
1313
import { ComponentSet } from '../../../src/collections/componentSet';

0 commit comments

Comments
 (0)