From 272af9db389b6620099ebd432b870aba0479b666 Mon Sep 17 00:00:00 2001 From: William Chargin Date: Sat, 30 May 2020 15:58:05 -0700 Subject: [PATCH] combo: allow field renaming (#1819) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: This patch expands the API of `Combo.object` such that fields in the input JSON may be renamed in the output JSON. This often occurs in combination with `fmap`, as we convert a simple string field with a user-facing name to a different structured representation. For example: ```javascript C.object({ repoIds: C.rename( "repositories", C.fmap(C.array(C.string), repoIdToString) ), }); ``` This is backward-compatible and invisible when not needed: the fields of the argument to `C.object` may now be either parsers (as before) or results of `C.rename`. This patch also adds a check that the required and optional key sets don’t overlap, which could technically have happened before but is more important now that renames are possible. Test Plan: Unit tests included, retaining full coverage. wchargin-branch: combo-rename-fields --- src/util/combo.js | 85 ++++++++++++++++++++++++++++++------------ src/util/combo.test.js | 73 ++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 24 deletions(-) diff --git a/src/util/combo.js b/src/util/combo.js index 92b468d..2c5fcbe 100644 --- a/src/util/combo.js +++ b/src/util/combo.js @@ -41,7 +41,6 @@ export class Parser<+T> { // Helper type to extract the underlying type of a parser: for instance, // `ParserOutput>` is just `string`. export type ParserOutput> = $PropertyType; -type ExtractParserOutput = >(P) => ParserOutput

; // Helper to make a successful parse result. For readability. function success(t: T): ParseResult { @@ -167,13 +166,36 @@ export function array(p: Parser): Parser { }); } -type Fields = {+[string]: Parser}; +// Fields for an object type. Each is either a bare parser or the result +// of `rename("oldFieldName", p)` for a parser `p`, to be used when the +// field name in the output type is to be different from the field name +// in the input type. +export type Field<+T> = Parser | RenameField; +export opaque type RenameField<+T>: {+_phantomT: T} = RenameFieldImpl; +export type Fields = {+[string]: Field}; + +// Like `ExtractParserOutput`, but works on `Field`s even when the +// bound ascription is checked outside of this module. +type FieldOutput> = $PropertyType; +type ExtractFieldOutput = >(F) => FieldOutput; + +export function rename(oldKey: string, parser: Parser): RenameField { + return new RenameFieldImpl(oldKey, parser); +} + +class RenameFieldImpl<+T> extends Parser { + +oldKey: string; + constructor(oldKey: string, parser: Parser) { + super(parser._f); + this.oldKey = oldKey; + } +} // Parser combinator for an object type all of whose fields are // required. type PObjectAllRequired = ( required: FReq -) => Parser<$ObjMap>; +) => Parser<$ObjMap>; // Parser combinator for an object type with some required fields (maybe // none) and some optional ones. @@ -182,8 +204,8 @@ type PObjectWithOptionals = ( optional: FOpt ) => Parser< $Exact<{ - ...$Exact<$ObjMap>, - ...$Rest<$Exact<$ObjMap>, {}>, + ...$Exact<$ObjMap>, + ...$Rest<$Exact<$ObjMap>, {}>, }> >; @@ -202,32 +224,47 @@ export const object: PObject = (function object( requiredFields, optionalFields? ) { + const newKeysSeen = new Set(); + const fields: Array<{| + +oldKey: string, + +newKey: string, + +required: boolean, + +parser: Parser, + |}> = []; + const fieldsets = [ + {inputFields: requiredFields, required: true}, + {inputFields: optionalFields || {}, required: false}, + ]; + for (const {inputFields, required} of fieldsets) { + for (const newKey of Object.keys(inputFields)) { + const parser = inputFields[newKey]; + if (newKeysSeen.has(newKey)) { + throw new Error("duplicate key: " + JSON.stringify(newKey)); + } + newKeysSeen.add(newKey); + const oldKey = parser instanceof RenameFieldImpl ? parser.oldKey : newKey; + fields.push({oldKey, newKey, parser, required}); + } + } return new Parser((x) => { if (typeof x !== "object" || Array.isArray(x) || x == null) { return failure("expected object, got " + typename(x)); } const result = {}; - const fieldsets = [ - {fields: requiredFields, required: true}, - {fields: optionalFields || {}, required: false}, - ]; - for (const {fields, required} of fieldsets) { - for (const key of Object.keys(fields)) { - const raw = x[key]; - if (raw === undefined) { - if (required) { - return failure("missing key: " + JSON.stringify(key)); - } else { - continue; - } + for (const {oldKey, newKey, parser, required} of fields) { + const raw = x[oldKey]; + if (raw === undefined) { + if (required) { + return failure("missing key: " + JSON.stringify(oldKey)); + } else { + continue; } - const parser = fields[key]; - const parsed = parser.parse(raw); - if (!parsed.ok) { - return failure(`key ${JSON.stringify(key)}: ${parsed.err}`); - } - result[key] = parsed.value; } + const parsed = parser.parse(raw); + if (!parsed.ok) { + return failure(`key ${JSON.stringify(oldKey)}: ${parsed.err}`); + } + result[newKey] = parsed.value; } return success(result); }); diff --git a/src/util/combo.test.js b/src/util/combo.test.js index dd29d34..83870d0 100644 --- a/src/util/combo.test.js +++ b/src/util/combo.test.js @@ -297,5 +297,78 @@ describe("src/util/combo", () => { expect(p.parseOrThrow(v)).toEqual(v); }); }); + describe("with field renaming", () => { + const p: C.Parser<{| + +one: number, + +two: number, + +three?: number, + +four?: number, + |}> = C.object( + {one: C.number, two: C.rename("dos", C.number)}, + {three: C.number, four: C.rename("cuatro", C.number)} + ); + it("renames both required and optional fields", () => { + expect(p.parseOrThrow({one: 1, dos: 2, three: 3, cuatro: 4})).toEqual({ + one: 1, + two: 2, + three: 3, + four: 4, + }); + }); + it("provides missing key errors using the user-facing name", () => { + const thunk = () => p.parseOrThrow({one: 1, cuatro: 4}); + expect(thunk).toThrow('missing key: "dos"'); + }); + it("only accepts the user-facing keys", () => { + const thunk = () => p.parseOrThrow({one: 1, two: 2}); + expect(thunk).toThrow('missing key: "dos"'); + }); + it("only accepts the user-facing keys for optionals", () => { + expect(p.parseOrThrow({one: 1, dos: 2, three: 3, four: 4})).toEqual({ + one: 1, + two: 2, + three: 3, + }); + }); + it("allows mapping one old key to multiple new keys", () => { + // This makes it a bit harder to see how to turn `object` into + // an iso, but it's the intended behavior for now, so let's test + // it. + const p: C.Parser<{| + +value: number, + +valueAsString: string, + |}> = C.object({ + value: C.number, + valueAsString: C.rename( + "value", + C.fmap(C.number, (n) => n.toFixed()) + ), + }); + expect(p.parseOrThrow({value: 7})).toEqual({ + value: 7, + valueAsString: "7", + }); + }); + }); + it("fails when `required` and `optional` have overlapping new keys", () => { + expect(() => { + // ...even if the parser types are compatible and the old keys + // are different + C.object({hmm: C.string}, {hmm: C.rename("hum", C.string)}); + }).toThrow('duplicate key: "hmm"'); + }); + it("doesn't type a rename as a parser", () => { + // In the (current) implementation, a `C.rename(...)` actually is + // a parser, for weird typing reasons. This test ensures that that + // implementation detail doesn't leak past the opaque type + // boundary. + const rename = C.rename("old", C.string); + // $ExpectFlowError + (rename: C.Parser); + }); + it("forbids renaming a rename at the type level", () => { + // $ExpectFlowError + C.rename("hmm", C.rename("old", C.string)); + }); }); });