Skip to content

Commit 7a0f003

Browse files
mdonnalleysfsholden
authored andcommitted
feat: support split CustomLabels on deploy and retrieve (#278)
* feat: support split CustomLabels on deploy and retrieve * chore: rename uniqueIdAttribute to uniqueIdElement * fix: allow unprocessed component to be the default component * chore: bump shelljs
1 parent c48eb45 commit 7a0f003

28 files changed

+975
-62
lines changed

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
},
3030
"dependencies": {
3131
"@salesforce/core": "2.13.0",
32+
"@salesforce/kit": "1.5.0",
33+
"@salesforce/ts-types": "^1.4.2",
3234
"archiver": "4.0.1",
3335
"fast-xml-parser": "^3.17.4",
3436
"gitignore-parser": "0.0.2",
@@ -41,9 +43,9 @@
4143
"@commitlint/cli": "^7",
4244
"@commitlint/config-conventional": "^7",
4345
"@salesforce/ts-sinon": "^1.1.2",
44-
"@salesforce/ts-types": "^1.4.2",
4546
"@types/archiver": "^3.1.0",
4647
"@types/chai": "^4",
48+
"@types/deep-equal-in-any-order": "^1.0.1",
4749
"@types/mime": "2.0.3",
4850
"@types/mkdirp": "0.5.2",
4951
"@types/mocha": "^5",
@@ -55,6 +57,7 @@
5557
"chai": "^4",
5658
"commitizen": "^3.0.5",
5759
"cz-conventional-changelog": "^2.1.0",
60+
"deep-equal-in-any-order": "^1.1.4",
5861
"deepmerge": "^4.2.2",
5962
"eslint": "^6.8.0",
6063
"eslint-config-prettier": "^6.11.0",
@@ -67,7 +70,7 @@
6770
"mocha-junit-reporter": "^1.23.3",
6871
"nyc": "^14.1.1",
6972
"prettier": "2.0.5",
70-
"shelljs": "0.8.3",
73+
"shelljs": "0.8.4",
7174
"shx": "^0.3.2",
7275
"sinon": "^7.3.1",
7376
"source-map-support": "^0.5.16",

src/client/metadataApiRetrieve.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,9 @@ export class MetadataApiRetrieve extends MetadataTransfer<
126126
protected async checkStatus(id: string): Promise<MetadataApiRetrieveStatus> {
127127
const connection = await this.getConnection();
128128
// Recasting to use the project's RetrieveResult type
129-
return (connection.metadata.checkRetrieveStatus(id) as unknown) as Promise<
130-
MetadataApiRetrieveStatus
131-
>;
129+
const status = await connection.metadata.checkRetrieveStatus(id);
130+
status.fileProperties = normalizeToArray(status.fileProperties);
131+
return status as MetadataApiRetrieveStatus;
132132
}
133133

134134
protected async post(result: MetadataApiRetrieveStatus): Promise<RetrieveResult> {

src/collections/componentSet.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ export class ComponentSet extends LazyCollection<MetadataComponent> {
257257
indentBy: new Array(indentation + 1).join(' '),
258258
ignoreAttributes: false,
259259
});
260-
const toParse = this.getObject() as any;
260+
const toParse = this.getObject();
261261
toParse.Package[XML_NS_KEY] = XML_NS_URL;
262262
return XML_DECL.concat(j2x.parse(toParse));
263263
}

src/collections/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* Licensed under the BSD 3-Clause license.
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
7-
import { OptionalTreeRegistryOptions } from '../common';
7+
import { OptionalTreeRegistryOptions, XML_NS_KEY } from '../common';
88
import { ComponentSet } from './componentSet';
99

1010
export interface PackageTypeMembers {
@@ -17,6 +17,7 @@ export interface PackageManifestObject {
1717
types: PackageTypeMembers[];
1818
version: string;
1919
fullName: string;
20+
[XML_NS_KEY]?: string;
2021
};
2122
}
2223

src/convert/convertContext.ts

