Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the children are always added. the parent might or might not be depending on if it has content.

doing the children first lets the parent conditionally return early

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 = ({
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this code is simplified and now uses a set instead of an array

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 =
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved for easier sharing

(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
Loading