From d7797f39a9adcd21756453bdbe4f9226ca99e01c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Wed, 4 Dec 2019 11:54:17 +0100 Subject: [PATCH] feat: preserveKeyOrder option (#32) * feat: sortKeys option * chore: rename sortKeys to preserveKeyOrder --- src/__tests__/parseWithPointers.spec.ts | 122 ++++++++++++++++++++++++ src/parseWithPointers.ts | 84 ++++++++++++++-- src/types.ts | 1 + 3 files changed, 201 insertions(+), 6 deletions(-) diff --git a/src/__tests__/parseWithPointers.spec.ts b/src/__tests__/parseWithPointers.spec.ts index 42f9085..55e87a5 100644 --- a/src/__tests__/parseWithPointers.spec.ts +++ b/src/__tests__/parseWithPointers.spec.ts @@ -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']); + }); + }); + }); + }); }); diff --git a/src/parseWithPointers.ts b/src/parseWithPointers.ts index 96e8b43..f1196d6 100644 --- a/src/parseWithPointers.ts +++ b/src/parseWithPointers.ts @@ -66,6 +66,8 @@ export const parseWithPointers = (value: string, options?: IParseOptions): Ya return parsed; }; +const KEYS = Symbol('object_keys'); + export const walkAST = ( node: YAMLNode | null, options?: IParseOptions, @@ -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; @@ -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 }>)[KEYS]!.push(KEYS); + } + return container; } case Kind.SEQ: @@ -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); +} diff --git a/src/types.ts b/src/types.ts index 0730483..5c4eec2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 = Omit & {