Implement a function to merge query results (#122)

Summary:
Once we execute the root query, find continuations, embed the
continuations into queries, and execute the continuation query, we will
need to merge the continuations’ results back into the root results.
This commit adds a function `merge` that will be suitable for doing just
that.

Test Plan:
New unit tests added, with 100% coverage. Run `yarn test`.

wchargin-branch: merge-query-results
This commit is contained in:
William Chargin 2018-04-05 02:27:03 -07:00 committed by GitHub
parent 751172ea77
commit e82b56e52c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 365 additions and 0 deletions

View File

@ -340,6 +340,118 @@ function* continuationsFromReview(
}
}
/**
* Merge structured data from the given `source` into a given subpath of
* the `destination`. The original inputs are not modified.
*
* Arrays are merged by concatenation. Objects are merged by recursively
* merging each key. Primitives are merged by replacement (the
* destination is simply overwritten with the source).
*
* See test cases for examples.
*
* NOTE: The type of `source` should be the same as the type of
*
* destination[path[0]][path[1]]...[path[path.length - 1]],
*
* but this constraint cannot be expressed in Flow so we just use `any`.
*/
export function merge<T>(
destination: T,
source: any,
path: $ReadOnlyArray<string | number>
): T {
if (path.length === 0) {
return mergeDirect(destination, source);
}
function isObject(x) {
return !Array.isArray(x) && typeof x === "object" && x != null;
}
function checkKey(key: string | number, destination: Object | Array<any>) {
if (!(key in destination)) {
const keyText = JSON.stringify(key);
const destinationText = JSON.stringify(destination);
throw new Error(
`Key ${keyText} not found in destination: ${destinationText}`
);
}
}
const key = path[0];
if (typeof key === "number") {
if (!Array.isArray(destination)) {
throw new Error(
"Found index key for non-array destination: " +
JSON.stringify(destination)
);
}
checkKey(key, destination);
const newValue = merge(destination[key], source, path.slice(1));
const result = destination.slice();
result.splice(key, 1, newValue);
return result;
} else if (typeof key === "string") {
if (!isObject(destination)) {
throw new Error(
"Found string key for non-object destination: " +
JSON.stringify(destination)
);
}
const destinationObject: Object = (destination: any);
checkKey(key, destinationObject);
const newValue = merge(destinationObject[key], source, path.slice(1));
return {
...destination,
[key]: newValue,
};
} else {
throw new Error(`Unexpected key: ${JSON.stringify(key)}`);
}
}
// Merge, without the path traversal.
function mergeDirect<T>(destination: T, source: any): T {
function isObject(x) {
return !Array.isArray(x) && typeof x === "object" && x != null;
}
if (Array.isArray(source)) {
if (!Array.isArray(destination)) {
const destinationText = JSON.stringify(destination);
const sourceText = JSON.stringify(source);
throw new Error(
"Tried to merge array into non-array: " +
`(source: ${sourceText}; destination: ${destinationText})`
);
}
return destination.concat(source);
} else if (isObject(source)) {
if (!isObject(destination)) {
const destinationText = JSON.stringify(destination);
const sourceText = JSON.stringify(source);
throw new Error(
"Tried to merge object into non-object: " +
`(source: ${sourceText}; destination: ${destinationText})`
);
}
const result = {...destination};
Object.keys(source).forEach((k) => {
result[k] = mergeDirect(result[k], source[k]);
});
return result;
} else {
if (Array.isArray(destination) || isObject(destination)) {
const destinationText = JSON.stringify(destination);
const sourceText = JSON.stringify(source);
throw new Error(
"Tried to merge primitive into non-primitive: " +
`(source: ${sourceText}; destination: ${destinationText})`
);
}
return source;
}
}
/**
* These fragments are used to construct the root query, and also to
* fetch more pages of specific entity types.

View File

@ -6,6 +6,7 @@ import {
PAGE_LIMIT,
continuationsFromQuery,
continuationsFromContinuation,
merge,
} from "./graphql";
describe("graphql", () => {
@ -378,4 +379,256 @@ describe("graphql", () => {
});
});
});
describe("#merge", () => {
describe("merges at the root", () => {
it("replacing primitive numbers", () => {
expect(merge(3, 5, [])).toEqual(5);
});
it("replacing primitive strings", () => {
expect(merge("three", "five", [])).toEqual("five");
});
it("replacing a primitive string with null", () => {
expect(merge("three", null, [])).toEqual(null);
});
it("replacing null with a number", () => {
expect(merge(null, 3, [])).toEqual(3);
});
it("concatenating arrays", () => {
expect(merge([1, 2], [3, 4], [])).toEqual([1, 2, 3, 4]);
});
it("merging objects", () => {
const destination = {a: 1, b: 2};
const source = {c: 3, d: 4};
const expected = {a: 1, b: 2, c: 3, d: 4};
expect(merge(destination, source, [])).toEqual(expected);
});
it("overwriting primitives in an object", () => {
const destination = {hasNextPage: true, endCursor: "cursor-aaa"};
const source = {hasNextPage: false, endCursor: "cursor-bbb"};
expect(merge(destination, source, [])).toEqual(source);
});
it("merging complex structures recursively", () => {
const destination = {
fst: {a: 1, b: 2},
snd: {e: 5, f: 6},
fruits: ["apple", "banana"],
letters: ["whiskey", "x-ray"],
};
const source = {
fst: {c: 3, d: 4},
snd: {g: 7, h: 8},
fruits: ["cherry", "durian"],
letters: ["yankee", "zulu"],
};
const expected = {
fst: {a: 1, b: 2, c: 3, d: 4},
snd: {e: 5, f: 6, g: 7, h: 8},
fruits: ["apple", "banana", "cherry", "durian"],
letters: ["whiskey", "x-ray", "yankee", "zulu"],
};
expect(merge(destination, source, [])).toEqual(expected);
});
});
describe("traverses", () => {
it("down an object path", () => {
const destination = {
child: {
grandchild: {
one: 1,
two: 2,
},
otherGrandchild: "world",
},
otherChild: "hello",
};
const source = {
three: 3,
four: 4,
};
const expected = {
child: {
grandchild: {
one: 1,
two: 2,
three: 3,
four: 4,
},
otherGrandchild: "world",
},
otherChild: "hello",
};
expect(merge(destination, source, ["child", "grandchild"])).toEqual(
expected
);
});
it("down an array path", () => {
const destination = [["change me", [1, 2]], ["ignore me", [5, 6]]];
const source = [3, 4];
const expected = [["change me", [1, 2, 3, 4]], ["ignore me", [5, 6]]];
expect(merge(destination, source, [0, 1])).toEqual(expected);
});
it("down a path of mixed objects and arrays", () => {
const destination = {
families: [
{
childCount: 3,
children: [
{name: "Alice", hobbies: ["acupuncture"]},
{name: "Bob", hobbies: ["billiards"]},
{name: "Cheryl", hobbies: ["chess"]},
],
},
{
childCount: 0,
children: [],
},
],
};
const path = ["families", 0, "children", 2, "hobbies"];
const source = ["charades", "cheese-rolling"];
const expected = {
families: [
{
childCount: 3,
children: [
{name: "Alice", hobbies: ["acupuncture"]},
{name: "Bob", hobbies: ["billiards"]},
{
name: "Cheryl",
hobbies: ["chess", "charades", "cheese-rolling"],
},
],
},
{childCount: 0, children: []},
],
};
expect(merge(destination, source, path)).toEqual(expected);
});
});
describe("doesn't mutate its inputs", () => {
it("when merging arrays", () => {
const destination = [1, 2];
const source = [3, 4];
merge(destination, source, []);
expect(destination).toEqual([1, 2]);
expect(source).toEqual([3, 4]);
});
it("when merging objects", () => {
const destination = {a: 1, b: 2};
const source = {c: 3, d: 4};
merge(destination, source, []);
expect(destination).toEqual({a: 1, b: 2});
expect(source).toEqual({c: 3, d: 4});
});
test("along an object path", () => {
const makeDestination = () => ({
child: {
grandchild: {
one: 1,
two: 2,
},
otherGrandchild: "world",
},
otherChild: "hello",
});
const makeSource = () => ({
three: 3,
four: 4,
});
const destination = makeDestination();
const source = makeSource();
merge(destination, source, ["child", "grandchild"]);
expect(destination).toEqual(makeDestination());
expect(source).toEqual(makeSource());
});
test("along an array path", () => {
const makeDestination = () => [
["change me", [1, 2]],
["ignore me", [5, 6]],
];
const makeSource = () => [3, 4];
const destination = makeDestination();
const source = makeSource();
merge(destination, source, [0, 1]);
expect(destination).toEqual(makeDestination());
expect(source).toEqual(makeSource());
});
});
describe("complains", () => {
describe("about bad keys", () => {
it("when given a numeric key into a primitive", () => {
expect(() => merge(123, 234, [0])).toThrow(/non-array/);
});
it("when given a numeric key into null", () => {
expect(() => merge(null, null, [0])).toThrow(/non-array/);
});
describe("when given a numeric key into an object", () => {
test("for the usual case of an object with string keys", () => {
expect(() => merge({a: 1}, {b: 2}, [0])).toThrow(/non-array/);
});
test("even when the object has the stringifed version of the key", () => {
expect(() =>
merge({"0": "zero", "1": "one"}, {"2": "two"}, [0])
).toThrow(/non-array/);
});
});
it("when given a string key into a primitive", () => {
expect(() => merge(123, 234, ["k"])).toThrow(/non-object/);
});
it("when given a string key into null", () => {
expect(() => merge(null, null, ["k"])).toThrow(/non-object/);
});
it("when given a string key into an array", () => {
expect(() => merge([1, 2], [1, 2], ["k"])).toThrow(/non-object/);
});
it("when given a non-string, non-numeric key", () => {
const badKey: any = false;
expect(() => merge({a: 1}, {b: 2}, [badKey])).toThrow(/key.*false/);
});
it("when given a non-existent string key", () => {
expect(() => merge({a: 1}, {b: 2}, ["c"])).toThrow(/"c" not found/);
});
it("when given a non-existent numeric key", () => {
expect(() => merge([1], [2], [3])).toThrow(/3 not found/);
});
});
describe("about source/destination mismatch", () => {
it("when merging an array into a non-array", () => {
const re = () => /array into non-array/;
expect(() => merge({a: 1}, [2], [])).toThrow(re());
expect(() => merge(true, [2], [])).toThrow(re());
});
it("when merging an object into a non-object", () => {
const re = () => /object into non-object/;
expect(() => merge([1], {b: 2}, [])).toThrow(re());
expect(() => merge(true, {b: 2}, [])).toThrow(re());
});
it("when merging a primitive into a non-primitive", () => {
const re = () => /primitive into non-primitive/;
expect(() => merge([], true, [])).toThrow(re());
expect(() => merge({a: 1}, true, [])).toThrow(re());
});
});
});
});
});