Commit a1e10d76 authored by Jacques Erasmus's avatar Jacques Erasmus

Merge branch 'add-yaml-util' into 'master'

Add the `merge` utility function to extend the yaml package

See merge request gitlab-org/gitlab!79955
parents 7bc693ea 0b9a545f
/**
* This file adds a merge function to be used with a yaml Document as defined by
* the yaml@2.x package: https://eemeli.org/yaml/#yaml
*
* Ultimately, this functionality should be merged upstream into the package,
* track the progress of that effort at https://github.com/eemeli/yaml/pull/347
* */
import { visit, Scalar, isCollection, isDocument, isScalar, isNode, isMap, isSeq } from 'yaml';
function getPath(ancestry) {
return ancestry.reduce((p, { key }) => {
return key !== undefined ? [...p, key.value] : p;
}, []);
}
function getFirstChildNode(collection) {
let firstChildKey;
let type;
switch (collection.constructor.name) {
case 'YAMLSeq': // eslint-disable-line @gitlab/require-i18n-strings
return collection.items.find((i) => isNode(i));
case 'YAMLMap': // eslint-disable-line @gitlab/require-i18n-strings
firstChildKey = collection.items[0]?.key;
if (!firstChildKey) return undefined;
return isScalar(firstChildKey) ? firstChildKey : new Scalar(firstChildKey);
default:
type = collection.constructor?.name || typeof collection;
throw Error(`Cannot identify a child Node for type ${type}`);
}
}
function moveMetaPropsToFirstChildNode(collection) {
const firstChildNode = getFirstChildNode(collection);
const { comment, commentBefore, spaceBefore } = collection;
if (!(comment || commentBefore || spaceBefore)) return;
if (!firstChildNode)
throw new Error('Cannot move meta properties to a child of an empty Collection'); // eslint-disable-line @gitlab/require-i18n-strings
Object.assign(firstChildNode, { comment, commentBefore, spaceBefore });
Object.assign(collection, {
comment: undefined,
commentBefore: undefined,
spaceBefore: undefined,
});
}
function assert(isTypeFn, node, path) {
if (![isSeq, isMap].includes(isTypeFn)) {
throw new Error('assert() can only be used with isSeq() and isMap()');
}
const expectedTypeName = isTypeFn === isSeq ? 'YAMLSeq' : 'YAMLMap'; // eslint-disable-line @gitlab/require-i18n-strings
if (!isTypeFn(node)) {
const type = node?.constructor?.name || typeof node;
throw new Error(
`Type conflict at "${path.join(
'.',
)}": Destination node is of type ${type}, the node to be merged is of type ${expectedTypeName}.`,
);
}
}
function mergeCollection(target, node, path) {
// In case both the source and the target node have comments or spaces
// We'll move them to their first child so they do not conflict
moveMetaPropsToFirstChildNode(node);
if (target.hasIn(path)) {
const targetNode = target.getIn(path, true);
assert(isSeq(node) ? isSeq : isMap, targetNode, path);
moveMetaPropsToFirstChildNode(targetNode);
}
}
function mergePair(target, node, path) {
if (!isScalar(node.value)) return undefined;
if (target.hasIn([...path, node.key.value])) {
target.setIn(path, node);
} else {
target.addIn(path, node);
}
return visit.SKIP;
}
function getVisitorFn(target, options) {
return {
Map: (_, node, ancestors) => {
mergeCollection(target, node, getPath(ancestors));
},
Pair: (_, node, ancestors) => {
mergePair(target, node, getPath(ancestors));
},
Seq: (_, node, ancestors) => {
const path = getPath(ancestors);
mergeCollection(target, node, path);
if (options.onSequence === 'replace') {
target.setIn(path, node);
return visit.SKIP;
}
node.items.forEach((item) => target.addIn(path, item));
return visit.SKIP;
},
};
}
/** Merge another collection into this */
export function merge(target, source, options = {}) {
const opt = {
onSequence: 'replace',
...options,
};
const sourceNode = target.createNode(isDocument(source) ? source.contents : source);
if (!isCollection(sourceNode)) {
const type = source?.constructor?.name || typeof source;
throw new Error(`Cannot merge type "${type}", expected a Collection`);
}
if (!isCollection(target.contents)) {
// If the target doc is empty add the source to it directly
Object.assign(target, { contents: sourceNode });
return;
}
visit(sourceNode, getVisitorFn(target, opt));
}
import { Document, parseDocument } from 'yaml';
import { merge } from '~/lib/utils/yaml';
// Mock data for Comments on pairs
const COMMENTS_ON_PAIRS_SOURCE = `foo:
# barbaz
bar: baz
# bazboo
baz: boo
`;
const COMMENTS_ON_PAIRS_TARGET = `foo:
# abcdef
abc: def
# boobaz
boo: baz
`;
const COMMENTS_ON_PAIRS_EXPECTED = `foo:
# abcdef
abc: def
# boobaz
boo: baz
# barbaz
bar: baz
# bazboo
baz: boo
`;
// Mock data for Comments on seqs
const COMMENTS_ON_SEQS_SOURCE = `foo:
# barbaz
- barbaz
# bazboo
- baz: boo
`;
const COMMENTS_ON_SEQS_TARGET = `foo:
# abcdef
- abcdef
# boobaz
- boobaz
`;
const COMMENTS_ON_SEQS_EXPECTED = `foo:
# abcdef
- abcdef
# boobaz
- boobaz
# barbaz
- barbaz
# bazboo
- baz: boo
`;
describe('Yaml utility functions', () => {
describe('merge', () => {
const getAsNode = (yamlStr) => {
return parseDocument(yamlStr).contents;
};
describe('Merge two Nodes', () => {
it.each`
scenario | source | target | options | expected
${'merge a map'} | ${getAsNode('foo:\n bar: baz\n')} | ${'foo:\n abc: def\n'} | ${undefined} | ${'foo:\n abc: def\n bar: baz\n'}
${'merge a seq'} | ${getAsNode('foo:\n - bar\n')} | ${'foo:\n - abc\n'} | ${undefined} | ${'foo:\n - bar\n'}
${'merge-append seqs'} | ${getAsNode('foo:\n - bar\n')} | ${'foo:\n - abc\n'} | ${{ onSequence: 'append' }} | ${'foo:\n - abc\n - bar\n'}
${'merge-replace a seq'} | ${getAsNode('foo:\n - bar\n')} | ${'foo:\n - abc\n'} | ${{ onSequence: 'replace' }} | ${'foo:\n - bar\n'}
${'override existing paths'} | ${getAsNode('foo:\n bar: baz\n')} | ${'foo:\n bar: boo\n'} | ${undefined} | ${'foo:\n bar: baz\n'}
${'deep maps'} | ${getAsNode('foo:\n bar:\n abc: def\n')} | ${'foo:\n bar:\n baz: boo\n jkl: mno\n'} | ${undefined} | ${'foo:\n bar:\n baz: boo\n abc: def\n jkl: mno\n'}
${'append maps inside seqs'} | ${getAsNode('foo:\n - abc: def\n')} | ${'foo:\n - bar: baz\n'} | ${{ onSequence: 'append' }} | ${'foo:\n - bar: baz\n - abc: def\n'}
${'inexistent paths create new nodes'} | ${getAsNode('foo:\n bar: baz\n')} | ${'abc: def\n'} | ${undefined} | ${'abc: def\nfoo:\n bar: baz\n'}
${'document as source'} | ${parseDocument('foo:\n bar: baz\n')} | ${'foo:\n abc: def\n'} | ${undefined} | ${'foo:\n abc: def\n bar: baz\n'}
${'object as source'} | ${{ foo: { bar: 'baz' } }} | ${'foo:\n abc: def\n'} | ${undefined} | ${'foo:\n abc: def\n bar: baz\n'}
${'comments on pairs'} | ${parseDocument(COMMENTS_ON_PAIRS_SOURCE)} | ${COMMENTS_ON_PAIRS_TARGET} | ${undefined} | ${COMMENTS_ON_PAIRS_EXPECTED}
${'comments on seqs'} | ${parseDocument(COMMENTS_ON_SEQS_SOURCE)} | ${COMMENTS_ON_SEQS_TARGET} | ${{ onSequence: 'append' }} | ${COMMENTS_ON_SEQS_EXPECTED}
`('$scenario', ({ source, target, expected, options }) => {
const targetDoc = parseDocument(target);
merge(targetDoc, source, options);
const expectedDoc = parseDocument(expected);
expect(targetDoc.toString()).toEqual(expectedDoc.toString());
});
it('type conflict will throw an Error', () => {
const sourceDoc = parseDocument('foo:\n bar:\n - baz\n');
const targetDoc = parseDocument('foo:\n bar: def\n');
expect(() => merge(targetDoc, sourceDoc)).toThrow(
'Type conflict at "foo.bar": Destination node is of type Scalar, the node' +
' to be merged is of type YAMLSeq',
);
});
it('merging a collection into an empty doc', () => {
const targetDoc = new Document();
merge(targetDoc, { foo: { bar: 'baz' } });
const expected = parseDocument('foo:\n bar: baz\n');
expect(targetDoc.toString()).toEqual(expected.toString());
});
});
});
});
......@@ -12586,10 +12586,10 @@ yaml@^1.10.0:
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
yaml@^2.0.0-8:
version "2.0.0-8"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.0.0-8.tgz#226365f0d804ba7fb8cc2b527a00a7a4a3d8ea5f"
integrity sha512-QaYgJZMfWD6fKN/EYMk6w1oLWPCr1xj9QaPSZW5qkDb3y8nGCXhy2Ono+AF4F+CSL/vGcqswcAT0BaS//pgD2A==
yaml@^2.0.0-10:
version "2.0.0-10"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.0.0-10.tgz#d5b59e2d14b8683313a534f2bbc648e211a2753e"
integrity sha512-FHV8s5ODFFQXX/enJEU2EkanNl1UDBUz8oa4k5Qo/sR+Iq7VmhCDkRMb0/mjJCNeAWQ31W8WV6PYStDE4d9EIw==
yargs-parser@^13.1.2:
version "13.1.2"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment