diff --git a/common/changes/@rushstack/heft-config-file/user-danade-AllowCustomMergeBehavior_2022-03-14-22-51.json b/common/changes/@rushstack/heft-config-file/user-danade-AllowCustomMergeBehavior_2022-03-14-22-51.json new file mode 100644 index 0000000000..04c0814bab --- /dev/null +++ b/common/changes/@rushstack/heft-config-file/user-danade-AllowCustomMergeBehavior_2022-03-14-22-51.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-config-file", + "comment": "Add support for an inline \"$.inheritanceType\" property. This feature allows for configuration files to specify how object and array properties are inherited, overriding the default inheritance behavior provided by the configuration file class.", + "type": "minor" + } + ], + "packageName": "@rushstack/heft-config-file" +} \ No newline at end of file diff --git a/common/reviews/api/heft-config-file.api.md b/common/reviews/api/heft-config-file.api.md index d1321f82cf..c394bbf136 100644 --- a/common/reviews/api/heft-config-file.api.md +++ b/common/reviews/api/heft-config-file.api.md @@ -48,20 +48,21 @@ export interface IJsonPathsMetadata { export enum InheritanceType { append = "append", custom = "custom", + merge = "merge", replace = "replace" } // @beta (undocumented) export interface IOriginalValueOptions { // (undocumented) - parentObject: TParentProperty; + parentObject: Partial; // (undocumented) propertyName: keyof TParentProperty; } // @beta (undocumented) export type IPropertiesInheritance = { - [propertyName in keyof TConfigurationFile]?: IPropertyInheritance | ICustomPropertyInheritance; + [propertyName in keyof TConfigurationFile]?: IPropertyInheritance | ICustomPropertyInheritance; }; // @beta (undocumented) diff --git a/libraries/heft-config-file/src/ConfigurationFile.ts b/libraries/heft-config-file/src/ConfigurationFile.ts index a1edf36693..977a603711 100644 --- a/libraries/heft-config-file/src/ConfigurationFile.ts +++ b/libraries/heft-config-file/src/ConfigurationFile.ts @@ -22,10 +22,17 @@ interface IConfigurationJson { */ export enum InheritanceType { /** - * Append additional elements after elements from the parent file's property + * Append additional elements after elements from the parent file's property. Only applicable + * for arrays. */ append = 'append', + /** + * Perform a shallow merge of additional elements after elements from the parent file's property. + * Only applicable for objects. + */ + merge = 'merge', + /** * Discard elements from the parent file's property */ @@ -63,6 +70,7 @@ export enum PathResolutionMethod { custom } +const CONFIGURATION_FILE_MERGE_BEHAVIOR_FIELD_REGEX: RegExp = /^\$([^\.]+)\.inheritanceType$/; const CONFIGURATION_FILE_FIELD_ANNOTATION: unique symbol = Symbol('configuration-file-field-annotation'); interface IAnnotatedField { @@ -125,7 +133,7 @@ export interface ICustomPropertyInheritance extends IPropertyInheritanc */ export type IPropertiesInheritance = { [propertyName in keyof TConfigurationFile]?: - | IPropertyInheritance + | IPropertyInheritance | ICustomPropertyInheritance; }; @@ -176,7 +184,7 @@ interface IJsonPathCallbackObject { * @beta */ export interface IOriginalValueOptions { - parentObject: TParentProperty; + parentObject: Partial; propertyName: keyof TParentProperty; } @@ -371,8 +379,6 @@ export class ConfigurationFile { throw new Error(`In config file "${resolvedConfigurationFilePathForLogging}": ${e}`); } - this._schema.validateObject(configurationJson, resolvedConfigurationFilePathForLogging); - this._annotateProperties(resolvedConfigurationFilePath, configurationJson); for (const [jsonPath, metadata] of Object.entries(this._jsonPathMetadata)) { @@ -395,7 +401,7 @@ export class ConfigurationFile { }); } - let parentConfiguration: Partial = {}; + let parentConfiguration: TConfigurationFile | undefined; if (configurationJson.extends) { try { const resolvedParentConfigPath: string = Import.resolveModule({ @@ -420,135 +426,19 @@ export class ConfigurationFile { } } - const propertyNames: Set = new Set([ - ...Object.keys(parentConfiguration), - ...Object.keys(configurationJson) - ]); - - const resultAnnotation: IConfigurationFileFieldAnnotation = { - configurationFilePath: resolvedConfigurationFilePath, - originalValues: {} as TConfigurationFile - }; - const result: TConfigurationFile = { - [CONFIGURATION_FILE_FIELD_ANNOTATION]: resultAnnotation - } as unknown as TConfigurationFile; - for (const propertyName of propertyNames) { - if (propertyName === '$schema' || propertyName === 'extends') { - continue; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const propertyValue: unknown | undefined = (configurationJson as any)[propertyName]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const parentPropertyValue: unknown | undefined = (parentConfiguration as any)[propertyName]; - - const bothAreArrays: boolean = Array.isArray(propertyValue) && Array.isArray(parentPropertyValue); - const defaultInheritanceType: IPropertyInheritance = bothAreArrays - ? { inheritanceType: InheritanceType.append } - : { inheritanceType: InheritanceType.replace }; - const propertyInheritance: IPropertyInheritance = - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (this._propertyInheritanceTypes as any)[propertyName] !== undefined - ? // eslint-disable-next-line @typescript-eslint/no-explicit-any - (this._propertyInheritanceTypes as any)[propertyName] - : defaultInheritanceType; - - let newValue: unknown; - const usePropertyValue: () => void = () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (resultAnnotation.originalValues as any)[propertyName] = this.getPropertyOriginalValue({ - parentObject: configurationJson, - propertyName: propertyName - }); - newValue = propertyValue; - }; - const useParentPropertyValue: () => void = () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (resultAnnotation.originalValues as any)[propertyName] = this.getPropertyOriginalValue({ - parentObject: parentConfiguration, - propertyName: propertyName - }); - newValue = parentPropertyValue; - }; - - if (propertyValue !== undefined && parentPropertyValue === undefined) { - usePropertyValue(); - } else if (parentPropertyValue !== undefined && propertyValue === undefined) { - useParentPropertyValue(); - } else { - switch (propertyInheritance.inheritanceType) { - case InheritanceType.replace: { - if (propertyValue !== undefined) { - usePropertyValue(); - } else { - useParentPropertyValue(); - } - - break; - } - - case InheritanceType.append: { - if (propertyValue !== undefined && parentPropertyValue === undefined) { - usePropertyValue(); - } else if (propertyValue === undefined && parentPropertyValue !== undefined) { - useParentPropertyValue(); - } else { - if (!Array.isArray(propertyValue) || !Array.isArray(parentPropertyValue)) { - throw new Error( - `Issue in processing configuration file property "${propertyName}". ` + - `Property is not an array, but the inheritance type is set as "${InheritanceType.append}"` - ); - } - - newValue = [...parentPropertyValue, ...propertyValue]; - (newValue as unknown as IAnnotatedField)[CONFIGURATION_FILE_FIELD_ANNOTATION] = { - configurationFilePath: undefined, - originalValues: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...(parentPropertyValue as any)[CONFIGURATION_FILE_FIELD_ANNOTATION].originalValues, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...(propertyValue as any)[CONFIGURATION_FILE_FIELD_ANNOTATION].originalValues - } - }; - } - - break; - } - - case InheritanceType.custom: { - const customInheritance: ICustomPropertyInheritance = - propertyInheritance as ICustomPropertyInheritance; - if ( - !customInheritance.inheritanceFunction || - typeof customInheritance.inheritanceFunction !== 'function' - ) { - throw new Error( - 'For property inheritance type "InheritanceType.custom", an inheritanceFunction must be provided.' - ); - } - - newValue = customInheritance.inheritanceFunction(propertyValue, parentPropertyValue); - - break; - } - - default: { - throw new Error(`Unknown inheritance type "${propertyInheritance}"`); - } - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (result as any)[propertyName] = newValue; - } - + const result: Partial = this._mergeConfigurationFiles( + parentConfiguration || {}, + configurationJson, + resolvedConfigurationFilePath + ); try { this._schema.validateObject(result, resolvedConfigurationFilePathForLogging); } catch (e) { throw new Error(`Resolved configuration object does not match schema: ${e}`); } - return result; + // If the schema validates, we can assume that the configuration file is complete. + return result as TConfigurationFile; } private async _tryLoadConfigurationFileInRigAsync( @@ -668,6 +558,241 @@ export class ConfigurationFile { } } + private _mergeConfigurationFiles( + parentConfiguration: Partial, + configurationJson: Partial, + resolvedConfigurationFilePath: string + ): Partial { + const ignoreProperties: Set = new Set(['extends', '$schema']); + + // Need to do a dance with the casting here because while we know that JSON keys are always + // strings, TypeScript doesn't. + return this._mergeObjects( + parentConfiguration as { [key: string]: unknown }, + configurationJson as { [key: string]: unknown }, + resolvedConfigurationFilePath, + this._propertyInheritanceTypes as IPropertiesInheritance<{ [key: string]: unknown }>, + ignoreProperties + ) as Partial; + } + + private _mergeObjects( + parentObject: Partial, + currentObject: Partial, + resolvedConfigurationFilePath: string, + configuredPropertyInheritance?: IPropertiesInheritance, + ignoreProperties?: Set + ): Partial { + const resultAnnotation: IConfigurationFileFieldAnnotation> = { + configurationFilePath: resolvedConfigurationFilePath, + originalValues: {} as Partial + }; + const result: Partial = { + [CONFIGURATION_FILE_FIELD_ANNOTATION]: resultAnnotation + } as unknown as Partial; + + // An array of property names that are on the merging object. Typed as Set since it may + // contain inheritance type annotation keys, or other built-in properties that we ignore + // (eg. "extends", "$schema"). + const currentObjectPropertyNames: Set = new Set(Object.keys(currentObject)); + // An array of property names that should be included in the resulting object. + const filteredObjectPropertyNames: (keyof TField)[] = []; + // A map of property names to their inheritance type. + const inheritanceTypeMap: Map> = new Map(); + + // Do a first pass to gather and strip the inheritance type annotations from the merging object. + for (const propertyName of currentObjectPropertyNames) { + if (ignoreProperties && ignoreProperties.has(propertyName)) { + continue; + } + + // Try to get the inheritance type annotation from the merging object using the regex. + // Note: since this regex matches a specific style of property name, we should not need to + // allow for any escaping of $-prefixed properties. If this ever changes (eg. to allow for + // `"$propertyName": { ... }` options), then we'll likely need to handle that error case, + // as well as allow escaping $-prefixed properties that developers want to be serialized, + // possibly by using the form `$$propertyName` to escape `$propertyName`. + const inheritanceTypeMatches: RegExpMatchArray | null = propertyName.match( + CONFIGURATION_FILE_MERGE_BEHAVIOR_FIELD_REGEX + ); + if (inheritanceTypeMatches) { + // Should always be of length 2, since the first match is the entire string and the second + // match is the capture group. + const mergeTargetPropertyName: string = inheritanceTypeMatches[1]; + const inheritanceTypeRaw: unknown | undefined = currentObject[propertyName]; + if (!currentObjectPropertyNames.has(mergeTargetPropertyName)) { + throw new Error( + `Issue in processing configuration file property "${propertyName}". ` + + `An inheritance type was provided but no matching property was found in the parent.` + ); + } else if (typeof inheritanceTypeRaw !== 'string') { + throw new Error( + `Issue in processing configuration file property "${propertyName}". ` + + `An unsupported inheritance type was provided: ${JSON.stringify(inheritanceTypeRaw)}` + ); + } else if (typeof currentObject[mergeTargetPropertyName] !== 'object') { + throw new Error( + `Issue in processing configuration file property "${propertyName}". ` + + `An inheritance type was provided for a property that is not a keyed object or array.` + ); + } + switch (inheritanceTypeRaw.toLowerCase()) { + case 'append': + inheritanceTypeMap.set(mergeTargetPropertyName, { inheritanceType: InheritanceType.append }); + break; + case 'merge': + inheritanceTypeMap.set(mergeTargetPropertyName, { inheritanceType: InheritanceType.merge }); + break; + case 'replace': + inheritanceTypeMap.set(mergeTargetPropertyName, { inheritanceType: InheritanceType.replace }); + break; + default: + throw new Error( + `Issue in processing configuration file property "${propertyName}". ` + + `An unsupported inheritance type was provided: "${inheritanceTypeRaw}"` + ); + } + } else { + filteredObjectPropertyNames.push(propertyName); + } + } + + // We only filter the currentObject because the parent object should already be filtered + const propertyNames: Set = new Set([ + ...Object.keys(parentObject), + ...filteredObjectPropertyNames + ]); + + // Cycle through properties and merge them + for (const propertyName of propertyNames) { + const propertyValue: TField[keyof TField] | undefined = currentObject[propertyName]; + const parentPropertyValue: TField[keyof TField] | undefined = parentObject[propertyName]; + + let newValue: TField[keyof TField] | undefined; + const usePropertyValue: () => void = () => { + resultAnnotation.originalValues[propertyName] = this.getPropertyOriginalValue({ + parentObject: currentObject, + propertyName: propertyName + }); + newValue = propertyValue; + }; + const useParentPropertyValue: () => void = () => { + resultAnnotation.originalValues[propertyName] = this.getPropertyOriginalValue({ + parentObject: parentObject, + propertyName: propertyName + }); + newValue = parentPropertyValue; + }; + + if (propertyValue !== undefined && parentPropertyValue === undefined) { + usePropertyValue(); + } else if (parentPropertyValue !== undefined && propertyValue === undefined) { + useParentPropertyValue(); + } else if (propertyValue !== undefined && parentPropertyValue !== undefined) { + // If the property is an inheritance type annotation, use it. Fallback to the configuration file inheritance + // behavior, and if one isn't specified, use the default. + let propertyInheritance: IPropertyInheritance | undefined = + inheritanceTypeMap.get(propertyName); + if (!propertyInheritance) { + const bothAreArrays: boolean = Array.isArray(propertyValue) && Array.isArray(parentPropertyValue); + propertyInheritance = + configuredPropertyInheritance?.[propertyName] ?? + (bothAreArrays + ? { inheritanceType: InheritanceType.append } + : { inheritanceType: InheritanceType.replace }); + } + + switch (propertyInheritance.inheritanceType) { + case InheritanceType.replace: { + usePropertyValue(); + + break; + } + + case InheritanceType.append: { + if (!Array.isArray(propertyValue) || !Array.isArray(parentPropertyValue)) { + throw new Error( + `Issue in processing configuration file property "${propertyName}". ` + + `Property is not an array, but the inheritance type is set as "${InheritanceType.append}"` + ); + } + + newValue = [...parentPropertyValue, ...propertyValue] as TField[keyof TField]; + (newValue as unknown as IAnnotatedField)[CONFIGURATION_FILE_FIELD_ANNOTATION] = { + configurationFilePath: undefined, + originalValues: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...(parentPropertyValue as any)[CONFIGURATION_FILE_FIELD_ANNOTATION].originalValues, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...(propertyValue as any)[CONFIGURATION_FILE_FIELD_ANNOTATION].originalValues + } + }; + + break; + } + + case InheritanceType.merge: { + if (parentPropertyValue === null || propertyValue === null) { + throw new Error( + `Issue in processing configuration file property "${propertyName}". ` + + `Null values cannot be used when the inheritance type is set as "${InheritanceType.merge}"` + ); + } else if ( + (propertyValue && typeof propertyValue !== 'object') || + (parentPropertyValue && typeof parentPropertyValue !== 'object') + ) { + throw new Error( + `Issue in processing configuration file property "${propertyName}". ` + + `Primitive types cannot be provided when the inheritance type is set as "${InheritanceType.merge}"` + ); + } else if (Array.isArray(propertyValue) || Array.isArray(parentPropertyValue)) { + throw new Error( + `Issue in processing configuration file property "${propertyName}". ` + + `Property is not a keyed object, but the inheritance type is set as "${InheritanceType.merge}"` + ); + } + + // Recursively merge the parent and child objects. Don't pass the configuredPropertyInheritance or + // ignoreProperties because we are no longer at the top level of the configuration file. We also know + // that it must be a string-keyed object, since the JSON spec requires it. + newValue = this._mergeObjects( + parentPropertyValue as { [key: string]: unknown }, + propertyValue as { [key: string]: unknown }, + resolvedConfigurationFilePath + ) as TField[keyof TField]; + + break; + } + + case InheritanceType.custom: { + const customInheritance: ICustomPropertyInheritance = + propertyInheritance as ICustomPropertyInheritance; + if ( + !customInheritance.inheritanceFunction || + typeof customInheritance.inheritanceFunction !== 'function' + ) { + throw new Error( + 'For property inheritance type "InheritanceType.custom", an inheritanceFunction must be provided.' + ); + } + + newValue = customInheritance.inheritanceFunction(propertyValue, parentPropertyValue); + + break; + } + + default: { + throw new Error(`Unknown inheritance type "${propertyInheritance}"`); + } + } + } + + result[propertyName] = newValue; + } + + return result; + } + private _getConfigurationFilePathForProject(projectPath: string): string { return nodeJsPath.resolve(projectPath, this.projectRelativeFilePath); } diff --git a/libraries/heft-config-file/src/test/ConfigurationFile.test.ts b/libraries/heft-config-file/src/test/ConfigurationFile.test.ts index 76effa50d2..7510202636 100644 --- a/libraries/heft-config-file/src/test/ConfigurationFile.test.ts +++ b/libraries/heft-config-file/src/test/ConfigurationFile.test.ts @@ -421,6 +421,279 @@ describe(ConfigurationFile.name, () => { }); }); + describe('a complex file with inheritance type annotations', () => { + interface IInheritanceTypeConfigFile { + a: string; + b: { c: string }[]; + d: { + e: string; + f: string; + g: { h: string }[]; + i: { j: string }[]; + k: { + l: string; + m: { n: string }[]; + z?: string; + }; + o: { + p: { q: string }[]; + }; + r: { + s: string; + }; + y?: { + z: string; + }; + }; + y?: { + z: string; + }; + } + + interface ISimpleInheritanceTypeConfigFile { + a: { b: string }[]; + c: { + d: { e: string }[]; + }; + f: { + g: { h: string }[]; + i: { + j: { k: string }[]; + }; + }; + l: string; + } + + it('Correctly loads a complex config file with inheritance type annotations', async () => { + const projectRelativeFilePath: string = 'inheritanceTypeConfigFile/inheritanceTypeConfigFileB.json'; + const rootConfigFilePath: string = nodeJsPath.resolve( + __dirname, + 'inheritanceTypeConfigFile', + 'inheritanceTypeConfigFileA.json' + ); + const secondConfigFilePath: string = nodeJsPath.resolve( + __dirname, + 'inheritanceTypeConfigFile', + 'inheritanceTypeConfigFileB.json' + ); + const schemaPath: string = nodeJsPath.resolve( + __dirname, + 'inheritanceTypeConfigFile', + 'inheritanceTypeConfigFile.schema.json' + ); + + const configFileLoader: ConfigurationFile = + new ConfigurationFile({ + projectRelativeFilePath: projectRelativeFilePath, + jsonSchemaPath: schemaPath + }); + const loadedConfigFile: IInheritanceTypeConfigFile = + await configFileLoader.loadConfigurationFileForProjectAsync(terminal, __dirname); + const expectedConfigFile: IInheritanceTypeConfigFile = { + a: 'A', + // "$b.inheritanceType": "append" + b: [{ c: 'A' }, { c: 'B' }], + // "$d.inheritanceType": "merge" + d: { + e: 'A', + f: 'B', + // "$g.inheritanceType": "append" + g: [{ h: 'A' }, { h: 'B' }], + // "$i.inheritanceType": "replace" + i: [{ j: 'B' }], + // "$k.inheritanceType": "merge" + k: { + l: 'A', + m: [{ n: 'A' }, { n: 'B' }], + z: 'B' + }, + // "$o.inheritanceType": "replace" + o: { + p: [{ q: 'B' }] + }, + r: { + s: 'A' + }, + y: { + z: 'B' + } + }, + y: { + z: 'B' + } + }; + + expect(JSON.stringify(loadedConfigFile)).toEqual(JSON.stringify(expectedConfigFile)); + + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.b[0])).toEqual(rootConfigFilePath); + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.b[1])).toEqual(secondConfigFilePath); + + // loadedConfigFile.d source path is the second config file since it was merged into the first + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.d)).toEqual(secondConfigFilePath); + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.d.g[0])).toEqual(rootConfigFilePath); + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.d.g[1])).toEqual(secondConfigFilePath); + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.d.i[0])).toEqual(secondConfigFilePath); + + // loadedConfigFile.d.k source path is the second config file since it was merged into the first + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.d.k)).toEqual(secondConfigFilePath); + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.d.k.m[0])).toEqual(rootConfigFilePath); + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.d.k.m[1])).toEqual( + secondConfigFilePath + ); + + // loadedConfigFile.d.o source path is the second config file since it replaced the first + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.d.o)).toEqual(secondConfigFilePath); + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.d.o.p[0])).toEqual( + secondConfigFilePath + ); + + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.d.r)).toEqual(rootConfigFilePath); + + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.d.y!)).toEqual(secondConfigFilePath); + + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.y!)).toEqual(secondConfigFilePath); + }); + + it('Correctly loads a complex config file with a single inheritance type annotation', async () => { + const projectRelativeFilePath: string = + 'simpleInheritanceTypeConfigFile/simpleInheritanceTypeConfigFileB.json'; + const rootConfigFilePath: string = nodeJsPath.resolve( + __dirname, + 'simpleInheritanceTypeConfigFile', + 'simpleInheritanceTypeConfigFileA.json' + ); + const secondConfigFilePath: string = nodeJsPath.resolve( + __dirname, + 'simpleInheritanceTypeConfigFile', + 'simpleInheritanceTypeConfigFileB.json' + ); + const schemaPath: string = nodeJsPath.resolve( + __dirname, + 'simpleInheritanceTypeConfigFile', + 'simpleInheritanceTypeConfigFile.schema.json' + ); + + const configFileLoader: ConfigurationFile = + new ConfigurationFile({ + projectRelativeFilePath: projectRelativeFilePath, + jsonSchemaPath: schemaPath + }); + const loadedConfigFile: ISimpleInheritanceTypeConfigFile = + await configFileLoader.loadConfigurationFileForProjectAsync(terminal, __dirname); + const expectedConfigFile: ISimpleInheritanceTypeConfigFile = { + a: [{ b: 'A' }, { b: 'B' }], + c: { + d: [{ e: 'B' }] + }, + // "$f.inheritanceType": "merge" + f: { + g: [{ h: 'A' }, { h: 'B' }], + i: { + j: [{ k: 'B' }] + } + }, + l: 'A' + }; + + expect(JSON.stringify(loadedConfigFile)).toEqual(JSON.stringify(expectedConfigFile)); + + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.a[0])).toEqual(rootConfigFilePath); + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.a[1])).toEqual(secondConfigFilePath); + + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.c)).toEqual(secondConfigFilePath); + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.c.d[0])).toEqual(secondConfigFilePath); + + // loadedConfigFile.f source path is the second config file since it was merged into the first + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.f)).toEqual(secondConfigFilePath); + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.f.g[0])).toEqual(rootConfigFilePath); + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.f.g[1])).toEqual(secondConfigFilePath); + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.f.i)).toEqual(secondConfigFilePath); + expect(configFileLoader.getObjectSourceFilePath(loadedConfigFile.f.i.j[0])).toEqual( + secondConfigFilePath + ); + }); + + it("throws an error when an array uses the 'merge' inheritance type", async () => { + const schemaPath: string = nodeJsPath.resolve( + __dirname, + 'simpleInheritanceTypeConfigFile', + 'simpleInheritanceTypeConfigFile.schema.json' + ); + const configFileLoader: ConfigurationFile = new ConfigurationFile({ + projectRelativeFilePath: 'simpleInheritanceTypeConfigFile/badInheritanceTypeConfigFileA.json', + jsonSchemaPath: schemaPath + }); + + await expect( + configFileLoader.loadConfigurationFileForProjectAsync(terminal, __dirname) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + it("throws an error when a keyed object uses the 'append' inheritance type", async () => { + const schemaPath: string = nodeJsPath.resolve( + __dirname, + 'simpleInheritanceTypeConfigFile', + 'simpleInheritanceTypeConfigFile.schema.json' + ); + const configFileLoader: ConfigurationFile = new ConfigurationFile({ + projectRelativeFilePath: 'simpleInheritanceTypeConfigFile/badInheritanceTypeConfigFileB.json', + jsonSchemaPath: schemaPath + }); + + await expect( + configFileLoader.loadConfigurationFileForProjectAsync(terminal, __dirname) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + it('throws an error when a non-object property uses an inheritance type', async () => { + const schemaPath: string = nodeJsPath.resolve( + __dirname, + 'simpleInheritanceTypeConfigFile', + 'simpleInheritanceTypeConfigFile.schema.json' + ); + const configFileLoader: ConfigurationFile = new ConfigurationFile({ + projectRelativeFilePath: 'simpleInheritanceTypeConfigFile/badInheritanceTypeConfigFileC.json', + jsonSchemaPath: schemaPath + }); + + await expect( + configFileLoader.loadConfigurationFileForProjectAsync(terminal, __dirname) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + it('throws an error when an inheritance type is specified for an unspecified property', async () => { + const schemaPath: string = nodeJsPath.resolve( + __dirname, + 'simpleInheritanceTypeConfigFile', + 'simpleInheritanceTypeConfigFile.schema.json' + ); + const configFileLoader: ConfigurationFile = new ConfigurationFile({ + projectRelativeFilePath: 'simpleInheritanceTypeConfigFile/badInheritanceTypeConfigFileD.json', + jsonSchemaPath: schemaPath + }); + + await expect( + configFileLoader.loadConfigurationFileForProjectAsync(terminal, __dirname) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + it('throws an error when an unsupported inheritance type is specified', async () => { + const schemaPath: string = nodeJsPath.resolve( + __dirname, + 'simpleInheritanceTypeConfigFile', + 'simpleInheritanceTypeConfigFile.schema.json' + ); + const configFileLoader: ConfigurationFile = new ConfigurationFile({ + projectRelativeFilePath: 'simpleInheritanceTypeConfigFile/badInheritanceTypeConfigFileE.json', + jsonSchemaPath: schemaPath + }); + + await expect( + configFileLoader.loadConfigurationFileForProjectAsync(terminal, __dirname) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + }); + describe('loading a rig', () => { const projectFolder: string = nodeJsPath.resolve(__dirname, 'project-referencing-rig'); const rigConfig: RigConfig = RigConfig.loadForProjectFolder({ projectFolderPath: projectFolder }); diff --git a/libraries/heft-config-file/src/test/__snapshots__/ConfigurationFile.test.ts.snap b/libraries/heft-config-file/src/test/__snapshots__/ConfigurationFile.test.ts.snap index 4ef175939b..22283591d2 100644 --- a/libraries/heft-config-file/src/test/__snapshots__/ConfigurationFile.test.ts.snap +++ b/libraries/heft-config-file/src/test/__snapshots__/ConfigurationFile.test.ts.snap @@ -120,8 +120,88 @@ Object { } `; +exports[`ConfigurationFile a complex file with inheritance type annotations Correctly loads a complex config file with a single inheritance type annotation 1`] = ` +Object { + "debug": "", + "error": "", + "log": "", + "verbose": "", + "warning": "", +} +`; + +exports[`ConfigurationFile a complex file with inheritance type annotations Correctly loads a complex config file with inheritance type annotations 1`] = ` +Object { + "debug": "", + "error": "", + "log": "", + "verbose": "", + "warning": "", +} +`; + +exports[`ConfigurationFile a complex file with inheritance type annotations throws an error when a keyed object uses the 'append' inheritance type 1`] = `"Issue in processing configuration file property \\"c\\". Property is not an array, but the inheritance type is set as \\"append\\""`; + +exports[`ConfigurationFile a complex file with inheritance type annotations throws an error when a keyed object uses the 'append' inheritance type 2`] = ` +Object { + "debug": "", + "error": "", + "log": "", + "verbose": "", + "warning": "", +} +`; + +exports[`ConfigurationFile a complex file with inheritance type annotations throws an error when a non-object property uses an inheritance type 1`] = `"Issue in processing configuration file property \\"$l.inheritanceType\\". An inheritance type was provided for a property that is not a keyed object or array."`; + +exports[`ConfigurationFile a complex file with inheritance type annotations throws an error when a non-object property uses an inheritance type 2`] = ` +Object { + "debug": "", + "error": "", + "log": "", + "verbose": "", + "warning": "", +} +`; + +exports[`ConfigurationFile a complex file with inheritance type annotations throws an error when an array uses the 'merge' inheritance type 1`] = `"Issue in processing configuration file property \\"a\\". Property is not a keyed object, but the inheritance type is set as \\"merge\\""`; + +exports[`ConfigurationFile a complex file with inheritance type annotations throws an error when an array uses the 'merge' inheritance type 2`] = ` +Object { + "debug": "", + "error": "", + "log": "", + "verbose": "", + "warning": "", +} +`; + +exports[`ConfigurationFile a complex file with inheritance type annotations throws an error when an inheritance type is specified for an unspecified property 1`] = `"Issue in processing configuration file property \\"$c.inheritanceType\\". An inheritance type was provided but no matching property was found in the parent."`; + +exports[`ConfigurationFile a complex file with inheritance type annotations throws an error when an inheritance type is specified for an unspecified property 2`] = ` +Object { + "debug": "", + "error": "", + "log": "", + "verbose": "", + "warning": "", +} +`; + +exports[`ConfigurationFile a complex file with inheritance type annotations throws an error when an unsupported inheritance type is specified 1`] = `"Issue in processing configuration file property \\"$a.inheritanceType\\". An unsupported inheritance type was provided: \\"custom\\""`; + +exports[`ConfigurationFile a complex file with inheritance type annotations throws an error when an unsupported inheritance type is specified 2`] = ` +Object { + "debug": "", + "error": "", + "log": "", + "verbose": "", + "warning": "", +} +`; + exports[`ConfigurationFile error cases Throws an error for a file that doesn't match its schema 1`] = ` -"JSON validation failed: +"Resolved configuration object does not match schema: Error: JSON validation failed: /src/test/errorCases/invalidType/config.json Error: #/filePaths diff --git a/libraries/heft-config-file/src/test/inheritanceTypeConfigFile/inheritanceTypeConfigFile.schema.json b/libraries/heft-config-file/src/test/inheritanceTypeConfigFile/inheritanceTypeConfigFile.schema.json new file mode 100644 index 0000000000..cf761aa0ec --- /dev/null +++ b/libraries/heft-config-file/src/test/inheritanceTypeConfigFile/inheritanceTypeConfigFile.schema.json @@ -0,0 +1,152 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Inheritance Type Configuration", + "description": "Defines an arbitrary configration file used to test inheritance", + "type": "object", + + "additionalProperties": false, + + "required": ["a", "b", "d"], + + "properties": { + "$schema": { + "description": "Part of the JSON Schema standard, this optional keyword declares the URL of the schema that the file conforms to. Editors may download the schema and use it to perform syntax highlighting.", + "type": "string" + }, + + "extends": { + "type": "string" + }, + + "a": { + "type": "string" + }, + + "b": { + "type": "array", + "items": { + "type": "object", + "required": ["c"], + "properties": { + "c": { + "type": "string" + } + } + } + }, + + "d": { + "type": "object", + "required": ["e", "f", "g", "i", "k", "o", "r"], + "properties": { + "e": { + "type": "string" + }, + + "f": { + "type": "string" + }, + + "g": { + "type": "array", + "items": { + "type": "object", + "required": ["h"], + "properties": { + "h": { + "type": "string" + } + } + } + }, + + "i": { + "type": "array", + "items": { + "type": "object", + "required": ["j"], + "properties": { + "j": { + "type": "string" + } + } + } + }, + + "k": { + "type": "object", + "required": ["l", "m"], + "properties": { + "l": { + "type": "string" + }, + "m": { + "type": "array", + "items": { + "type": "object", + "required": ["n"], + "properties": { + "n": { + "type": "string" + } + } + } + }, + "z": { + "type": "string" + } + } + }, + + "o": { + "type": "object", + "required": ["p"], + "properties": { + "p": { + "type": "array", + "items": { + "type": "object", + "required": ["q"], + "properties": { + "q": { + "type": "string" + } + } + } + } + } + }, + + "r": { + "type": "object", + "required": ["s"], + "properties": { + "s": { + "type": "string" + } + } + }, + + "y": { + "type": "object", + "required": ["z"], + "properties": { + "z": { + "type": "string" + } + } + } + } + }, + + "y": { + "type": "object", + "required": ["z"], + "properties": { + "z": { + "type": "string" + } + } + } + } +} diff --git a/libraries/heft-config-file/src/test/inheritanceTypeConfigFile/inheritanceTypeConfigFileA.json b/libraries/heft-config-file/src/test/inheritanceTypeConfigFile/inheritanceTypeConfigFileA.json new file mode 100644 index 0000000000..20c60fccf6 --- /dev/null +++ b/libraries/heft-config-file/src/test/inheritanceTypeConfigFile/inheritanceTypeConfigFileA.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://schema.net/", + + "a": "A", + + "b": [ + { + "c": "A" + } + ], + + "d": { + "e": "A", + + "f": "A", + + "g": [{ "h": "A" }], + + "i": [{ "j": "A" }], + + "k": { + "l": "A", + "m": [{ "n": "A" }] + }, + + "o": { + "p": [{ "q": "A" }] + }, + + "r": { + "s": "A" + } + } +} diff --git a/libraries/heft-config-file/src/test/inheritanceTypeConfigFile/inheritanceTypeConfigFileB.json b/libraries/heft-config-file/src/test/inheritanceTypeConfigFile/inheritanceTypeConfigFileB.json new file mode 100644 index 0000000000..d391959250 --- /dev/null +++ b/libraries/heft-config-file/src/test/inheritanceTypeConfigFile/inheritanceTypeConfigFileB.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://schema.net/", + + "extends": "./inheritanceTypeConfigFileA.json", + + "$b.inheritanceType": "append", + "b": [ + { + "c": "B" + } + ], + + "$d.inheritanceType": "merge", + "d": { + "f": "B", + + "$g.inheritanceType": "append", + "g": [{ "h": "B" }], + + "$i.inheritanceType": "replace", + "i": [{ "j": "B" }], + + "$k.inheritanceType": "merge", + "k": { + "$m.inheritanceType": "append", + "m": [{ "n": "B" }], + "z": "B" + }, + + "$o.inheritanceType": "replace", + "o": { + "p": [{ "q": "B" }] + }, + + "y": { + "z": "B" + } + }, + + "y": { + "z": "B" + } +} diff --git a/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/badInheritanceTypeConfigFileA.json b/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/badInheritanceTypeConfigFileA.json new file mode 100644 index 0000000000..b628817309 --- /dev/null +++ b/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/badInheritanceTypeConfigFileA.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://schema.net/", + + "extends": "./simpleInheritanceTypeConfigFileA.json", + + "$a.inheritanceType": "merge", + "a": [ + { + "b": "B" + } + ] +} diff --git a/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/badInheritanceTypeConfigFileB.json b/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/badInheritanceTypeConfigFileB.json new file mode 100644 index 0000000000..49cd809768 --- /dev/null +++ b/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/badInheritanceTypeConfigFileB.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://schema.net/", + + "extends": "./simpleInheritanceTypeConfigFileA.json", + + "$c.inheritanceType": "append", + "c": { + "d": [{ "e": "B" }] + } +} diff --git a/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/badInheritanceTypeConfigFileC.json b/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/badInheritanceTypeConfigFileC.json new file mode 100644 index 0000000000..42f31760e8 --- /dev/null +++ b/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/badInheritanceTypeConfigFileC.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://schema.net/", + + "extends": "./simpleInheritanceTypeConfigFileA.json", + + "$l.inheritanceType": "replace", + "l": "B" +} diff --git a/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/badInheritanceTypeConfigFileD.json b/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/badInheritanceTypeConfigFileD.json new file mode 100644 index 0000000000..076557439d --- /dev/null +++ b/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/badInheritanceTypeConfigFileD.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://schema.net/", + + "extends": "./simpleInheritanceTypeConfigFileA.json", + + "$c.inheritanceType": "merge" +} diff --git a/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/badInheritanceTypeConfigFileE.json b/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/badInheritanceTypeConfigFileE.json new file mode 100644 index 0000000000..ec3f9fe2f9 --- /dev/null +++ b/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/badInheritanceTypeConfigFileE.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://schema.net/", + + "extends": "./simpleInheritanceTypeConfigFileA.json", + + "$a.inheritanceType": "custom", + "a": [ + { + "b": "B" + } + ] +} diff --git a/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/simpleInheritanceTypeConfigFile.schema.json b/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/simpleInheritanceTypeConfigFile.schema.json new file mode 100644 index 0000000000..c96567e8d5 --- /dev/null +++ b/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/simpleInheritanceTypeConfigFile.schema.json @@ -0,0 +1,85 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Inheritance Type Configuration", + "description": "Defines an arbitrary configration file used to test inheritance", + "type": "object", + + "additionalProperties": false, + + "required": ["a", "c", "f", "l"], + + "properties": { + "a": { + "type": "array", + "items": { + "type": "object", + "required": ["b"], + "properties": { + "b": { + "type": "string" + } + } + } + }, + + "c": { + "type": "object", + "required": ["d"], + "properties": { + "d": { + "type": "array", + "items": { + "type": "object", + "required": ["e"], + "properties": { + "e": { + "type": "string" + } + } + } + } + } + }, + + "f": { + "type": "object", + "required": ["g", "i"], + "properties": { + "g": { + "type": "array", + "items": { + "type": "object", + "required": ["h"], + "properties": { + "h": { + "type": "string" + } + } + } + }, + + "i": { + "type": "object", + "properties": { + "j": { + "type": "array", + "items": { + "type": "object", + "required": ["k"], + "properties": { + "k": { + "type": "string" + } + } + } + } + } + } + } + }, + + "l": { + "type": "string" + } + } +} diff --git a/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/simpleInheritanceTypeConfigFileA.json b/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/simpleInheritanceTypeConfigFileA.json new file mode 100644 index 0000000000..c005aabed7 --- /dev/null +++ b/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/simpleInheritanceTypeConfigFileA.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://schema.net/", + + "a": [ + { + "b": "A" + } + ], + + "c": { + "d": [{ "e": "A" }] + }, + + "f": { + "g": [{ "h": "A" }], + + "i": { + "j": [{ "k": "A" }] + } + }, + + "l": "A" +} diff --git a/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/simpleInheritanceTypeConfigFileB.json b/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/simpleInheritanceTypeConfigFileB.json new file mode 100644 index 0000000000..2ccae63bd7 --- /dev/null +++ b/libraries/heft-config-file/src/test/simpleInheritanceTypeConfigFile/simpleInheritanceTypeConfigFileB.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://schema.net/", + + "extends": "./simpleInheritanceTypeConfigFileA.json", + + "a": [ + { + "b": "B" + } + ], + + "c": { + "d": [{ "e": "B" }] + }, + + "$f.inheritanceType": "merge", + "f": { + "g": [{ "h": "B" }], + + "i": { + "j": [{ "k": "B" }] + } + } +}