Skip to content

Commit

Permalink
fix: manifests for custom object can omit parent (#1375)
Browse files Browse the repository at this point in the history
* 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 <ewillhoit@salesforce.com>
  • Loading branch information
mshanemc and iowillhoit authored Aug 1, 2024
1 parent d174440 commit 8fd9c9e
Show file tree
Hide file tree
Showing 18 changed files with 1,299 additions and 2,358 deletions.
2,025 changes: 425 additions & 1,600 deletions CHANGELOG.md

Large diffs are not rendered by default.

1,264 changes: 631 additions & 633 deletions METADATA_SUPPORT.md

Large diffs are not rendered by default.

112 changes: 62 additions & 50 deletions src/collections/componentSet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
SfProject,
} from '@salesforce/core';
import { isString } from '@salesforce/ts-types';
import { objectHasSomeRealValues } from '../utils/decomposed';
import { MetadataApiDeploy, MetadataApiDeployOptions } from '../client/metadataApiDeploy';
import { MetadataApiRetrieve } from '../client/metadataApiRetrieve';
import type { MetadataApiRetrieveOptions } from '../client/types';
Expand Down Expand Up @@ -406,74 +407,60 @@ export class ComponentSet extends LazyCollection<MetadataComponent> {
* @returns Object representation of a package manifest
*/
public async getObject(destructiveType?: DestructiveChangesType): Promise<PackageManifestObject> {
const version = await this.getApiVersion();

// If this ComponentSet has components marked for delete, we need to
// only include those components in a destructiveChanges.xml and
// all other components in the regular manifest.
let components = this.components;
if (this.getTypesOfDestructiveChanges().length) {
components = destructiveType ? this.destructiveComponents[destructiveType] : this.manifestComponents;
}
const components = this.getTypesOfDestructiveChanges().length
? destructiveType
? this.destructiveComponents[destructiveType]
: this.manifestComponents
: this.components;

const typeMap = new Map<string, string[]>();
const typeMap = new Map<string, Set<string>>();

const addToTypeMap = (type: MetadataType, fullName: string): void => {
if (type.isAddressable !== false) {
const typeName = type.name;
if (!typeMap.has(typeName)) {
typeMap.set(typeName, []);
}
const typeEntry = typeMap.get(typeName);
if (fullName === ComponentSet.WILDCARD && !type.supportsWildcardAndName && !destructiveType) {
// if the type doesn't support mixed wildcards and specific names, overwrite the names to be a wildcard
typeMap.set(typeName, [fullName]);
} else if (
typeEntry &&
!typeEntry.includes(fullName) &&
(!typeEntry.includes(ComponentSet.WILDCARD) || type.supportsWildcardAndName)
) {
// if the type supports both wildcards and names, add them regardless
typeMap.get(typeName)?.push(fullName);
}
}
};

for (const key of components.keys()) {
[...components.entries()].map(([key, cmpMap]) => {
const [typeId, fullName] = splitOnFirstDelimiter(key);
let type = this.registry.getTypeByName(typeId);

if (type.folderContentType) {
type = this.registry.getTypeByName(type.folderContentType);
}
addToTypeMap(
type,
// they're reassembled like CustomLabels.MyLabel
this.registry.getParentType(type.name)?.strategies?.recomposition === 'startEmpty' && fullName.includes('.')
? fullName.split('.')[1]
: fullName
);
const type = this.registry.getTypeByName(typeId);

// Add children
const componentMap = components.get(key);
if (componentMap) {
for (const comp of componentMap.values()) {
for (const child of comp.getChildren()) {
addToTypeMap(child.type, child.fullName);
}
[...(cmpMap?.values() ?? [])]
.flatMap((c) => c.getChildren())
.map((child) => addToTypeMap({ typeMap, type: child.type, fullName: child.fullName, destructiveType }));

// logic: if this is a decomposed type, skip its inclusion in the manifest if the parent is "empty"
if (
type.strategies?.transformer === 'decomposed' &&
// exclude (ex: CustomObjectTranslation) where there are no addressable children
Object.values(type.children?.types ?? {}).some((t) => t.unaddressableWithoutParent !== true) &&
Object.values(type.children?.types ?? {}).some((t) => t.isAddressable !== false)
) {
const parentComp = [...(cmpMap?.values() ?? [])].find((c) => c.fullName === fullName);
if (parentComp?.xml && !objectHasSomeRealValues(type)(parentComp.parseXmlSync())) {
return;
}
}
}

addToTypeMap({
typeMap,
type: type.folderContentType ? this.registry.getTypeByName(type.folderContentType) : type,
fullName:
this.registry.getParentType(type.name)?.strategies?.recomposition === 'startEmpty' && fullName.includes('.')
? // they're reassembled like CustomLabels.MyLabel
fullName.split('.')[1]
: fullName,
destructiveType,
});
});

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

return {
Package: {
...{
types: typeMembers,
version,
version: await this.getApiVersion(),
},
...(this.fullName ? { fullName: this.fullName } : {}),
},
Expand Down Expand Up @@ -750,3 +737,28 @@ const splitOnFirstDelimiter = (input: string): [string, string] => {
const indexOfSplitChar = input.indexOf(KEY_DELIMITER);
return [input.substring(0, indexOfSplitChar), input.substring(indexOfSplitChar + 1)];
};

/** side effect: mutates the typeMap property */
const addToTypeMap = ({
typeMap,
type,
fullName,
destructiveType,
}: {
typeMap: Map<string, Set<string>>;
type: MetadataType;
fullName: string;
destructiveType?: DestructiveChangesType;
}): void => {
if (type.isAddressable === false) return;
if (fullName === ComponentSet.WILDCARD && !type.supportsWildcardAndName && !destructiveType) {
// if the type doesn't support mixed wildcards and specific names, overwrite the names to be a wildcard
typeMap.set(type.name, new Set([fullName]));
return;
}
const existing = typeMap.get(type.name) ?? new Set<string>();
if (!existing.has(ComponentSet.WILDCARD) || type.supportsWildcardAndName) {
// if the type supports both wildcards and names, add them regardless
typeMap.set(type.name, existing.add(fullName));
}
};
18 changes: 1 addition & 17 deletions src/convert/convertContext/recompositionFinalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { join } from 'node:path';
import { JsonMap } from '@salesforce/ts-types';
import { Messages } from '@salesforce/core';
import { extractUniqueElementValue, getXmlElement } from '../../utils/decomposed';
import { extractUniqueElementValue, getXmlElement, unwrapAndOmitNS } from '../../utils/decomposed';
import { MetadataComponent } from '../../resolve/types';
import { XML_NS_KEY, XML_NS_URL } from '../../common/constants';
import { ComponentSet } from '../../collections/componentSet';
Expand Down Expand Up @@ -199,19 +199,3 @@ const getXmlFromCache =
}
return xmlCache.get(key) ?? {};
};

/** composed function, exported from module for test */
export const unwrapAndOmitNS =
(outerType: string) =>
(xml: JsonMap): JsonMap =>
omitNsKey(unwrapXml(outerType)(xml));

/** Remove the namespace key from the json object. Only the parent needs one */
const omitNsKey = (obj: JsonMap): JsonMap =>
Object.fromEntries(Object.entries(obj).filter(([key]) => key !== XML_NS_KEY)) as JsonMap;

const unwrapXml =
(outerType: string) =>
(xml: JsonMap): JsonMap =>
// assert that the outerType is also a metadata type name (ex: CustomObject)
(xml[outerType] as JsonMap) ?? xml;
11 changes: 2 additions & 9 deletions src/convert/transformers/decomposedMetadataTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,18 @@ import { ensureArray } from '@salesforce/kit';
import { Messages } from '@salesforce/core';
import { calculateRelativePath } from '../../utils/path';
import { ForceIgnore } from '../../resolve/forceIgnore';
import { extractUniqueElementValue } from '../../utils/decomposed';
import { extractUniqueElementValue, objectHasSomeRealValues } from '../../utils/decomposed';
import type { MetadataComponent } from '../../resolve/types';
import { DecompositionStrategy, type MetadataType } from '../../registry/types';
import { SourceComponent } from '../../resolve/sourceComponent';
import { JsToXml } from '../streams';
import type { WriteInfo } from '../types';
import type { WriteInfo, XmlObj } from '../types';
import { META_XML_SUFFIX, XML_NS_KEY, XML_NS_URL } from '../../common/constants';
import type { SourcePath } from '../../common/types';
import { ComponentSet } from '../../collections/componentSet';
import type { DecompositionState, DecompositionStateValue } from '../convertContext/decompositionFinalizer';
import { BaseMetadataTransformer } from './baseMetadataTransformer';

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

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

/** Ex: CustomObject: { '@_xmlns': 'http://soap.sforce.com/2006/04/metadata' } has no real values */
const objectHasSomeRealValues =
(type: MetadataType) =>
(obj: XmlObj): boolean =>
Object.keys(obj[type.name] ?? {}).length > 1;

const hasChildTypeId = (cm: ComposedMetadata): cm is Required<ComposedMetadata> => !!cm.childTypeId;

const addChildType = (cm: Required<ComposedMetadata>): ComposedMetadataWithChildType => {
Expand Down
3 changes: 3 additions & 0 deletions src/convert/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import { Readable } from 'node:stream';
import { JsonMap } from '@salesforce/ts-types';
import { XML_NS_KEY, XML_NS_URL } from '../common/constants';
import { FileResponseSuccess } from '../client/types';
import { SourcePath } from '../common/types';
import { MetadataComponent, SourceComponent } from '../resolve';
Expand Down Expand Up @@ -153,3 +155,4 @@ export type ReplacementEvent = {
filename: string;
replaced: string;
};
export type XmlObj = { [index: string]: { [XML_NS_KEY]: typeof XML_NS_URL } & JsonMap };
7 changes: 7 additions & 0 deletions src/registry/presets/decomposeWorkflowBeta.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,48 +39,55 @@
"workflowalert": {
"directoryName": "workflowAlerts",
"id": "workflowalert",
"isAddressable": false,
"name": "WorkflowAlert",
"suffix": "workflowAlert",
"xmlElementName": "alerts"
},
"workflowfieldupdate": {
"directoryName": "workflowFieldUpdates",
"id": "workflowfieldupdate",
"isAddressable": false,
"name": "WorkflowFieldUpdate",
"suffix": "workflowFieldUpdate",
"xmlElementName": "fieldUpdates"
},
"workflowknowledgepublish": {
"directoryName": "workflowKnowledgePublishes",
"id": "workflowknowledgepublish",
"isAddressable": false,
"name": "WorkflowKnowledgePublish",
"suffix": "workflowKnowledgePublish",
"xmlElementName": "knowledgePublishes"
},
"workflowoutboundmessage": {
"directoryName": "workflowOutboundMessages",
"id": "workflowoutboundmessage",
"isAddressable": false,
"name": "WorkflowOutboundMessage",
"suffix": "workflowOutboundMessage",
"xmlElementName": "outboundMessages"
},
"workflowrule": {
"directoryName": "workflowRules",
"id": "workflowrule",
"isAddressable": false,
"name": "WorkflowRule",
"suffix": "workflowRule",
"xmlElementName": "rules"
},
"workflowsend": {
"directoryName": "workflowSends",
"id": "workflowsend",
"isAddressable": false,
"name": "WorkflowSend",
"suffix": "workflowSend",
"xmlElementName": "send"
},
"workflowtask": {
"directoryName": "workflowTasks",
"id": "workflowtask",
"isAddressable": false,
"name": "WorkflowTask",
"suffix": "workflowTask",
"xmlElementName": "tasks"
Expand Down
25 changes: 25 additions & 0 deletions src/utils/decomposed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import { JsonMap, getString } from '@salesforce/ts-types';
import { XmlObj } from '../convert/types';
import { XML_NS_KEY } from '../common/constants';
import { MetadataType } from '../registry/types';

/** handle wide-open reading of values from elements inside any metadata xml file...we don't know the type
* Return the value of the matching element if supplied, or defaults `fullName` then `name` */
export const extractUniqueElementValue = (xml: JsonMap, uniqueId?: string): string | undefined =>
Expand All @@ -16,3 +19,25 @@ const getStandardElements = (xml: JsonMap): string | undefined =>

/** @returns xmlElementName if specified, otherwise returns the directoryName */
export const getXmlElement = (mdType: MetadataType): string => mdType.xmlElementName ?? mdType.directoryName;
/** composed function, exported from module for test */

export const unwrapAndOmitNS =
(outerType: string) =>
(xml: JsonMap): JsonMap =>
omitNsKey(unwrapXml(outerType)(xml));

/** Remove the namespace key from the json object. Only the parent needs one */
const omitNsKey = (obj: JsonMap): JsonMap =>
Object.fromEntries(Object.entries(obj).filter(([key]) => key !== XML_NS_KEY)) as JsonMap;

const unwrapXml =
(outerType: string) =>
(xml: JsonMap): JsonMap =>
// assert that the outerType is also a metadata type name (ex: CustomObject)
(xml[outerType] as JsonMap) ?? xml;

/** Ex: CustomObject: { '@_xmlns': 'http://soap.sforce.com/2006/04/metadata' } has no real values */
export const objectHasSomeRealValues =
(type: MetadataType) =>
(obj: XmlObj): boolean =>
Object.keys(obj[type.name] ?? {}).length > 1;
25 changes: 25 additions & 0 deletions test/collections/componentSet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup';
import { assert, expect } from 'chai';
import { SinonStub } from 'sinon';
import { AuthInfo, ConfigAggregator, Connection, Lifecycle, Messages, SfProject } from '@salesforce/core';
import {
DECOMPOSED_CHILD_COMPONENT_1_EMPTY,
DECOMPOSED_CHILD_COMPONENT_2_EMPTY,
DECOMPOSED_COMPONENT_EMPTY,
} from '../mock/type-constants/customObjectConstantEmptyObjectMeta';
import {
ComponentSet,
ComponentSetBuilder,
Expand Down Expand Up @@ -975,6 +980,26 @@ describe('ComponentSet', () => {
},
]);
});

it('omits empty parents from the package manifest', async () => {
const set = new ComponentSet([
DECOMPOSED_CHILD_COMPONENT_1_EMPTY,
DECOMPOSED_CHILD_COMPONENT_2_EMPTY,
DECOMPOSED_COMPONENT_EMPTY,
]);
const types = (await set.getObject()).Package.types;
expect(types.map((type) => type.name)).to.not.include(DECOMPOSED_COMPONENT_EMPTY.type.name);
expect((await set.getObject()).Package.types).to.deep.equal([
{
name: DECOMPOSED_CHILD_COMPONENT_1_EMPTY.type.name,
members: [DECOMPOSED_CHILD_COMPONENT_1_EMPTY.fullName],
},
{
name: DECOMPOSED_CHILD_COMPONENT_2_EMPTY.type.name,
members: [DECOMPOSED_CHILD_COMPONENT_2_EMPTY.fullName],
},
]);
});
});

describe('getPackageXml', () => {
Expand Down
2 changes: 1 addition & 1 deletion test/convert/convertContext/recomposition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { join } from 'node:path';
import { expect } from 'chai';
import { createSandbox } from 'sinon';
import { unwrapAndOmitNS } from '../../../src/convert/convertContext/recompositionFinalizer';
import { unwrapAndOmitNS } from '../../../src/utils/decomposed';
import { decomposed, nonDecomposed } from '../../mock';
import { ConvertContext } from '../../../src/convert/convertContext/convertContext';
import { ComponentSet } from '../../../src/collections/componentSet';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,7 @@ describe('DecomposedMetadataTransformer', () => {
describe('Merging Components', () => {
it('should merge output with merge component that only has children', async () => {
assert(registry.types.customobject.children?.types.customfield.name);
const mergeComponentChild = component.getChildren()[0];
const mergeComponentChild = component.getChildren()[1];
assert(mergeComponentChild.parent);
const componentToConvert = SourceComponent.createVirtualComponent(
{
Expand Down
Loading

0 comments on commit 8fd9c9e

Please sign in to comment.