Skip to content

Commit

Permalink
feat: preserveKeyOrder option (#32)
Browse files Browse the repository at this point in the history
* feat: sortKeys option

* chore: rename sortKeys to preserveKeyOrder
  • Loading branch information
P0lip authored Dec 4, 2019
1 parent 4684ca8 commit d7797f3
Show file tree
Hide file tree
Showing 3 changed files with 201 additions and 6 deletions.
122 changes: 122 additions & 0 deletions src/__tests__/parseWithPointers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,4 +514,126 @@ european-cities: &cities
expect(result.diagnostics).toEqual([]);
});
});

describe('keys order', () => {
it('does not retain the order of keys by default', () => {
const { data } = parseWithPointers(`foo: true
bar: false
"1": false
"0": true
`);

expect(Object.keys(data)).toEqual(['0', '1', 'foo', 'bar']);
});

describe('when preserveKeyOrder option is set to true', () => {
it('retains the initial order of keys', () => {
const { data } = parseWithPointers(
`foo: true
bar: false
"1": false
"0": true
`,
{ preserveKeyOrder: true },
);

expect(Object.keys(data)).toEqual(['foo', 'bar', '1', '0']);
});

it('handles duplicate properties', () => {
const { data } = parseWithPointers(
`{
foo: true,
bar: false,
"0": 0,
foo: null,
"1": false,
"0": true,
"1": 0,
}`,
{ preserveKeyOrder: true },
);

expect(Object.keys(data)).toEqual(['bar', 'foo', '0', '1']);
expect(data).toStrictEqual({
bar: false,
foo: null,
1: 0,
0: true,
});
});

it('does not touch sequences', () => {
const { data } = parseWithPointers(
`- 0
- 1
- 2`,
{ preserveKeyOrder: true },
);

expect(Object.keys(data)).toEqual(['0', '1', '2']);
expect(Object.getOwnPropertySymbols(data)).toEqual([]);
});

it('handles empty maps', () => {
const { data } = parseWithPointers(`{}`, { preserveKeyOrder: true });

expect(Object.keys(data)).toEqual([]);
});

it('works for nested maps', () => {
const { data } = parseWithPointers(
`foo:
"1": "test"
hello: 0,
"0": false`,
{ preserveKeyOrder: true },
);

expect(Object.keys(data.foo)).toEqual(['1', 'hello', '0']);
});
});

describe('merge keys handling', () => {
it('treats merge keys as regular mappings by default', () => {
const { data } = parseWithPointers(
`---
- &CENTER { x: 1, y: 2 }
- &LEFT { x: 0, y: 2 }
- &BIG { r: 10 }
- &SMALL { r: 1 }
-
<< : [ *BIG, *LEFT, *SMALL ]
x: 1
1: []
0: true
label: center/big`,
{ preserveKeyOrder: true },
);

expect(Object.keys(data[4])).toEqual(['<<', 'x', '1', '0', 'label']);
});

describe('when mergeKeys option is set to true', () => {
it('takes mappings included as a result of merging into account', () => {
const { data } = parseWithPointers(
`---
- &CENTER { x: 1, y: 4 }
- &LEFT { x: 0, z: null, 1000: false, y: 2}
- &BIG { r: 10, 100: true }
- &SMALL { r: 1, 9: true }
-
<< : [ *CENTER, *BIG, *LEFT, *SMALL ]
x: 1
1: []
0: true
label: center/big`,
{ preserveKeyOrder: true, mergeKeys: true },
);

expect(Object.keys(data[4])).toEqual(['y', 'r', '100', 'z', '1000', '9', 'x', '1', '0', 'label']);
});
});
});
});
});
84 changes: 78 additions & 6 deletions src/parseWithPointers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export const parseWithPointers = <T>(value: string, options?: IParseOptions): Ya
return parsed;
};

const KEYS = Symbol('object_keys');

