packages/@aws-cdk/cloudformation-diff/lib/diff/util.ts (121 lines of code) (raw):
import { loadAwsServiceSpecSync } from '@aws-cdk/aws-service-spec';
import type { Resource, SpecDatabase } from '@aws-cdk/service-spec-types';
/**
* Compares two objects for equality, deeply. The function handles arguments that are
* +null+, +undefined+, arrays and objects. For objects, the function will not take the
* object prototype into account for the purpose of the comparison, only the values of
* properties reported by +Object.keys+.
*
* If both operands can be parsed to equivalent numbers, will return true.
* This makes diff consistent with CloudFormation, where a numeric 10 and a literal "10"
* are considered equivalent.
*
* @param lvalue the left operand of the equality comparison.
* @param rvalue the right operand of the equality comparison.
*
* @returns +true+ if both +lvalue+ and +rvalue+ are equivalent to each other.
*/
export function deepEqual(lvalue: any, rvalue: any): boolean {
if (lvalue === rvalue) {
return true;
}
// CloudFormation allows passing strings into boolean-typed fields
if (((typeof lvalue === 'string' && typeof rvalue === 'boolean') ||
(typeof lvalue === 'boolean' && typeof rvalue === 'string')) &&
lvalue.toString() === rvalue.toString()) {
return true;
}
// allows a numeric 10 and a literal "10" to be equivalent;
// this is consistent with CloudFormation.
if ((typeof lvalue === 'string' || typeof rvalue === 'string') &&
safeParseFloat(lvalue) === safeParseFloat(rvalue)) {
return true;
}
if (typeof lvalue !== typeof rvalue) {
return false;
}
if (Array.isArray(lvalue) !== Array.isArray(rvalue)) {
return false;
}
if (Array.isArray(lvalue) /* && Array.isArray(rvalue) */) {
if (lvalue.length !== rvalue.length) {
return false;
}
for (let i = 0 ; i < lvalue.length ; i++) {
if (!deepEqual(lvalue[i], rvalue[i])) {
return false;
}
}
return true;
}
if (typeof lvalue === 'object' /* && typeof rvalue === 'object' */) {
if (lvalue === null || rvalue === null) {
// If both were null, they'd have been ===
return false;
}
const keys = Object.keys(lvalue);
if (keys.length !== Object.keys(rvalue).length) {
return false;
}
for (const key of keys) {
if (!rvalue.hasOwnProperty(key)) {
return false;
}
if (key === 'DependsOn') {
if (!dependsOnEqual(lvalue[key], rvalue[key])) {
return false;
}
// check differences other than `DependsOn`
continue;
}
if (!deepEqual(lvalue[key], rvalue[key])) {
return false;
}
}
return true;
}
// Neither object, nor array: I deduce this is primitive type
// Primitive type and not ===, so I deduce not deepEqual
return false;
}
/**
* Compares two arguments to DependsOn for equality.
*
* @param lvalue the left operand of the equality comparison.
* @param rvalue the right operand of the equality comparison.
*
* @returns +true+ if both +lvalue+ and +rvalue+ are equivalent to each other.
*/
function dependsOnEqual(lvalue: any, rvalue: any): boolean {
// allows ['Value'] and 'Value' to be equal
if (Array.isArray(lvalue) !== Array.isArray(rvalue)) {
const array = Array.isArray(lvalue) ? lvalue : rvalue;
const nonArray = Array.isArray(lvalue) ? rvalue : lvalue;
if (array.length === 1 && deepEqual(array[0], nonArray)) {
return true;
}
return false;
}
// allows arrays passed to DependsOn to be equivalent irrespective of element order
if (Array.isArray(lvalue) && Array.isArray(rvalue)) {
if (lvalue.length !== rvalue.length) {
return false;
}
for (let i = 0 ; i < lvalue.length ; i++) {
for (let j = 0 ; j < lvalue.length ; j++) {
if ((!deepEqual(lvalue[i], rvalue[j])) && (j === lvalue.length - 1)) {
return false;
}
break;
}
}
return true;
}
return false;
}
/**
* Produce the differences between two maps, as a map, using a specified diff function.
*
* @param oldValue the old map.
* @param newValue the new map.
* @param elementDiff the diff function.
*
* @returns a map representing the differences between +oldValue+ and +newValue+.
*/
export function diffKeyedEntities<T>(
oldValue: { [key: string]: any } | undefined,
newValue: { [key: string]: any } | undefined,
elementDiff: (oldElement: any, newElement: any, key: string) => T): { [name: string]: T } {
const result: { [name: string]: T } = {};
for (const logicalId of unionOf(Object.keys(oldValue || {}), Object.keys(newValue || {}))) {
const oldElement = oldValue && oldValue[logicalId];
const newElement = newValue && newValue[logicalId];
if (oldElement === undefined && newElement === undefined) {
// Shouldn't happen in reality, but may happen in tests. Skip.
continue;
}
result[logicalId] = elementDiff(oldElement, newElement, logicalId);
}
return result;
}
/**
* Computes the union of two sets of strings.
*
* @param lv the left set of strings.
* @param rv the right set of strings.
*
* @returns a new array containing all elemebts from +lv+ and +rv+, with no duplicates.
*/
export function unionOf(lv: string[] | Set<string>, rv: string[] | Set<string>): string[] {
const result = new Set(lv);
for (const v of rv) {
result.add(v);
}
return new Array(...result);
}
/**
* GetStackTemplate flattens any codepoint greater than "\u7f" to "?". This is
* true even for codepoints in the supplemental planes which are represented
* in JS as surrogate pairs, all the way up to "\u{10ffff}".
*
* This function implements the same mangling in order to provide diagnostic
* information in `cdk diff`.
*/
export function mangleLikeCloudFormation(payload: string) {
return payload.replace(/[\u{80}-\u{10ffff}]/gu, '?');
}
/**
* A parseFloat implementation that does the right thing for
* strings like '0.0.0'
* (for which JavaScript's parseFloat() returns 0).
* We return NaN for all of these strings that do not represent numbers,
* and so comparing them fails,
* and doesn't short-circuit the diff logic.
*/
function safeParseFloat(str: string): number {
return Number(str);
}
/**
* Lazily load the service spec database and cache the loaded db
*/
let DATABASE: SpecDatabase | undefined;
function database(): SpecDatabase {
if (!DATABASE) {
DATABASE = loadAwsServiceSpecSync();
}
return DATABASE;
}
/**
* Load a Resource model from the Service Spec Database
*
* The database is loaded lazily and cached across multiple calls to `loadResourceModel`.
*/
export function loadResourceModel(type: string): Resource | undefined {
return database().lookup('resource', 'cloudFormationType', 'equals', type)[0];
}