mirror: add support for connections of union types (#882)
Summary: Almost every GitHub connection has nodes of an object type, like `User` or `IssueComment`. But a few have nodes of union type, including `IssueTimelineItemConnection` (which we will likely want to query), and those require special handling. This commit adds susupport for such connections. Analysis to determine which connections have non-object elements: <https://gist.github.com/wchargin/647fa7ed8d6d17ae2e204bd098104407> Test Plan: Unit tests modified appropriately, retaining full coverage. The easiest way to verify the snapshot is probably to copy the raw contents (everything inside the quotes) into `/tmp/snapshot`, then run: ```shell $ sed -e 's/\\//g' </tmp/snapshot >/tmp/query # Jest adds backslashes $ jq -csR '{query: ., variables: {}}' </tmp/query >/tmp/payload $ ENDPOINT='https://api.github.com/graphql' $ AUTH="Authorization: bearer ${SOURCECRED_GITHUB_TOKEN}" $ curl "$ENDPOINT" -X POST -H "$AUTH" -d @- </tmp/payload >/tmp/result ``` and then execute the JQ program mentioned in the comment in the test case, and verify that it prints `true`. wchargin-branch: connection-of-union
This commit is contained in:
parent
23ae7e2f08
commit
838092194b
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
exports[`graphql/mirror Mirror _queryConnection snapshot test for actual GitHub queries 1`] = `
|
exports[`graphql/mirror Mirror _queryConnection snapshot test for actual GitHub queries 1`] = `
|
||||||
"query TestQuery {
|
"query TestQuery {
|
||||||
initialQuery: node(id: \\"MDEwOlJlcG9zaXRvcnkxMjMyNTUwMDY=\\") {
|
objectInitial: node(id: \\"MDEwOlJlcG9zaXRvcnkxMjMyNTUwMDY=\\") {
|
||||||
... on Repository {
|
... on Repository {
|
||||||
issues(first: 2) {
|
issues(first: 2) {
|
||||||
totalCount
|
totalCount
|
||||||
|
@ -17,7 +17,7 @@ exports[`graphql/mirror Mirror _queryConnection snapshot test for actual GitHub
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateQuery: node(id: \\"MDEwOlJlcG9zaXRvcnkxMjMyNTUwMDY=\\") {
|
objectUpdate: node(id: \\"MDEwOlJlcG9zaXRvcnkxMjMyNTUwMDY=\\") {
|
||||||
... on Repository {
|
... on Repository {
|
||||||
issues(first: 2 after: \\"Y3Vyc29yOnYyOpHOEe_nRA==\\") {
|
issues(first: 2 after: \\"Y3Vyc29yOnYyOpHOEe_nRA==\\") {
|
||||||
totalCount
|
totalCount
|
||||||
|
@ -32,7 +32,7 @@ exports[`graphql/mirror Mirror _queryConnection snapshot test for actual GitHub
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expectedIds: node(id: \\"MDEwOlJlcG9zaXRvcnkxMjMyNTUwMDY=\\") {
|
objectExpectedIds: node(id: \\"MDEwOlJlcG9zaXRvcnkxMjMyNTUwMDY=\\") {
|
||||||
... on Repository {
|
... on Repository {
|
||||||
issues(first: 4) {
|
issues(first: 4) {
|
||||||
nodes {
|
nodes {
|
||||||
|
@ -41,5 +41,194 @@ exports[`graphql/mirror Mirror _queryConnection snapshot test for actual GitHub
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
unionInitial: node(id: \\"MDU6SXNzdWUzMDA5MzQ4MTg=\\") {
|
||||||
|
... on Issue {
|
||||||
|
timeline(first: 1) {
|
||||||
|
totalCount
|
||||||
|
pageInfo {
|
||||||
|
endCursor
|
||||||
|
hasNextPage
|
||||||
|
}
|
||||||
|
nodes {
|
||||||
|
__typename
|
||||||
|
... on Commit {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on IssueComment {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on CrossReferencedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on ClosedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on ReopenedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on SubscribedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on UnsubscribedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on ReferencedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on AssignedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on UnassignedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on LabeledEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on UnlabeledEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on MilestonedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on DemilestonedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on RenamedTitleEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on LockedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on UnlockedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unionUpdate: node(id: \\"MDU6SXNzdWUzMDA5MzQ4MTg=\\") {
|
||||||
|
... on Issue {
|
||||||
|
timeline(first: 1 after: \\"MQ==\\") {
|
||||||
|
totalCount
|
||||||
|
pageInfo {
|
||||||
|
endCursor
|
||||||
|
hasNextPage
|
||||||
|
}
|
||||||
|
nodes {
|
||||||
|
__typename
|
||||||
|
... on Commit {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on IssueComment {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on CrossReferencedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on ClosedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on ReopenedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on SubscribedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on UnsubscribedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on ReferencedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on AssignedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on UnassignedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on LabeledEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on UnlabeledEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on MilestonedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on DemilestonedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on RenamedTitleEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on LockedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on UnlockedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unionExpectedIds: node(id: \\"MDU6SXNzdWUzMDA5MzQ4MTg=\\") {
|
||||||
|
... on Issue {
|
||||||
|
timeline(first: 2) {
|
||||||
|
nodes {
|
||||||
|
... on Commit {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on IssueComment {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on CrossReferencedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on ClosedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on ReopenedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on SubscribedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on UnsubscribedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on ReferencedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on AssignedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on UnassignedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on LabeledEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on UnlabeledEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on MilestonedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on DemilestonedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on RenamedTitleEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on LockedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
... on UnlockedEvent {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}"
|
}"
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -416,15 +416,15 @@ export class Mirror {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a GraphQL selection set required to identify the typename
|
* Create a GraphQL selection set required to identify the typename
|
||||||
* and ID for an object. This is the minimal information required to
|
* and ID for an object of the given declared type, which may be
|
||||||
* register an object in our database, so we query this information
|
* either an object type or a union type. This is the minimal
|
||||||
* whenever we find a reference to an object that we want to traverse
|
* whenever we find a reference to an object that we want to traverse
|
||||||
* later.
|
* later.
|
||||||
*
|
*
|
||||||
* The resulting GraphQL should be embedded in any node context. For
|
* The resulting GraphQL should be embedded in the context of any node
|
||||||
* instance, it might replace the `?` in any of the following queries:
|
* of the provided type. For instance, `_queryShallow("Issue")`
|
||||||
*
|
* returns a selection set that might replace the `?` in any of the
|
||||||
* repository(owner: "foo", name: "bar") { ? }
|
* following queries:
|
||||||
*
|
*
|
||||||
* repository(owner: "foo", name: "bar") {
|
* repository(owner: "foo", name: "bar") {
|
||||||
* issues(first: 1) {
|
* issues(first: 1) {
|
||||||
|
@ -432,13 +432,34 @@ export class Mirror {
|
||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* nodes(ids: ["baz", "quux"]) { ? }
|
* nodes(ids: ["issue#1", "issue#2"]) { ? }
|
||||||
*
|
*
|
||||||
* The result of this query has type `NodeFieldResult`.
|
* The result of this query has type `NodeFieldResult`.
|
||||||
|
*
|
||||||
|
* This function is pure: it does not interact with the database.
|
||||||
*/
|
*/
|
||||||
_queryShallow(): Queries.Selection[] {
|
_queryShallow(typename: Schema.Typename): Queries.Selection[] {
|
||||||
|
const type = this._schema[typename];
|
||||||
|
if (type == null) {
|
||||||
|
// Should not be reachable via APIs.
|
||||||
|
throw new Error("No such type: " + JSON.stringify(typename));
|
||||||
|
}
|
||||||
const b = Queries.build;
|
const b = Queries.build;
|
||||||
|
switch (type.type) {
|
||||||
|
case "OBJECT":
|
||||||
return [b.field("__typename"), b.field("id")];
|
return [b.field("__typename"), b.field("id")];
|
||||||
|
case "UNION":
|
||||||
|
return [
|
||||||
|
b.field("__typename"),
|
||||||
|
...this._schemaInfo.unionTypes[typename].clauses.map(
|
||||||
|
(clause: Schema.Typename) =>
|
||||||
|
b.inlineFragment(clause, [b.field("id")])
|
||||||
|
),
|
||||||
|
];
|
||||||
|
// istanbul ignore next
|
||||||
|
default:
|
||||||
|
throw new Error((type.type: empty));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -478,7 +499,10 @@ export class Mirror {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a GraphQL selection set to fetch elements from a collection.
|
* Create a GraphQL selection set to fetch elements from a collection,
|
||||||
|
* specified by its enclosing object type and the connection field
|
||||||
|
* name (for instance, "Repository" and "issues").
|
||||||
|
*
|
||||||
* If the connection has been queried before and you wish to fetch new
|
* If the connection has been queried before and you wish to fetch new
|
||||||
* elements, use an appropriate end cursor. Use `undefined` otherwise.
|
* elements, use an appropriate end cursor. Use `undefined` otherwise.
|
||||||
* Note that `null` is a valid end cursor and is distinct from
|
* Note that `null` is a valid end cursor and is distinct from
|
||||||
|
@ -515,10 +539,35 @@ export class Mirror {
|
||||||
* See: `_updateConnection`.
|
* See: `_updateConnection`.
|
||||||
*/
|
*/
|
||||||
_queryConnection(
|
_queryConnection(
|
||||||
|
typename: Schema.Typename,
|
||||||
fieldname: Schema.Fieldname,
|
fieldname: Schema.Fieldname,
|
||||||
endCursor: EndCursor | void,
|
endCursor: EndCursor | void,
|
||||||
connectionPageSize: number
|
connectionPageSize: number
|
||||||
): Queries.Selection[] {
|
): Queries.Selection[] {
|
||||||
|
if (this._schema[typename] == null) {
|
||||||
|
throw new Error("No such type: " + JSON.stringify(typename));
|
||||||
|
}
|
||||||
|
if (this._schema[typename].type !== "OBJECT") {
|
||||||
|
const s = JSON.stringify;
|
||||||
|
throw new Error(
|
||||||
|
`Cannot query connection on non-object type ${s(typename)} ` +
|
||||||
|
`(${this._schema[typename].type})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const field = this._schemaInfo.objectTypes[typename].fields[fieldname];
|
||||||
|
if (field == null) {
|
||||||
|
const s = JSON.stringify;
|
||||||
|
throw new Error(
|
||||||
|
`Object type ${s(typename)} has no field ${s(fieldname)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (field.type !== "CONNECTION") {
|
||||||
|
const s = JSON.stringify;
|
||||||
|
throw new Error(
|
||||||
|
`Cannot query non-connection field ${s(typename)}.${s(fieldname)} ` +
|
||||||
|
`(${field.type})`
|
||||||
|
);
|
||||||
|
}
|
||||||
const b = Queries.build;
|
const b = Queries.build;
|
||||||
const connectionArguments: Queries.Arguments = {
|
const connectionArguments: Queries.Arguments = {
|
||||||
first: b.literal(connectionPageSize),
|
first: b.literal(connectionPageSize),
|
||||||
|
@ -530,7 +579,7 @@ export class Mirror {
|
||||||
b.field(fieldname, connectionArguments, [
|
b.field(fieldname, connectionArguments, [
|
||||||
b.field("totalCount"),
|
b.field("totalCount"),
|
||||||
b.field("pageInfo", {}, [b.field("endCursor"), b.field("hasNextPage")]),
|
b.field("pageInfo", {}, [b.field("endCursor"), b.field("hasNextPage")]),
|
||||||
b.field("nodes", {}, this._queryShallow()),
|
b.field("nodes", {}, this._queryShallow(field.elementType)),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,9 +10,30 @@ import * as Queries from "./queries";
|
||||||
import {_buildSchemaInfo, _inTransaction, Mirror} from "./mirror";
|
import {_buildSchemaInfo, _inTransaction, Mirror} from "./mirror";
|
||||||
|
|
||||||
describe("graphql/mirror", () => {
|
describe("graphql/mirror", () => {
|
||||||
|
function issueTimelineItemClauses() {
|
||||||
|
return [
|
||||||
|
"Commit",
|
||||||
|
"IssueComment",
|
||||||
|
"CrossReferencedEvent",
|
||||||
|
"ClosedEvent",
|
||||||
|
"ReopenedEvent",
|
||||||
|
"SubscribedEvent",
|
||||||
|
"UnsubscribedEvent",
|
||||||
|
"ReferencedEvent",
|
||||||
|
"AssignedEvent",
|
||||||
|
"UnassignedEvent",
|
||||||
|
"LabeledEvent",
|
||||||
|
"UnlabeledEvent",
|
||||||
|
"MilestonedEvent",
|
||||||
|
"DemilestonedEvent",
|
||||||
|
"RenamedTitleEvent",
|
||||||
|
"LockedEvent",
|
||||||
|
"UnlockedEvent",
|
||||||
|
];
|
||||||
|
}
|
||||||
function buildGithubSchema(): Schema.Schema {
|
function buildGithubSchema(): Schema.Schema {
|
||||||
const s = Schema;
|
const s = Schema;
|
||||||
return s.schema({
|
const types: {[Schema.Typename]: Schema.NodeType} = {
|
||||||
Repository: s.object({
|
Repository: s.object({
|
||||||
id: s.id(),
|
id: s.id(),
|
||||||
url: s.primitive(),
|
url: s.primitive(),
|
||||||
|
@ -25,12 +46,14 @@ describe("graphql/mirror", () => {
|
||||||
repository: s.node("Repository"),
|
repository: s.node("Repository"),
|
||||||
title: s.primitive(),
|
title: s.primitive(),
|
||||||
comments: s.connection("IssueComment"),
|
comments: s.connection("IssueComment"),
|
||||||
|
timeline: s.connection("IssueTimelineItem"),
|
||||||
}),
|
}),
|
||||||
IssueComment: s.object({
|
IssueComment: s.object({
|
||||||
id: s.id(),
|
id: s.id(),
|
||||||
body: s.primitive(),
|
body: s.primitive(),
|
||||||
author: s.node("Actor"),
|
author: s.node("Actor"),
|
||||||
}),
|
}),
|
||||||
|
IssueTimelineItem: s.union(issueTimelineItemClauses()),
|
||||||
Actor: s.union(["User", "Bot", "Organization"]), // actually an interface
|
Actor: s.union(["User", "Bot", "Organization"]), // actually an interface
|
||||||
User: s.object({
|
User: s.object({
|
||||||
id: s.id(),
|
id: s.id(),
|
||||||
|
@ -47,7 +70,13 @@ describe("graphql/mirror", () => {
|
||||||
url: s.primitive(),
|
url: s.primitive(),
|
||||||
login: s.primitive(),
|
login: s.primitive(),
|
||||||
}),
|
}),
|
||||||
});
|
};
|
||||||
|
for (const clause of issueTimelineItemClauses()) {
|
||||||
|
if (types[clause] == null) {
|
||||||
|
types[clause] = s.object({id: s.id(), actor: s.node("Actor")});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.schema(types);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Mirror", () => {
|
describe("Mirror", () => {
|
||||||
|
@ -79,7 +108,8 @@ describe("graphql/mirror", () => {
|
||||||
.pluck()
|
.pluck()
|
||||||
.all();
|
.all();
|
||||||
expect(tables.sort()).toEqual(
|
expect(tables.sort()).toEqual(
|
||||||
[
|
Array.from(
|
||||||
|
new Set([
|
||||||
// Structural tables
|
// Structural tables
|
||||||
"meta",
|
"meta",
|
||||||
"updates",
|
"updates",
|
||||||
|
@ -94,7 +124,9 @@ describe("graphql/mirror", () => {
|
||||||
"primitives_User",
|
"primitives_User",
|
||||||
"primitives_Bot",
|
"primitives_Bot",
|
||||||
"primitives_Organization",
|
"primitives_Organization",
|
||||||
].sort()
|
...issueTimelineItemClauses().map((x) => `primitives_${x}`),
|
||||||
|
])
|
||||||
|
).sort()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -240,7 +272,7 @@ describe("graphql/mirror", () => {
|
||||||
)
|
)
|
||||||
.pluck()
|
.pluck()
|
||||||
.all(issueId)
|
.all(issueId)
|
||||||
).toEqual(["comments"]);
|
).toEqual(["comments", "timeline"].sort());
|
||||||
expect(
|
expect(
|
||||||
db
|
db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
@ -445,6 +477,19 @@ describe("graphql/mirror", () => {
|
||||||
hasNextPage: +false,
|
hasNextPage: +false,
|
||||||
endCursor: "cursor:issue4.comments",
|
endCursor: "cursor:issue4.comments",
|
||||||
});
|
});
|
||||||
|
for (const n of [1, 2, 3, 4]) {
|
||||||
|
// The "timeline" connection doesn't provide any extra useful
|
||||||
|
// info; just mark them all updated.
|
||||||
|
const objectId = `issue:ab/cd#${n}`;
|
||||||
|
setConnectionData({
|
||||||
|
objectId,
|
||||||
|
fieldname: "timeline",
|
||||||
|
update: lateUpdate.id,
|
||||||
|
totalCount: 0,
|
||||||
|
hasNextPage: +false,
|
||||||
|
endCursor: "cursor:whatever",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const actual = mirror._findOutdated(new Date(midUpdate.time));
|
const actual = mirror._findOutdated(new Date(midUpdate.time));
|
||||||
const expected = {
|
const expected = {
|
||||||
|
@ -482,6 +527,36 @@ describe("graphql/mirror", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("_queryShallow", () => {
|
||||||
|
it("fails when given a nonexistent type", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildGithubSchema());
|
||||||
|
expect(() => {
|
||||||
|
mirror._queryShallow("Wat");
|
||||||
|
}).toThrow('No such type: "Wat"');
|
||||||
|
});
|
||||||
|
it("handles an object type", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildGithubSchema());
|
||||||
|
const b = Queries.build;
|
||||||
|
expect(mirror._queryShallow("Issue")).toEqual([
|
||||||
|
b.field("__typename"),
|
||||||
|
b.field("id"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("handles a union type", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildGithubSchema());
|
||||||
|
const b = Queries.build;
|
||||||
|
expect(mirror._queryShallow("Actor")).toEqual([
|
||||||
|
b.field("__typename"),
|
||||||
|
b.inlineFragment("User", [b.field("id")]),
|
||||||
|
b.inlineFragment("Bot", [b.field("id")]),
|
||||||
|
b.inlineFragment("Organization", [b.field("id")]),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("_getEndCursor", () => {
|
describe("_getEndCursor", () => {
|
||||||
it("fails when the object does not exist", () => {
|
it("fails when the object does not exist", () => {
|
||||||
const db = new Database(":memory:");
|
const db = new Database(":memory:");
|
||||||
|
@ -550,12 +625,47 @@ describe("graphql/mirror", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("_queryConnection", () => {
|
describe("_queryConnection", () => {
|
||||||
|
it("fails when given a nonexistent type", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildGithubSchema());
|
||||||
|
expect(() => {
|
||||||
|
mirror._queryConnection("Wat", "wot", undefined, 23);
|
||||||
|
}).toThrow('No such type: "Wat"');
|
||||||
|
});
|
||||||
|
it("fails when given a non-object type", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildGithubSchema());
|
||||||
|
expect(() => {
|
||||||
|
mirror._queryConnection("Actor", "issues", undefined, 23);
|
||||||
|
}).toThrow(
|
||||||
|
'Cannot query connection on non-object type "Actor" (UNION)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("fails when given a nonexistent field name", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildGithubSchema());
|
||||||
|
expect(() => {
|
||||||
|
mirror._queryConnection("Issue", "mcguffins", undefined, 23);
|
||||||
|
}).toThrow('Object type "Issue" has no field "mcguffins"');
|
||||||
|
});
|
||||||
|
it("fails when given a non-connection field name", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildGithubSchema());
|
||||||
|
expect(() => {
|
||||||
|
mirror._queryConnection("Issue", "author", undefined, 23);
|
||||||
|
}).toThrow('Cannot query non-connection field "Issue"."author" (NODE)');
|
||||||
|
});
|
||||||
it("creates a query when no cursor is specified", () => {
|
it("creates a query when no cursor is specified", () => {
|
||||||
const db = new Database(":memory:");
|
const db = new Database(":memory:");
|
||||||
const mirror = new Mirror(db, buildGithubSchema());
|
const mirror = new Mirror(db, buildGithubSchema());
|
||||||
const pageLimit = 23;
|
const pageLimit = 23;
|
||||||
const endCursor = undefined;
|
const endCursor = undefined;
|
||||||
const actual = mirror._queryConnection("comments", endCursor, 23);
|
const actual = mirror._queryConnection(
|
||||||
|
"Issue",
|
||||||
|
"comments",
|
||||||
|
endCursor,
|
||||||
|
23
|
||||||
|
);
|
||||||
const b = Queries.build;
|
const b = Queries.build;
|
||||||
expect(actual).toEqual([
|
expect(actual).toEqual([
|
||||||
b.field("comments", {first: b.literal(pageLimit)}, [
|
b.field("comments", {first: b.literal(pageLimit)}, [
|
||||||
|
@ -573,7 +683,12 @@ describe("graphql/mirror", () => {
|
||||||
const mirror = new Mirror(db, buildGithubSchema());
|
const mirror = new Mirror(db, buildGithubSchema());
|
||||||
const pageLimit = 23;
|
const pageLimit = 23;
|
||||||
const endCursor = null;
|
const endCursor = null;
|
||||||
const actual = mirror._queryConnection("comments", endCursor, 23);
|
const actual = mirror._queryConnection(
|
||||||
|
"Issue",
|
||||||
|
"comments",
|
||||||
|
endCursor,
|
||||||
|
23
|
||||||
|
);
|
||||||
const b = Queries.build;
|
const b = Queries.build;
|
||||||
expect(actual).toEqual([
|
expect(actual).toEqual([
|
||||||
b.field(
|
b.field(
|
||||||
|
@ -595,7 +710,12 @@ describe("graphql/mirror", () => {
|
||||||
const mirror = new Mirror(db, buildGithubSchema());
|
const mirror = new Mirror(db, buildGithubSchema());
|
||||||
const pageLimit = 23;
|
const pageLimit = 23;
|
||||||
const endCursor = "c29tZS1jdXJzb3I=";
|
const endCursor = "c29tZS1jdXJzb3I=";
|
||||||
const actual = mirror._queryConnection("comments", endCursor, 23);
|
const actual = mirror._queryConnection(
|
||||||
|
"Issue",
|
||||||
|
"comments",
|
||||||
|
endCursor,
|
||||||
|
23
|
||||||
|
);
|
||||||
const b = Queries.build;
|
const b = Queries.build;
|
||||||
expect(actual).toEqual([
|
expect(actual).toEqual([
|
||||||
b.field(
|
b.field(
|
||||||
|
@ -614,51 +734,64 @@ describe("graphql/mirror", () => {
|
||||||
});
|
});
|
||||||
it("snapshot test for actual GitHub queries", () => {
|
it("snapshot test for actual GitHub queries", () => {
|
||||||
// This test emits as a snapshot a valid query against GitHub's
|
// This test emits as a snapshot a valid query against GitHub's
|
||||||
// GraphQL API. You can copy-and-paste the snapshot into
|
// GraphQL API. You should be able to copy-and-paste the
|
||||||
// <https://developer.github.com/v4/explorer/> to run it. The
|
// snapshot into <https://developer.github.com/v4/explorer/> to
|
||||||
// resulting IDs in `initialQuery` and `updateQuery` should
|
// run it.* The resulting IDs in `initialQuery` and
|
||||||
// concatenate to match those in `expectedIds`. In particular,
|
// `updateQuery` should concatenate to match those in
|
||||||
// the following JQ program should output `true` when passed the
|
// `expectedIds`. In particular, the following JQ program should
|
||||||
// query result from GitHub:
|
// output `true` when passed the query result from GitHub:
|
||||||
//
|
//
|
||||||
// jq '.data |
|
// jq '
|
||||||
// ([.initialQuery, .updateQuery] | map(.issues.nodes[].id))
|
// .data |
|
||||||
// == [.expectedIds.issues.nodes[].id]
|
// (([.objectInitial, .objectUpdate] | map(.issues.nodes[].id))
|
||||||
|
// == [.objectExpectedIds.issues.nodes[].id])
|
||||||
|
// and
|
||||||
|
// (([.unionInitial, .unionUpdate] | map(.timeline.nodes[].id))
|
||||||
|
// == [.unionExpectedIds.timeline.nodes[].id])
|
||||||
// '
|
// '
|
||||||
|
//
|
||||||
|
// * This may not actually work, because the query text is
|
||||||
|
// somewhat large (a few kilobytes), and sometimes GitHub's
|
||||||
|
// GraphiQL explorer chokes on such endpoints. Posting directly
|
||||||
|
// to the input with curl(1) works. You could also temporarily
|
||||||
|
// change the `multilineLayout` to an `inlineLayout` to shave
|
||||||
|
// off some bytes and possibly appease the GraphiQL gods.
|
||||||
const db = new Database(":memory:");
|
const db = new Database(":memory:");
|
||||||
const mirror = new Mirror(db, buildGithubSchema());
|
const mirror = new Mirror(db, buildGithubSchema());
|
||||||
|
const b = Queries.build;
|
||||||
|
|
||||||
|
// Queries for a connection whose declared type is an object.
|
||||||
|
function objectConnectionQuery(): Queries.Selection[] {
|
||||||
const exampleGithubRepoId = "MDEwOlJlcG9zaXRvcnkxMjMyNTUwMDY=";
|
const exampleGithubRepoId = "MDEwOlJlcG9zaXRvcnkxMjMyNTUwMDY=";
|
||||||
const pageLimit = 2;
|
const pageLimit = 2;
|
||||||
const b = Queries.build;
|
|
||||||
const initialQuery = mirror._queryConnection(
|
const initialQuery = mirror._queryConnection(
|
||||||
|
"Repository",
|
||||||
"issues",
|
"issues",
|
||||||
undefined,
|
undefined,
|
||||||
pageLimit
|
pageLimit
|
||||||
);
|
);
|
||||||
const expectedEndCursor = "Y3Vyc29yOnYyOpHOEe_nRA==";
|
const expectedEndCursor = "Y3Vyc29yOnYyOpHOEe_nRA==";
|
||||||
const updateQuery = mirror._queryConnection(
|
const updateQuery = mirror._queryConnection(
|
||||||
|
"Repository",
|
||||||
"issues",
|
"issues",
|
||||||
expectedEndCursor,
|
expectedEndCursor,
|
||||||
pageLimit
|
pageLimit
|
||||||
);
|
);
|
||||||
const query = b.query(
|
return [
|
||||||
"TestQuery",
|
|
||||||
[],
|
|
||||||
[
|
|
||||||
b.alias(
|
b.alias(
|
||||||
"initialQuery",
|
"objectInitial",
|
||||||
b.field("node", {id: b.literal(exampleGithubRepoId)}, [
|
b.field("node", {id: b.literal(exampleGithubRepoId)}, [
|
||||||
b.inlineFragment("Repository", initialQuery),
|
b.inlineFragment("Repository", initialQuery),
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
b.alias(
|
b.alias(
|
||||||
"updateQuery",
|
"objectUpdate",
|
||||||
b.field("node", {id: b.literal(exampleGithubRepoId)}, [
|
b.field("node", {id: b.literal(exampleGithubRepoId)}, [
|
||||||
b.inlineFragment("Repository", updateQuery),
|
b.inlineFragment("Repository", updateQuery),
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
b.alias(
|
b.alias(
|
||||||
"expectedIds",
|
"objectExpectedIds",
|
||||||
b.field("node", {id: b.literal(exampleGithubRepoId)}, [
|
b.field("node", {id: b.literal(exampleGithubRepoId)}, [
|
||||||
b.inlineFragment("Repository", [
|
b.inlineFragment("Repository", [
|
||||||
b.field("issues", {first: b.literal(pageLimit * 2)}, [
|
b.field("issues", {first: b.literal(pageLimit * 2)}, [
|
||||||
|
@ -667,7 +800,73 @@ describe("graphql/mirror", () => {
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
]
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queries for a connection whose declared type is a union.
|
||||||
|
function unionConnectionQuery(): Queries.Selection[] {
|
||||||
|
// Almost all GitHub connections return OBJECTs for nodes, but
|
||||||
|
// a very few return UNIONs:
|
||||||
|
//
|
||||||
|
// - `IssueTimelineConnection`,
|
||||||
|
// - `PullRequestTimelineConnection`, and
|
||||||
|
// - `SearchResultItemConnection`.
|
||||||
|
//
|
||||||
|
// Of these, `SearchResultItemConnection` does not adhere to
|
||||||
|
// the same interface as the rest of the connections (it does
|
||||||
|
// not have a `totalCount` field), so it will not work with
|
||||||
|
// our system. But the two timeline connections are actually
|
||||||
|
// important---they let us see who assigns labels---so we test
|
||||||
|
// one of them.
|
||||||
|
const exampleIssueId = "MDU6SXNzdWUzMDA5MzQ4MTg=";
|
||||||
|
const pageLimit = 1;
|
||||||
|
const initialQuery = mirror._queryConnection(
|
||||||
|
"Issue",
|
||||||
|
"timeline",
|
||||||
|
undefined,
|
||||||
|
pageLimit
|
||||||
|
);
|
||||||
|
const expectedEndCursor = "MQ==";
|
||||||
|
const updateQuery = mirror._queryConnection(
|
||||||
|
"Issue",
|
||||||
|
"timeline",
|
||||||
|
expectedEndCursor,
|
||||||
|
pageLimit
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
b.alias(
|
||||||
|
"unionInitial",
|
||||||
|
b.field("node", {id: b.literal(exampleIssueId)}, [
|
||||||
|
b.inlineFragment("Issue", initialQuery),
|
||||||
|
])
|
||||||
|
),
|
||||||
|
b.alias(
|
||||||
|
"unionUpdate",
|
||||||
|
b.field("node", {id: b.literal(exampleIssueId)}, [
|
||||||
|
b.inlineFragment("Issue", updateQuery),
|
||||||
|
])
|
||||||
|
),
|
||||||
|
b.alias(
|
||||||
|
"unionExpectedIds",
|
||||||
|
b.field("node", {id: b.literal(exampleIssueId)}, [
|
||||||
|
b.inlineFragment("Issue", [
|
||||||
|
b.field("timeline", {first: b.literal(pageLimit * 2)}, [
|
||||||
|
b.field("nodes", {}, [
|
||||||
|
...issueTimelineItemClauses().map((clause) =>
|
||||||
|
b.inlineFragment(clause, [b.field("id")])
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = b.query(
|
||||||
|
"TestQuery",
|
||||||
|
[],
|
||||||
|
[...objectConnectionQuery(), ...unionConnectionQuery()]
|
||||||
);
|
);
|
||||||
const format = (body: Queries.Body): string =>
|
const format = (body: Queries.Body): string =>
|
||||||
Queries.stringify.body(body, Queries.multilineLayout(" "));
|
Queries.stringify.body(body, Queries.multilineLayout(" "));
|
||||||
|
@ -881,14 +1080,17 @@ describe("graphql/mirror", () => {
|
||||||
it("processes object types properly", () => {
|
it("processes object types properly", () => {
|
||||||
const result = _buildSchemaInfo(buildGithubSchema());
|
const result = _buildSchemaInfo(buildGithubSchema());
|
||||||
expect(Object.keys(result.objectTypes).sort()).toEqual(
|
expect(Object.keys(result.objectTypes).sort()).toEqual(
|
||||||
[
|
Array.from(
|
||||||
|
new Set([
|
||||||
"Repository",
|
"Repository",
|
||||||
"Issue",
|
"Issue",
|
||||||
"IssueComment",
|
"IssueComment",
|
||||||
"User",
|
"User",
|
||||||
"Bot",
|
"Bot",
|
||||||
"Organization",
|
"Organization",
|
||||||
].sort()
|
...issueTimelineItemClauses(),
|
||||||
|
])
|
||||||
|
).sort()
|
||||||
);
|
);
|
||||||
expect(result.objectTypes["Issue"].fields).toEqual(
|
expect(result.objectTypes["Issue"].fields).toEqual(
|
||||||
(buildGithubSchema().Issue: any).fields
|
(buildGithubSchema().Issue: any).fields
|
||||||
|
@ -901,11 +1103,13 @@ describe("graphql/mirror", () => {
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
result.objectTypes["Issue"].connectionFieldNames.slice().sort()
|
result.objectTypes["Issue"].connectionFieldNames.slice().sort()
|
||||||
).toEqual(["comments"].sort());
|
).toEqual(["comments", "timeline"].sort());
|
||||||
});
|
});
|
||||||
it("processes union types correctly", () => {
|
it("processes union types correctly", () => {
|
||||||
const result = _buildSchemaInfo(buildGithubSchema());
|
const result = _buildSchemaInfo(buildGithubSchema());
|
||||||
expect(Object.keys(result.unionTypes).sort()).toEqual(["Actor"].sort());
|
expect(Object.keys(result.unionTypes).sort()).toEqual(
|
||||||
|
["Actor", "IssueTimelineItem"].sort()
|
||||||
|
);
|
||||||
expect(result.unionTypes["Actor"].clauses.slice().sort()).toEqual(
|
expect(result.unionTypes["Actor"].clauses.slice().sort()).toEqual(
|
||||||
["User", "Bot", "Organization"].sort()
|
["User", "Bot", "Organization"].sort()
|
||||||
);
|
);
|
||||||
|
|
Loading…
Reference in New Issue