-
Notifications
You must be signed in to change notification settings - Fork 109
/
Copy pathdecomposedMetadataTransformer.ts
334 lines (303 loc) · 14.5 KB
/
decomposedMetadataTransformer.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
/*
* 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 { dirname, join } from 'node:path';
import fs from 'node:fs';
import { AnyJson, JsonMap, ensureString, isJsonMap } from '@salesforce/ts-types';
import { ensureArray } from '@salesforce/kit';
import { Messages } from '@salesforce/core';
import { calculateRelativePath } from '../../utils/path';
import { ForceIgnore } from '../../resolve/forceIgnore';
import { extractUniqueElementValue, objectHasSomeRealValues } from '../../utils/decomposed';
import type { MetadataComponent } from '../../resolve/types';
import { type MetadataType } from '../../registry/types';
import { SourceComponent } from '../../resolve/sourceComponent';
import { JsToXml } from '../streams';
import type { ToSourceFormatInput, 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';
import type { ComposedMetadata, ComposedMetadataWithChildType, InfoContainer } from './types';
type StateSetter = (forComponent: MetadataComponent, props: Partial<Omit<DecompositionStateValue, 'origin'>>) => void;
Messages.importMessagesDirectory(__dirname);
const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr');
export class DecomposedMetadataTransformer extends BaseMetadataTransformer {
// eslint-disable-next-line @typescript-eslint/require-await
public async toMetadataFormat(component: SourceComponent): Promise<WriteInfo[]> {
if (component.parent) {
const { fullName: parentName } = component.parent;
const stateForParent = this.context.recomposition.transactionState.get(parentName) ?? {
component: component.parent,
children: new ComponentSet([], this.registry),
};
stateForParent.children?.add(component);
this.context.recomposition.transactionState.set(parentName, stateForParent);
} else {
const { fullName } = component;
const existing = this.context.recomposition.transactionState.get(fullName) ?? {
component,
children: new ComponentSet([], this.registry),
};
if (component.xml && existing.component && !existing.component.xml) {
// we've already found and created the parent of this component on L~38
// but now we have more information about the parent (xml) that we didn't have before, so add it
existing.component = component;
}
(component.getChildren() ?? []).map((child) => {
existing.children?.add(child);
});
this.context.recomposition.transactionState.set(fullName, existing);
}
// noop since the finalizer will push the writes to the component writer
return [];
}
public async toSourceFormat({ component, mergeWith }: ToSourceFormatInput): Promise<WriteInfo[]> {
const forceIgnore = component.getForceIgnore();
// if the whole parent is ignored, we won't worry about decomposing things
// this can happen if the manifest had a *; all the members will be retrieved.
if (forceIgnore.denies(getOutputFile(component, mergeWith))) {
return [];
}
const childrenOfMergeComponent = new ComponentSet(mergeWith?.getChildren(), this.registry);
const composedMetadata = await getComposedMetadataEntries(component);
const parentXmlObject = buildParentXml(component.type)(composedMetadata);
const stateSetter = setDecomposedState(this.context.decomposition.transactionState);
const writeInfosForChildren = composedMetadata
.filter(hasChildTypeId)
.map(addChildType)
.flatMap(({ tagValue, childType }) =>
// iterate each array member if it's Object-like (ex: customField of a CustomObject)
ensureArray(tagValue)
.filter(isJsonMap)
.map(toInfoContainer(mergeWith)(component)(childType))
.filter(forceIgnoreAllowsComponent(forceIgnore)) // only process child types that aren't forceignored
.map(handleUnaddressableChildAlone(composedMetadata.length)(parentXmlObject)(stateSetter))
.flatMap(getChildWriteInfos(stateSetter)(childrenOfMergeComponent))
);
const writeInfoForParent = mergeWith
? getWriteInfosFromMerge(mergeWith)(stateSetter)(parentXmlObject)(component)
: getWriteInfosWithoutMerge(this.defaultDirectory)(parentXmlObject)(component);
const childDestinations = new Set(writeInfosForChildren.map((w) => w.output));
// files that exist in FS (therefore, in mergeWith) but aren't in the component should be deleted by returning a writeInfo
// only do this if all the children have isAddressable marked false
const writeInfosForMissingChildrenToDelete: WriteInfo[] =
mergeWith && allChildrenAreUnaddressable(component.type)
? childrenOfMergeComponent
.getSourceComponents()
.toArray()
.filter(hasXml)
.filter((c) => !childDestinations.has(c.xml))
.map((c) => ({ shouldDelete: true, output: c.xml, fullName: c.fullName, type: c.type.name }))
: [];
return [...writeInfosForChildren, ...writeInfoForParent, ...writeInfosForMissingChildrenToDelete];
}
}
const hasXml = (c: SourceComponent): c is SourceComponent & { xml: string } => typeof c.xml === 'string';
const allChildrenAreUnaddressable = (type: MetadataType): boolean =>
Object.values(type.children?.types ?? {}).every(
// exclude the COFT (unaddressableWithoutParent) from being deleted because its absence *might* not mean it was deleted from the org
(c) => c.isAddressable === false && c.unaddressableWithoutParent !== true
);
/**
* composedMetadata is a representation of the parent's xml
*
* if there is no CustomObjectTranslation in the org, the composedMetadata will be 2 entries
* the xml declaration, and a fields attribute, which points to the child CustomObjectFieldTranslation
*
* because CustomObjectFieldTranslation is the only metadata type with 'requiresParent' = true we can
* calculate if a CustomObjectTranslation was retrieved from the org (composedMetadata.length > 2), or,
* if we'll have to write an empty CustomObjectTranslation file (composedMetadata.length <=2).
*
* CustomObjectFieldTranslations are only addressable through their parent, and require a
* CustomObjectTranslation file to be present
*/
const handleUnaddressableChildAlone =
(composedMetadataLength: number) =>
(parentXmlObject: XmlObj) =>
(stateSetter: StateSetter) =>
(v: InfoContainer): InfoContainer => {
if (v.childComponent.type.unaddressableWithoutParent && composedMetadataLength <= 2) {
stateSetter(v.childComponent, {
writeInfo: {
source: new JsToXml(parentXmlObject),
output: getDefaultOutput(v.parentComponent),
},
});
}
return v;
};
const getChildWriteInfos =
(stateSetter: StateSetter) =>
(childrenOfMergeComponent: ComponentSet) =>
({ mergeWith, childComponent, value, entryName }: InfoContainer): WriteInfo[] => {
const source = objectToSource(childComponent.type.name)(value);
// if there's nothing to merge with, push write operation now to default location
if (!mergeWith) {
return [{ source, output: getDefaultOutput(childComponent) }];
}
// if the merge parent has a child that can be merged with, push write
// operation now and mark it as merged in the state
if (childrenOfMergeComponent.has(childComponent)) {
const mergeChild = childrenOfMergeComponent.getSourceComponents(childComponent).first();
if (!mergeChild?.xml) {
throw messages.createError('error_parsing_xml', [childComponent.fullName, childComponent.type.name]);
}
stateSetter(childComponent, { foundMerge: true });
return [{ source, output: mergeChild.xml }];
}
// If we have a parent and the child is unaddressable without the parent, keep them
// together on the file system, meaning a new child will not be written to the default dir.
if (childComponent.type.unaddressableWithoutParent && typeof mergeWith?.xml === 'string') {
// get output path from parent
return [
{
source,
output: join(
dirname(mergeWith.xml),
`${entryName}.${ensureString(childComponent.type.suffix)}${META_XML_SUFFIX}`
),
},
];
}
// we didn't find a merge, so we add it to the state for later processing
stateSetter(childComponent, {
writeInfo: { source, output: getDefaultOutput(childComponent) },
});
return [];
};
export const getWriteInfosFromMerge =
(mergeWith: SourceComponent) =>
(stateSetter: StateSetter) =>
(parentXmlObject: XmlObj) =>
(parentComponent: SourceComponent): WriteInfo[] => {
const writeInfo = { source: new JsToXml(parentXmlObject), output: getOutputFile(parentComponent, mergeWith) };
const parentHasRealValues = objectHasSomeRealValues(parentComponent.type)(parentXmlObject);
if (mergeWith?.xml) {
// mark the component as found
stateSetter(parentComponent, { foundMerge: true });
return objectHasSomeRealValues(parentComponent.type)(mergeWith.parseXmlSync()) && !parentHasRealValues
? [] // the target file has values but this process doesn't, so we don't want to overwrite it
: [writeInfo];
}
if (objectHasSomeRealValues(parentComponent.type)(parentXmlObject)) {
// set the state but don't return any writeInfo to avoid writing "empty" (ns-only) parent files
stateSetter(parentComponent, { writeInfo });
}
return [];
};
export const getWriteInfosWithoutMerge =
(defaultDirectory: string | undefined) =>
(parentXmlObject: XmlObj) =>
(component: SourceComponent): WriteInfo[] => {
const output = getOutputFile(component);
// if the parent would be empty
// and it exists
// and every child is addressable
// don't overwrite the existing parent
if (
!objectHasSomeRealValues(component.type)(parentXmlObject) &&
fs.existsSync(join(defaultDirectory ?? '', output)) &&
Object.values(component.type.children ?? {}).every((child) => !child.isAddressable)
) {
return [];
} else {
return [{ source: new JsToXml(parentXmlObject), output }];
}
};
/**
* Helper for setting the decomposed transaction state
*
* @param state
*/
export const setDecomposedState =
(state: DecompositionState) =>
(forComponent: MetadataComponent, props: Partial<Omit<DecompositionStateValue, 'origin'>>): void => {
const key = getKey(forComponent);
state.set(key, {
// origin gets set the first time
...(state.get(key) ?? { origin: forComponent.parent ?? forComponent }),
...(props ?? {}),
});
};
const getKey = (component: MetadataComponent): string => `${component.type.name}#${component.fullName}`;
/** for a component, parse the xml and create an json object with contents, child typeId, etc */
const getComposedMetadataEntries = async (component: SourceComponent): Promise<ComposedMetadata[]> =>
// composedMetadata might be undefined if you call toSourceFormat() from a non-source-backed Component
Object.entries((await component.parseXml())[component.type.name] ?? {}).map(
([tagKey, tagValue]: [string, AnyJson]): ComposedMetadata => ({
tagKey,
tagValue,
parentType: component.type,
childTypeId: tagToChildTypeId({ tagKey, type: component.type }),
})
);
/** where the file goes if there's nothing to merge with */
const getDefaultOutput = (component: MetadataComponent): SourcePath => {
const { parent, fullName, type } = component;
const [baseName, ...tail] = fullName.split('.');
// there could be a '.' inside the child name (ex: PermissionSet.FieldPermissions.field uses Obj__c.Field__c)
const childName = tail.length ? tail.join('.') : undefined;
const output = join(
parent?.type.strategies?.decomposition === 'folderPerType' ? type.directoryName : '',
`${childName ?? baseName}.${ensureString(component.type.suffix)}${META_XML_SUFFIX}`
);
return join(calculateRelativePath('source')({ self: parent?.type ?? type })(fullName)(baseName), output);
};
/** use the given xmlElementName name if it exists, otherwise use see if one matches the directories */
export const tagToChildTypeId = ({ tagKey, type }: { tagKey: string; type: MetadataType }): string | undefined =>
Object.values(type.children?.types ?? {}).find((c) => c.xmlElementName === tagKey)?.id ??
type.children?.directories?.[tagKey];
export const hasChildTypeId = (cm: ComposedMetadata): cm is Required<ComposedMetadata> => !!cm.childTypeId;
export const addChildType = (cm: Required<ComposedMetadata>): ComposedMetadataWithChildType => {
const childType = cm.parentType.children?.types[cm.childTypeId];
if (childType) {
return { ...cm, childType };
}
throw messages.createError('error_missing_child_type_definition', [cm.parentType.name, cm.childTypeId]);
};
/** returns an data structure with lots of context information in it */
const toInfoContainer =
(mergeWith: SourceComponent | undefined) =>
(parent: SourceComponent) =>
(childType: MetadataType) =>
(tagValue: JsonMap): InfoContainer => {
const entryName = ensureString(extractUniqueElementValue(tagValue, childType.uniqueIdElement));
return {
parentComponent: parent,
entryName,
childComponent: {
fullName: `${parent.fullName}.${entryName}`,
type: childType,
parent,
},
value: tagValue,
mergeWith,
};
};
export const forceIgnoreAllowsComponent =
(forceIgnore: ForceIgnore) =>
(ic: InfoContainer): boolean =>
forceIgnore.accepts(getDefaultOutput(ic.childComponent));
/** wrap some xml in the Metadata type and add the NS stuff */
const objectToSource =
(childTypeName: string) =>
(obj: JsonMap): JsToXml =>
new JsToXml({ [childTypeName]: { [XML_NS_KEY]: XML_NS_URL, ...obj } });
/** filter out the children and create the remaining parentXml */
const buildParentXml =
(parentType: MetadataType) =>
(c: ComposedMetadata[]): XmlObj => ({
[parentType.name]: {
[XML_NS_KEY]: XML_NS_URL,
...Object.fromEntries(
c.filter((v) => v.childTypeId === undefined).map(({ tagKey, tagValue }) => [tagKey, tagValue])
),
},
});
export const getOutputFile = (component: SourceComponent, mergeWith?: SourceComponent): string =>
mergeWith?.xml ?? getDefaultOutput(component);