Lines changed: 219 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
*/
77
import { WriteInfo, WriterFormat } from './types';
88
import { MetadataComponent, SourceComponent } from '../resolve';
9-
import { join } from 'path';
9+
import { basename, dirname, join, resolve, sep } from 'path';
1010
import { JsToXml } from './streams';
11-
import { XML_NS_KEY, XML_NS_URL } from '../common';
12-
import { JsonArray, JsonMap } from '@salesforce/ts-types';
11+
import { META_XML_SUFFIX, XML_NS_KEY, XML_NS_URL } from '../common';
12+
import { getString, JsonArray, JsonMap } from '@salesforce/ts-types';
1313
import { ComponentSet } from '../collections';
14+
import { RecompositionStrategy } from '../registry/types';
15+
import { isEmpty } from '@salesforce/kit';
1416

1517
abstract class ConvertTransactionFinalizer<T> {
1618
protected abstract _state: T;
@@ -23,7 +25,7 @@ abstract class ConvertTransactionFinalizer<T> {
2325
return this._state;
2426
}
2527

26-
public abstract finalize(): Promise<WriterFormat[]>;
28+
public abstract finalize(defaultDirectory?: string): Promise<WriterFormat[]>;
2729
}
2830

2931
export interface RecompositionState {
@@ -66,13 +68,16 @@ class RecompositionFinalizer extends ConvertTransactionFinalizer<RecompositionSt
6668
}
6769

6870
private async recompose(children: ComponentSet, parent: SourceComponent): Promise<JsonMap> {
69-
const parentXmlObj: JsonMap = await parent.parseXml();
71+
const parentXmlObj =
72+
parent.type.strategies.recomposition === RecompositionStrategy.StartEmpty
73+
? {}
74+
: await parent.parseXml();
7075

7176
for (const child of children) {
7277
const { directoryName: groupName } = child.type;
7378
const { name: parentName } = child.parent.type;
7479
const xmlObj = await (child as SourceComponent).parseXml();
75-
const childContents = xmlObj[child.type.name];
80+
const childContents = xmlObj[child.type.name] || xmlObj;
7681

7782
if (!parentXmlObj[parentName]) {
7883
parentXmlObj[parentName] = { [XML_NS_KEY]: XML_NS_URL };
@@ -88,7 +93,6 @@ class RecompositionFinalizer extends ConvertTransactionFinalizer<RecompositionSt
8893

8994
group.push(childContents);
9095
}
91-
9296
return parentXmlObj;
9397
}
9498
}
@@ -125,17 +129,223 @@ class DecompositionFinalizer extends ConvertTransactionFinalizer<DecompositionSt
125129
}
126130
}
127131