export const walkAST = (
node: YAMLNode | null,
options?: IParseOptions,
Expand All @@ -74,7 +76,8 @@ export const walkAST = (
if (node) {
switch (node.kind) {
case Kind.MAP: {
const container = {};
const preserveKeyOrder = options !== void 0 && options.preserveKeyOrder === true;
const container = createMapContainer(preserveKeyOrder);
// note, we don't handle null aka '~' keys on purpose
const seenKeys: string[] = [];
const handleMergeKeys = options !== void 0 && options.mergeKeys === true;
Expand All @@ -99,12 +102,27 @@ export const walkAST = (

// https://yaml.org/type/merge.html merge keys, not a part of YAML spec
if (handleMergeKeys && key === SpecialMappingKeys.MergeKey) {
Object.assign(container, reduceMergeKeys(walkAST(mapping.value, options, duplicatedMappingKeys)));
const reduced = reduceMergeKeys(walkAST(mapping.value, options, duplicatedMappingKeys), preserveKeyOrder);
if (preserveKeyOrder && reduced !== null) {
for (const reducedKey of Object.keys(reduced)) {
pushKey(container, reducedKey);
}
}

Object.assign(container, reduced);
} else {
container[mapping.key.value] = walkAST(mapping.value, options, duplicatedMappingKeys);
container[key] = walkAST(mapping.value, options, duplicatedMappingKeys);

if (preserveKeyOrder) {
pushKey(container, key);
}
}
}

if (KEYS in container) {
(container as Partial<{ [KEYS]: Array<Symbol | string> }>)[KEYS]!.push(KEYS);
}

return container;
}
case Kind.SEQ:
Expand Down Expand Up @@ -260,11 +278,65 @@ const transformDuplicatedMappingKeys = (nodes: YAMLNode[], lineMap: number[]): I
return validations;
};

const reduceMergeKeys = (items: unknown): object | null => {
const reduceMergeKeys = (items: unknown, preserveKeyOrder: boolean): object | null => {
if (Array.isArray(items)) {
// reduceRight is on purpose here! We need to respect the order - the key cannot be overridden..
return items.reduceRight((merged, item) => Object.assign(merged, item), {});
// reduceRight is on purpose here! We need to respect the order - the key cannot be overridden
const reduced = items.reduceRight(
preserveKeyOrder
? (merged, item) => {
const keys = Object.keys(item);
for (let i = keys.length - 1; i >= 0; i--) {
unshiftKey(merged, keys[i]);
}

return Object.assign(merged, item);
}
: (merged, item) => Object.assign(merged, item),
createMapContainer(preserveKeyOrder),
);

if (preserveKeyOrder) {
reduced[KEYS].push(KEYS);
}

return reduced;
}

return typeof items !== 'object' || items === null ? null : Object(items);
};

const traps = {
ownKeys(target: object) {
return target[KEYS];
},
};

function createMapContainer(preserveKeyOrder: boolean): { [key in PropertyKey]: unknown } {
if (preserveKeyOrder) {
const container = new Proxy({}, traps);
Reflect.defineProperty(container, KEYS, {
value: [],
});

return container;
}

return {};
}

function deleteKey(container: object, key: string) {
const index = key in container ? container[KEYS].indexOf(key) : -1;
if (index !== -1) {
container[KEYS].splice(index, 1);
}
}

function unshiftKey(container: object, key: string) {
deleteKey(container, key);
container[KEYS].unshift(key);
}

function pushKey(container: object, key: string) {
deleteKey(container, key);
container[KEYS].push(key);
}
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Kind, ScalarType } from '@stoplight/yaml-ast-parser';
export interface IParseOptions extends YAMLAstParser.LoadOptions {
json?: boolean; // if true, properties can be overridden, otherwise throws
mergeKeys?: boolean;
preserveKeyOrder?: boolean;
}

export type YAMLBaseNode<K extends Kind> = Omit<YAMLAstParser.YAMLNode, 'kind' | 'parent'> & {
Expand Down

0 comments on commit d7797f3

Please sign in to comment.