Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: throw an error for unexpected child types #426

Merged
merged 4 commits into from
Aug 18, 2021
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
4 changes: 4 additions & 0 deletions src/client/metadataTransfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ export abstract class MetadataTransfer<
return result;
} catch (e) {
const error = new MetadataTransferError('md_request_fail', e.message);
if (error.stack && e.stack) {
// append the original stack to this new error
error.stack += `\nDUE TO:\n${e.stack}`;
}
if (this.event.listenerCount('error') === 0) {
throw error;
}
Expand Down
38 changes: 26 additions & 12 deletions src/convert/transformers/decomposedMetadataTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { SourcePath, META_XML_SUFFIX, XML_NS_URL, XML_NS_KEY } from '../../commo
import { ComponentSet } from '../../collections';
import { DecompositionState } from '../convertContext';
import { DecompositionStrategy } from '../../registry';
import { TypeInferenceError } from '../../errors';

export class DecomposedMetadataTransformer extends BaseMetadataTransformer {
public async toMetadataFormat(component: SourceComponent): Promise<WriteInfo[]> {
Expand All @@ -32,16 +33,10 @@ export class DecomposedMetadataTransformer extends BaseMetadataTransformer {
} else {
const { fullName } = component;
this.context.recomposition.setState((state) => {
if (state[fullName]) {
for (const child of component.getChildren()) {
state[fullName].children.add(child);
}
} else {
state[fullName] = {
component,
children: new ComponentSet(component.getChildren(), this.registry),
};
if (!state[fullName]) {
state[fullName] = { component, children: new ComponentSet([], this.registry) };
}
state[fullName].children = this.ensureValidChildren(component, state[fullName].children);
});
}
// noop since the finalizer will push the writes to the component writer
Expand All @@ -53,11 +48,9 @@ export class DecomposedMetadataTransformer extends BaseMetadataTransformer {
mergeWith?: SourceComponent
): Promise<WriteInfo[]> {
const writeInfos: WriteInfo[] = [];
const childrenOfMergeComponent = mergeWith && this.ensureValidChildren(mergeWith);
const { type, fullName: parentFullName } = component;

const childrenOfMergeComponent = mergeWith
? new ComponentSet(mergeWith.getChildren(), this.registry)
: undefined;
let parentXmlObject: JsonMap;
const composedMetadata = await this.getComposedMetadataEntries(component);

Expand Down Expand Up @@ -147,6 +140,27 @@ export class DecomposedMetadataTransformer extends BaseMetadataTransformer {
return writeInfos;
}

// Ensures that the children of the provided SourceComponent are valid child
// types before adding them to the returned ComponentSet. Invalid child types
// can occur when projects are structured in an atypical way such as having
// ApexClasses or Layouts within a CustomObject folder.
private ensureValidChildren(component: SourceComponent, compSet?: ComponentSet): ComponentSet {
compSet = compSet || new ComponentSet([], this.registry);
const validChildTypes = Object.keys(component.type.children.types);
for (const child of component.getChildren()) {
// Ensure only valid child types are included with the parent.
if (!validChildTypes.includes(child.type?.id)) {
const filePath = child.xml || child.content;
throw new TypeInferenceError('error_unexpected_child_type', [
filePath,
component.type.name,
]);
}
compSet.add(child);
}
return compSet;
}

private async getComposedMetadataEntries(component: SourceComponent): Promise<[string, any][]> {
const composedMetadata = (await component.parseXml())[component.type.name];
return Object.entries(composedMetadata);
Expand Down
1 change: 1 addition & 0 deletions src/i18n/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const messages = {
error_convert_invalid_format: "Invalid conversion format '%s'",
error_convert_not_implemented: '%s format conversion not yet implemented for type %s',
error_could_not_infer_type: '%s: Could not infer a metadata type',
error_unexpected_child_type: 'Unexpected child metadata [%s] found for parent type [%s]',
error_expected_source_files: "%s: Expected source files for type '%s'",
error_failed_convert: 'Component conversion failed: %s',
error_merge_metadata_target_unsupported:
Expand Down
7 changes: 6 additions & 1 deletion src/resolve/adapters/decomposedSourceAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { SourcePath } from '../../common';
import { SourceComponent } from '../sourceComponent';
import { baseName, parentName, parseMetadataXml } from '../../utils';
import { DecompositionStrategy } from '../../registry';
import { UnexpectedForceIgnore } from '../../errors';
import { TypeInferenceError } from '../../errors';

/**
* Handles decomposed types. A flavor of mixed content where a component can
Expand Down Expand Up @@ -115,6 +115,11 @@ export class DecomposedSourceAdapter extends MixedContentSourceAdapter {
);
}
if (!triggerIsAChild) {
if (!component) {
// This is most likely metadata found within a CustomObject folder that is not a
// child type of CustomObject. E.g., Layout, SharingRules, ApexClass.
throw new TypeInferenceError('error_unexpected_child_type', [trigger, this.type.name]);
}
component.content = pathToContent;
}
}
Expand Down
97 changes: 96 additions & 1 deletion test/convert/transformers/decomposedMetadataTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,25 @@
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import { mockRegistry, mockRegistryData, decomposed } from '../../mock/registry';
import {
mockRegistry,
mockRegistryData,
decomposed,
matchingContentFile,
} from '../../mock/registry';
import { DecomposedMetadataTransformer } from '../../../src/convert/transformers/decomposedMetadataTransformer';
import { expect } from 'chai';
import { createSandbox } from 'sinon';
import { join } from 'path';
import { baseName } from '../../../src/utils';
import { JsToXml } from '../../../src/convert/streams';
import { DECOMPOSED_TOP_LEVEL_COMPONENT } from '../../mock/registry/type-constants/decomposedTopLevelConstants';
import { ComponentSet, SourceComponent } from '../../../src';
import { XML_NS_URL, XML_NS_KEY } from '../../../src/common';
import { ConvertContext } from '../../../src/convert/convertContext';
import { TypeInferenceError } from '../../../src/errors';
import { nls } from '../../../src/i18n';
import { assert } from '@salesforce/ts-types';

const env = createSandbox();

Expand Down Expand Up @@ -66,6 +75,48 @@ describe('DecomposedMetadataTransformer', () => {
},
});
});

it('should throw when an invalid child is included with the parent', async () => {
const { CONTENT_NAMES, XML_NAMES } = matchingContentFile;
const fsUnexpectedChild = [
{
dirPath: decomposed.DECOMPOSED_PATH,
children: [
decomposed.DECOMPOSED_CHILD_XML_NAME_1,
decomposed.DECOMPOSED_CHILD_DIR,
'classes',
],
},
{
dirPath: decomposed.DECOMPOSED_CHILD_DIR_PATH,
children: [decomposed.DECOMPOSED_CHILD_XML_NAME_2],
},
{
dirPath: join(decomposed.DECOMPOSED_PATH, 'classes'),
children: [CONTENT_NAMES[0], XML_NAMES[0]],
},
];
const parentComponent = SourceComponent.createVirtualComponent(
{
name: baseName(decomposed.DECOMPOSED_XML_PATH),
type: decomposed.DECOMPOSED_COMPONENT.type,
xml: decomposed.DECOMPOSED_XML_PATH,
content: decomposed.DECOMPOSED_PATH,
},
fsUnexpectedChild
);
const fsPath = join(decomposed.DECOMPOSED_PATH, 'classes', XML_NAMES[0]);
const transformer = new DecomposedMetadataTransformer(mockRegistry, new ConvertContext());

try {
await transformer.toMetadataFormat(parentComponent);
assert(false, 'expected TypeInferenceError to be thrown');
} catch (err) {
expect(err).to.be.instanceOf(TypeInferenceError);
const msg = nls.localize('error_unexpected_child_type', [fsPath, component.type.name]);
expect(err.message).to.equal(msg);
}
});
});

describe('toSourceFormat', () => {
Expand Down Expand Up @@ -306,6 +357,50 @@ describe('DecomposedMetadataTransformer', () => {
]);
});

it('should throw when an invalid merge child is included with the parent', async () => {
const { CONTENT_NAMES, XML_NAMES } = matchingContentFile;
const fsUnexpectedChild = [
{
dirPath: decomposed.DECOMPOSED_PATH,
children: [
decomposed.DECOMPOSED_CHILD_XML_NAME_1,
decomposed.DECOMPOSED_CHILD_DIR,
'classes',
],
},
{
dirPath: decomposed.DECOMPOSED_CHILD_DIR_PATH,
children: [decomposed.DECOMPOSED_CHILD_XML_NAME_2],
},
{
dirPath: join(decomposed.DECOMPOSED_PATH, 'classes'),
children: [CONTENT_NAMES[0], XML_NAMES[0]],
},
];
const parentComponent = SourceComponent.createVirtualComponent(
{
name: baseName(decomposed.DECOMPOSED_XML_PATH),
type: decomposed.DECOMPOSED_COMPONENT.type,
xml: decomposed.DECOMPOSED_XML_PATH,
content: decomposed.DECOMPOSED_PATH,
},
fsUnexpectedChild
);
const fsPath = join(decomposed.DECOMPOSED_PATH, 'classes', XML_NAMES[0]);
const transformer = new DecomposedMetadataTransformer(mockRegistry, new ConvertContext());

try {
// NOTE: it doesn't matter what the first component is for this test since it's all
// about the child components of the parentComponent.
await transformer.toSourceFormat(component, parentComponent);
assert(false, 'expected TypeInferenceError to be thrown');
} catch (err) {
expect(err).to.be.instanceOf(TypeInferenceError);
const msg = nls.localize('error_unexpected_child_type', [fsPath, component.type.name]);
expect(err.message).to.equal(msg);
}
});

it('should defer write operations for children that are not members of merge component', async () => {
const mergeComponentChild = component.getChildren()[0];
const { fullName, type } = component;
Expand Down
42 changes: 41 additions & 1 deletion test/resolve/adapters/decomposedSourceAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ import {
decomposedtoplevel,
mockRegistryData,
xmlInFolder,
matchingContentFile,
} from '../../mock/registry';
import { expect } from 'chai';
import { assert, expect } from 'chai';
import { VirtualTreeContainer, SourceComponent } from '../../../src';
import { RegistryTestUtil } from '../registryTestUtil';
import { join } from 'path';
import { META_XML_SUFFIX } from '../../../src/common';
import { TypeInferenceError } from '../../../src/errors';
import { nls } from '../../../src/i18n';

describe('DecomposedSourceAdapter', () => {
const type = mockRegistryData.types.decomposed;
Expand Down Expand Up @@ -123,4 +126,41 @@ describe('DecomposedSourceAdapter', () => {
const adapter = new DefaultSourceAdapter(type, mockRegistry);
expect(adapter.getComponent(path)).to.be.undefined;
});

it('should throw an Error when unexpected child type found in parent folder', () => {
// This is most likely an odd project structure such as metadata found within a CustomObject
// folder that is not a child type of CustomObject. E.g., Layout, SharingRules, ApexClass.
// This test adds an ApexClass to the equivalent of here:
// .../main/default/objects/MyObject/classes/MyApexClass.cls-meta.xml
// The actual ApexClass file path for the test is:
// path/to/decomposeds/a/classes/a.mcf-meta.xml
const { CONTENT_NAMES, XML_NAMES } = matchingContentFile;
const fsUnexpectedChild = [
{
dirPath: decomposed.DECOMPOSED_PATH,
children: [
decomposed.DECOMPOSED_CHILD_XML_NAME_1,
decomposed.DECOMPOSED_CHILD_DIR,
'classes',
],
},
{
dirPath: decomposed.DECOMPOSED_CHILD_DIR_PATH,
children: [decomposed.DECOMPOSED_CHILD_XML_NAME_2],
},
{
dirPath: join(decomposed.DECOMPOSED_PATH, 'classes'),
children: [CONTENT_NAMES[0], XML_NAMES[0]],
},
];
const tree = new VirtualTreeContainer(fsUnexpectedChild);
const adapter = new DecomposedSourceAdapter(type, mockRegistry, undefined, tree);
const fsPath = join(decomposed.DECOMPOSED_PATH, 'classes', XML_NAMES[0]);

assert.throws(
() => adapter.getComponent(fsPath, false),
TypeInferenceError,
nls.localize('error_unexpected_child_type', [fsPath, type.name])
);
});
});