132+
export interface NonDecompositionState {
133+
claimed: ChildIndex;
134+
unclaimed: ChildIndex;
135+
}
136+
137+
type ChildIndex = {
138+
[componentKey: string]: {
139+
parent: SourceComponent;
140+
children: {
141+
[childName: string]: JsonMap;
142+
};
143+
};
144+
};
145+
146+
/**
147+
* Merges child components that share the same parent in the conversion pipeline
148+
* into a single file.
149+
*
150+
* Inserts unclaimed child components into the parent that belongs to the default package
151+
*/
152+
class NonDecompositionFinalizer extends ConvertTransactionFinalizer<NonDecompositionState> {
153+
protected _state: NonDecompositionState = {
154+
unclaimed: {},
155+
claimed: {},
156+
};
157+
158+
public async finalize(defaultDirectory: string): Promise<WriterFormat[]> {
159+
await this.finalizeState(defaultDirectory);
160+
161+
const writerData: WriterFormat[] = [];
162+
for (const { parent, children } of Object.values(this.state.claimed)) {
163+
const recomposedXmlObj = await this.recompose(Object.values(children), parent);
164+
165+
writerData.push({
166+
component: parent,
167+
writeInfos: [{ source: new JsToXml(recomposedXmlObj), output: parent.xml }],
168+
});
169+
}
170+
171+
for (const { parent, children } of Object.values(this.state.unclaimed)) {
172+
const recomposedXmlObj = await this.recompose(Object.values(children), parent);
173+
writerData.push({
174+
component: parent,
175+
writeInfos: [
176+
{ source: new JsToXml(recomposedXmlObj), output: this.getDefaultOutput(parent) },
177+
],
178+
});
179+
}
180+
181+
return writerData;
182+
}
183+
184+
/**
185+
* This method finalizes the state by:
186+
* - finding any "unprocessed components" (nondecomposed metadata types can exist in multiple locations under the same name
187+
* so we have to find all components that could potentially claim children)
188+
* - removing any children from the unclaimed state that have been claimed by the unprocessed components
189+
* - removing any children from the unclaimed state that have already been claimed by a prent in the claimed state
190+
* - merging the remaining unclaimed children into the default parent component (either the component that matches the
191+
* defaultDirectory or the first parent component)
192+
*/
193+
private async finalizeState(defaultDirectory: string): Promise<void> {
194+
if (isEmpty(this.state.claimed)) {
195+
return;
196+
}
197+
198+
const unprocessedComponents = this.getUnprocessedComponents(defaultDirectory);
199+
const parentPaths = Object.keys(this.state.claimed).concat(
200+
unprocessedComponents.map((c) => c.xml)
201+
);
202+
203+
const defaultComponentKey =
204+
parentPaths.find((p) => p.startsWith(defaultDirectory)) || parentPaths[0];
205+
206+
const claimedChildren = [
207+
...this.getClaimedChildrenNames(),
208+
...(await this.getChildrenOfUnprocessedComponents(unprocessedComponents)),
209+
];
210+
211+
// merge unclaimed children into default parent component
212+
for (const [key, childIndex] of Object.entries(this.state.unclaimed)) {
213+
const pruned = Object.entries(childIndex.children).reduce((result, [childName, childXml]) => {
214+
return !claimedChildren.includes(childName)
215+
? Object.assign(result, { [childName]: childXml })
216+
: result;
217+
}, {});
218+
delete this.state.unclaimed[key];
219+
if (this.state.claimed[defaultComponentKey]) {
220+
this.state.claimed[defaultComponentKey].children = Object.assign(
221+
{},
222+
this.state.claimed[defaultComponentKey].children,
223+
pruned
224+
);
225+
}
226+
}
227+
}
228+
229+
/**
230+
* Returns the "unprocessed components"
231+
*
232+
* An unprocessed component is a component that was not resolved during component resolution.
233+
* This typically only happens when a specific source path was resolved. This is problematic for
234+
* nondecomposed metadata types (like CustomLabels) because we need to know the location of each
235+
* child type before recomposing the final xml. So in order for each of the children to be properly
236+
* claimed, we have to create new ComponentSet that will have all the parent components.
237+
*/
238+
private getUnprocessedComponents(defaultDirectory: string): SourceComponent[] {
239+
if (isEmpty(this.state.unclaimed)) {
240+
return [];
241+
}
242+
const parents = this.getParentsOfClaimedChildren();
243+
const filterSet = new ComponentSet(parents);
244+
245+
const { tree } = parents[0];
246+
const projectDir = resolve(dirname(defaultDirectory));
247+
const parentDirs = Object.keys(this.state.claimed).map((k) => {
248+
const parts = k.split(sep);
249+
const partIndex = parts.findIndex((p) => basename(projectDir) === p);
250+
return parts[partIndex + 1];
251+
});
252+
253+
const fsPaths = tree
254+
.readDirectory(projectDir)
255+
.map((p) => join(projectDir, p))
256+
.filter((p) => {
257+
const dirName = basename(p);
258+
// Only return directories that are likely to be a project directory
259+
return (
260+
tree.isDirectory(p) &&
261+
!dirName.startsWith('.') &&
262+
dirName !== 'config' &&
263+
dirName !== 'node_modules' &&
264+
!parentDirs.includes(dirName)
265+
);
266+
});
267+
268+
const unprocessedComponents = ComponentSet.fromSource({ fsPaths, include: filterSet })
269+
.getSourceComponents()
270+
.filter((component) => !this.state.claimed[component.xml]);
271+
return unprocessedComponents.toArray();
272+
}
273+
274+
/**
275+
* Returns the children of "unprocessed components"
276+
*/
277+
private async getChildrenOfUnprocessedComponents(
278+
unprocessedComponents: SourceComponent[]
279+
): Promise<string[]> {
280+
const childrenOfUnprocessed = [];
281+
for (const component of unprocessedComponents) {
282+
for (const child of component.getChildren()) {
283+
const xml = await child.parseXml();
284+
const childName = getString(xml, child.type.uniqueIdElement);
285+
childrenOfUnprocessed.push(childName);
286+
}
287+
}
288+
return childrenOfUnprocessed;
289+
}
290+
291+
private async recompose(children: JsonMap[], parent: SourceComponent): Promise<JsonMap> {
292+
const parentXmlObj =
293+
parent.type.strategies.recomposition === RecompositionStrategy.StartEmpty
294+
? {}
295+
: await parent.parseXml();
296+
const groupName = parent.type.directoryName;
297+
const parentName = parent.type.name;
298+
for (const child of children) {
299+
if (!parentXmlObj[parentName]) {
300+
parentXmlObj[parentName] = { [XML_NS_KEY]: XML_NS_URL };
301+
}
302+
303+
const parent = parentXmlObj[parentName] as JsonMap;
304+
305+
if (!parent[groupName]) {
306+
parent[groupName] = [];
307+
}
308+
309+
const group = parent[groupName] as JsonArray;
310+
311+
group.push(child);
312+
}
313+
314+
return parentXmlObj;
315+
}
316+
317+
private getDefaultOutput(component: SourceComponent): string {
318+
const { fullName } = component;
319+
const [baseName] = fullName.split('.');
320+
const output = `${baseName}.${component.type.suffix}${META_XML_SUFFIX}`;
321+
322+
return join(component.getPackageRelativePath('', 'source'), output);
323+
}
324+
325+
private getClaimedChildrenNames(): string[] {
326+
return Object.values(this.state.claimed).reduce(
327+
(x, y) => x.concat(Object.keys(y.children)),
328+
[]
329+
);
330+
}
331+
332+
private getParentsOfClaimedChildren(): SourceComponent[] {
333+
return Object.values(this.state.claimed).reduce((x, y) => x.concat([y.parent]), []);
334+
}
335+
}
336+
128337
/**
129338
* A state manager over the course of a single metadata conversion call.
130339
*/
131340
export class ConvertContext {
132341
public readonly decomposition = new DecompositionFinalizer();
133342
public readonly recomposition = new RecompositionFinalizer();
343+
public readonly nonDecomposition = new NonDecompositionFinalizer();
134344

135-
public async *executeFinalizers(): AsyncIterable<WriterFormat[]> {
345+
public async *executeFinalizers(defaultDirectory?: string): AsyncIterable<WriterFormat[]> {
136346
for (const member of Object.values(this)) {
137347
if (member instanceof ConvertTransactionFinalizer) {
138-
yield member.finalize();
348+
yield member.finalize(defaultDirectory);
139349
}
140350
}
141351
}

src/convert/metadataConverter.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export class MetadataConverter {
6060
let writer: ComponentWriter;
6161
let mergeSet: ComponentSet;
6262
let packagePath: SourcePath;
63+
let defaultDirectory: SourcePath;
6364

6465
switch (output.type) {
6566
case 'directory':
@@ -68,6 +69,7 @@ export class MetadataConverter {
6869
}
6970
manifestContents = cs.getPackageXml();
7071
packagePath = this.getPackagePath(output);
72+
defaultDirectory = packagePath;
7173
writer = new StandardWriter(packagePath);
7274
if (!isSource) {
7375
const manifestPath = join(packagePath, MetadataConverter.PACKAGE_XML_FILE);
@@ -80,6 +82,7 @@ export class MetadataConverter {
8082
}
8183
manifestContents = cs.getPackageXml();
8284
packagePath = this.getPackagePath(output);
85+
defaultDirectory = packagePath;
8386
writer = new ZipWriter(packagePath);
8487
if (!isSource) {
8588
(writer as ZipWriter).addToZip(manifestContents, MetadataConverter.PACKAGE_XML_FILE);
@@ -89,6 +92,7 @@ export class MetadataConverter {
8992
if (!isSource) {
9093
throw new LibraryError('error_merge_metadata_target_unsupported');
9194
}
95+
defaultDirectory = output.defaultDirectory;
9296
mergeSet = new ComponentSet();
9397
// since child components are composed in metadata format, we need to merge using the parent
9498
for (const component of output.mergeWith) {
@@ -100,7 +104,7 @@ export class MetadataConverter {
100104

101105
const conversionPipeline = pipeline(
102106
new ComponentReader(components),
103-
new ComponentConverter(targetFormat, this.registry, mergeSet),
107+
new ComponentConverter(targetFormat, this.registry, mergeSet, defaultDirectory),
104108
writer
105109
);
106110
tasks.push(conversionPipeline);

0 commit comments

Comments
 (0)