mirror: add public method `extract` (#894)
Summary: The `extract` method lets you get data out of a mirror in a structured format. The mirror module now contains all the plumbing needed to provide meaningful value. Remaining to be implemented are some internal porcelain and a public method to perform an update step. This makes progress toward #622. Test Plan: Comprehensive unit tests included, with full coverage; run `yarn unit`. wchargin-branch: mirror-extract
This commit is contained in:
parent
90ace93f91
commit
b74f1f3714
|
@ -4,6 +4,7 @@ import type Database, {BindingDictionary, Statement} from "better-sqlite3";
|
|||
import stringify from "json-stable-stringify";
|
||||
|
||||
import dedent from "../util/dedent";
|
||||
import * as NullUtil from "../util/null";
|
||||
import * as Schema from "./schema";
|
||||
import * as Queries from "./queries";
|
||||
|
||||
|
@ -945,6 +946,269 @@ export class Mirror {
|
|||
|
||||
// Last-updates, primitives, and links all updated: we're done.
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a structured object and all of its transitive dependencies
|
||||
* from the database.
|
||||
*
|
||||
* The result is an object whose keys are fieldnames, and whose values
|
||||
* are:
|
||||
*
|
||||
* - for the ID field: the object ID;
|
||||
* - for primitive fields: the corresponding primitive value;
|
||||
* - for node reference fields: a reference to the corresponding
|
||||
* extracted object, which may be `null`;
|
||||
* - for connection fields: an in-order array of the corresponding
|
||||
* extracted objects, each of which may be `null`.
|
||||
*
|
||||
* For instance, the result of `extract("issue:1")` might be:
|
||||
*
|
||||
* {
|
||||
* id: "issue:1172",
|
||||
* title: "bug: holding <Space> causes CPU to overheat",
|
||||
* body: "We should fix this immediately.",
|
||||
* author: {
|
||||
* id: "user:admin",
|
||||
* login: "admin",
|
||||
* },
|
||||
* comments: [
|
||||
* {
|
||||
* body: "I depend on this behavior; please do not change it.",
|
||||
* author: {
|
||||
* id: "user:longtimeuser4",
|
||||
* login: "longtimeuser4",
|
||||
* },
|
||||
* },
|
||||
* {
|
||||
* body: "That's horrifying.",
|
||||
* author: {
|
||||
* id: "user:admin",
|
||||
* login: "admin",
|
||||
* },
|
||||
* },
|
||||
* ],
|
||||
* }
|
||||
*
|
||||
* The returned structure may be circular.
|
||||
*
|
||||
* If a node appears more than one time in the result---for instance,
|
||||
* the "user:admin" node above---all instances will refer to the same
|
||||
* object. However, objects are distinct across calls to `extract`, so
|
||||
* it is safe to deeply mutate the result of this function.
|
||||
*
|
||||
* The provided object ID must correspond to a known object, or an
|
||||
* error will be thrown. Furthermore, all transitive dependencies of
|
||||
* the object must have been at least partially loaded at some point,
|
||||
* or an error will be thrown.
|
||||
*/
|
||||
extract(rootId: Schema.ObjectId): mixed {
|
||||
const db = this._db;
|
||||
return _inTransaction(db, () => {
|
||||
// We'll compute the transitive dependencies and store them into a
|
||||
// temporary table. To do so, we first find a free table name.
|
||||
const temporaryTableName: string = _nontransactionallyFindUnusedTableName(
|
||||
db,
|
||||
"tmp_transitive_dependencies_"
|
||||
);
|
||||
db.prepare(
|
||||
`CREATE TEMPORARY TABLE ${temporaryTableName} ` +
|
||||
"(id TEXT NOT NULL PRIMARY KEY, typename TEXT NOT NULL)"
|
||||
).run();
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
dedent`\
|
||||
WITH RECURSIVE
|
||||
direct_dependencies (parent_id, child_id) AS (
|
||||
SELECT parent_id, child_id FROM links
|
||||
WHERE child_id IS NOT NULL
|
||||
UNION
|
||||
SELECT DISTINCT
|
||||
connections.object_id AS parent_id,
|
||||
connection_entries.child_id AS child_id
|
||||
FROM connections JOIN connection_entries
|
||||
ON connections.rowid = connection_entries.connection_id
|
||||
WHERE child_id IS NOT NULL
|
||||
),
|
||||
transitive_dependencies (id) AS (
|
||||
VALUES (:rootId) UNION
|
||||
SELECT direct_dependencies.child_id
|
||||
FROM transitive_dependencies JOIN direct_dependencies
|
||||
ON transitive_dependencies.id = direct_dependencies.parent_id
|
||||
)
|
||||
INSERT INTO ${temporaryTableName} (id, typename)
|
||||
SELECT objects.id AS id, objects.typename AS typename
|
||||
FROM objects JOIN transitive_dependencies
|
||||
ON objects.id = transitive_dependencies.id
|
||||
`
|
||||
).run({rootId});
|
||||
const typenames: $ReadOnlyArray<Schema.Typename> = db
|
||||
.prepare(`SELECT DISTINCT typename FROM ${temporaryTableName}`)
|
||||
.pluck()
|
||||
.all();
|
||||
|
||||
// Check to make sure all required objects and connections have
|
||||
// been updated at least once.
|
||||
{
|
||||
const neverUpdatedEntry: void | {|
|
||||
+id: Schema.ObjectId,
|
||||
+fieldname: null | Schema.Fieldname,
|
||||
|} = db
|
||||
.prepare(
|
||||
dedent`\
|
||||
SELECT objects.id AS id, NULL as fieldname
|
||||
FROM ${temporaryTableName}
|
||||
JOIN objects USING (id)
|
||||
WHERE objects.last_update IS NULL
|
||||
UNION ALL
|
||||
SELECT objects.id AS id, connections.fieldname AS fieldname
|
||||
FROM ${temporaryTableName}
|
||||
JOIN objects
|
||||
USING (id)
|
||||
LEFT OUTER JOIN connections
|
||||
ON objects.id = connections.object_id
|
||||
WHERE
|
||||
connections.rowid IS NOT NULL
|
||||
AND connections.last_update IS NULL
|
||||
`
|
||||
)
|
||||
.get();
|
||||
if (neverUpdatedEntry !== undefined) {
|
||||
const entry = neverUpdatedEntry;
|
||||
const s = JSON.stringify;
|
||||
const missingData: string =
|
||||
entry.fieldname == null
|
||||
? "own data"
|
||||
: `${s(entry.fieldname)} connection`;
|
||||
throw new Error(
|
||||
`${s(rootId)} transitively depends on ${s(entry.id)}, ` +
|
||||
`but that object's ${missingData} has never been fetched`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Constructing the result set inherently requires mutation,
|
||||
// because the object graph can have cycles. We start by
|
||||
// creating a record for each object, with just that object's
|
||||
// primitive data. Then, we link in node references and
|
||||
// connection entries.
|
||||
const allObjects: Map<Schema.ObjectId, Object> = new Map();
|
||||
for (const typename of typenames) {
|
||||
const objectType = this._schemaInfo.objectTypes[typename];
|
||||
// istanbul ignore if: should not be possible using the
|
||||
// publicly accessible APIs
|
||||
if (objectType == null) {
|
||||
throw new Error(
|
||||
`Corruption: unknown object type ${JSON.stringify(typename)}`
|
||||
);
|
||||
}
|
||||
const primitivesTableName = _primitivesTableName(typename);
|
||||
const selections = [
|
||||
`${primitivesTableName}.id AS id`,
|
||||
...objectType.primitiveFieldNames.map(
|
||||
(fieldname) =>
|
||||
`${primitivesTableName}."${fieldname}" AS "${fieldname}"`
|
||||
),
|
||||
].join(", ");
|
||||
const rows: $ReadOnlyArray<{|
|
||||
+id: Schema.ObjectId,
|
||||
+[Schema.Fieldname]: string,
|
||||
|}> = db
|
||||
.prepare(
|
||||
dedent`\
|
||||
SELECT ${selections}
|
||||
FROM ${temporaryTableName} JOIN ${primitivesTableName}
|
||||
USING (id)
|
||||
`
|
||||
)
|
||||
.all();
|
||||
for (const row of rows) {
|
||||
const object = {};
|
||||
object.id = row.id;
|
||||
for (const key of Object.keys(row)) {
|
||||
if (key === "id") continue;
|
||||
object[key] = JSON.parse(row[key]);
|
||||
}
|
||||
allObjects.set(object.id, object);
|
||||
}
|
||||
}
|
||||
|
||||
// Add links.
|
||||
{
|
||||
const getLinks = db.prepare(
|
||||
dedent`\
|
||||
SELECT
|
||||
parent_id AS parentId,
|
||||
fieldname AS fieldname,
|
||||
child_id AS childId
|
||||
FROM ${temporaryTableName} JOIN links
|
||||
ON ${temporaryTableName}.id = links.parent_id
|
||||
`
|
||||
);
|
||||
for (const link: {|
|
||||
+parentId: Schema.ObjectId,
|
||||
+fieldname: Schema.Fieldname,
|
||||
+childId: Schema.ObjectId | null,
|
||||
|} of getLinks.iterate()) {
|
||||
const parent = NullUtil.get(allObjects.get(link.parentId));
|
||||
const child =
|
||||
link.childId == null
|
||||
? null
|
||||
: NullUtil.get(allObjects.get(link.childId));
|
||||
parent[link.fieldname] = child;
|
||||
}
|
||||
}
|
||||
|
||||
// Add connections.
|
||||
{
|
||||
const getConnectionData = db.prepare(
|
||||
dedent`\
|
||||
SELECT
|
||||
objects.id AS parentId,
|
||||
connections.fieldname AS fieldname,
|
||||
connection_entries.connection_id IS NOT NULL AS hasContents,
|
||||
connection_entries.child_id AS childId
|
||||
FROM ${temporaryTableName}
|
||||
JOIN objects
|
||||
USING (id)
|
||||
JOIN connections
|
||||
ON objects.id = connections.object_id
|
||||
LEFT OUTER JOIN connection_entries
|
||||
ON connections.rowid = connection_entries.connection_id
|
||||
ORDER BY
|
||||
objects.id, connections.fieldname, connection_entries.idx ASC
|
||||
`
|
||||
);
|
||||
for (const datum: {|
|
||||
+parentId: Schema.ObjectId,
|
||||
+fieldname: Schema.Fieldname,
|
||||
+hasContents: 0 | 1,
|
||||
+childId: Schema.ObjectId | null,
|
||||
|} of getConnectionData.iterate()) {
|
||||
const parent = NullUtil.get(allObjects.get(datum.parentId));
|
||||
if (parent[datum.fieldname] === undefined) {
|
||||
parent[datum.fieldname] = [];
|
||||
}
|
||||
if (datum.hasContents) {
|
||||
const child =
|
||||
datum.childId == null
|
||||
? null
|
||||
: NullUtil.get(allObjects.get(datum.childId));
|
||||
parent[datum.fieldname].push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = allObjects.get(rootId);
|
||||
if (result === undefined) {
|
||||
throw new Error("No such object: " + JSON.stringify(rootId));
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
this._db.prepare(`DROP TABLE ${temporaryTableName}`).run();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1378,6 +1378,757 @@ describe("graphql/mirror", () => {
|
|||
expect(format([query])).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extract", () => {
|
||||
// A schema with some useful edge cases.
|
||||
function buildTestSchema(): Schema.Schema {
|
||||
const s = Schema;
|
||||
return s.schema({
|
||||
Caveman: s.object({
|
||||
id: s.id(),
|
||||
only: s.primitive(),
|
||||
primitives: s.primitive(),
|
||||
}),
|
||||
Feline: s.object({
|
||||
id: s.id(),
|
||||
only: s.node("Feline"),
|
||||
lynx: s.node("Feline"),
|
||||
}),
|
||||
Socket: s.object({
|
||||
id: s.id(),
|
||||
only: s.connection("Socket"),
|
||||
connections: s.connection("Socket"),
|
||||
}),
|
||||
Empty: s.object({
|
||||
id: s.id(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
type Caveman = {|
|
||||
+id: string,
|
||||
+only: mixed,
|
||||
+primitives: mixed,
|
||||
|};
|
||||
type Feline = {|
|
||||
+id: string,
|
||||
+only: null | Feline,
|
||||
+lynx: null | Feline,
|
||||
|};
|
||||
type Socket = {|
|
||||
+id: string,
|
||||
+only: $ReadOnlyArray<null | Socket>,
|
||||
+connections: $ReadOnlyArray<null | Socket>,
|
||||
|};
|
||||
|
||||
it("fails if the provided object does not exist", () => {
|
||||
const db = new Database(":memory:");
|
||||
const mirror = new Mirror(db, buildTestSchema());
|
||||
expect(() => {
|
||||
mirror.extract("wat");
|
||||
}).toThrow('No such object: "wat"');
|
||||
});
|
||||
|
||||
it("fails if the provided object is missing own-data", () => {
|
||||
const db = new Database(":memory:");
|
||||
const mirror = new Mirror(db, buildTestSchema());
|
||||
mirror.registerObject({typename: "Caveman", id: "brog"});
|
||||
expect(() => {
|
||||
mirror.extract("brog");
|
||||
}).toThrow(
|
||||
'"brog" transitively depends on "brog", ' +
|
||||
"but that object's own data has never been fetched"
|
||||
);
|
||||
});
|
||||
|
||||
it("fails if the provided object is missing connection data", () => {
|
||||
const db = new Database(":memory:");
|
||||
const mirror = new Mirror(db, buildTestSchema());
|
||||
mirror.registerObject({typename: "Socket", id: "localhost"});
|
||||
mirror.registerObject({typename: "Socket", id: "loopback"});
|
||||
const updateId = mirror._createUpdate(new Date(123));
|
||||
mirror._updateOwnData(updateId, [
|
||||
{__typename: "Socket", id: "localhost"},
|
||||
{__typename: "Socket", id: "loopback"},
|
||||
]);
|
||||
|
||||
mirror._updateConnection(updateId, "localhost", "connections", {
|
||||
totalCount: 0,
|
||||
pageInfo: {hasNextPage: false, endCursor: null},
|
||||
nodes: [],
|
||||
});
|
||||
expect(() => {
|
||||
mirror.extract("localhost");
|
||||
}).toThrow(
|
||||
'"localhost" transitively depends on "localhost", ' +
|
||||
'but that object\'s "only" connection has never been fetched'
|
||||
);
|
||||
|
||||
mirror._updateConnection(updateId, "loopback", "only", {
|
||||
totalCount: 0,
|
||||
pageInfo: {hasNextPage: false, endCursor: null},
|
||||
nodes: [],
|
||||
});
|
||||
expect(() => {
|
||||
mirror.extract("loopback");
|
||||
}).toThrow(
|
||||
'"loopback" transitively depends on "loopback", ' +
|
||||
'but that object\'s "connections" connection has never been fetched'
|
||||
);
|
||||
});
|
||||
|
||||
it("fails if a transitive dependency is missing own-data", () => {
|
||||
const db = new Database(":memory:");
|
||||
const mirror = new Mirror(db, buildTestSchema());
|
||||
mirror.registerObject({typename: "Feline", id: "alpha"});
|
||||
mirror.registerObject({typename: "Feline", id: "beta"});
|
||||
mirror.registerObject({typename: "Feline", id: "gamma"});
|
||||
const updateId = mirror._createUpdate(new Date(123));
|
||||
mirror._updateOwnData(updateId, [
|
||||
{
|
||||
__typename: "Feline",
|
||||
id: "alpha",
|
||||
only: null,
|
||||
lynx: {__typename: "Feline", id: "beta"},
|
||||
},
|
||||
{
|
||||
__typename: "Feline",
|
||||
id: "beta",
|
||||
only: null,
|
||||
lynx: {__typename: "Feline", id: "gamma"},
|
||||
},
|
||||
]);
|
||||
expect(() => {
|
||||
mirror.extract("alpha");
|
||||
}).toThrow(
|
||||
'"alpha" transitively depends on "gamma", ' +
|
||||
"but that object's own data has never been fetched"
|
||||
);
|
||||
});
|
||||
|
||||
it("fails if a transitive dependency is missing connection data", () => {
|
||||
const db = new Database(":memory:");
|
||||
const mirror = new Mirror(db, buildTestSchema());
|
||||
mirror.registerObject({typename: "Socket", id: "localhost:8080"});
|
||||
mirror.registerObject({typename: "Socket", id: "localhost:7070"});
|
||||
mirror.registerObject({typename: "Socket", id: "localhost:6060"});
|
||||
const updateId = mirror._createUpdate(new Date(123));
|
||||
mirror._updateOwnData(updateId, [
|
||||
{__typename: "Socket", id: "localhost:8080"},
|
||||
{__typename: "Socket", id: "localhost:7070"},
|
||||
{__typename: "Socket", id: "localhost:6060"},
|
||||
]);
|
||||
const updateConnection = (
|
||||
objectId: Schema.ObjectId,
|
||||
fieldname: Schema.Fieldname,
|
||||
ids: $ReadOnlyArray<Schema.ObjectId>
|
||||
) => {
|
||||
mirror._updateConnection(updateId, objectId, fieldname, {
|
||||
totalCount: ids.length,
|
||||
pageInfo: {hasNextPage: false, endCursor: String(ids.length)},
|
||||
nodes: ids.map((id) => ({__typename: "Socket", id})),
|
||||
});
|
||||
};
|
||||
updateConnection("localhost:8080", "only", []);
|
||||
updateConnection("localhost:7070", "only", []);
|
||||
updateConnection("localhost:6060", "only", []);
|
||||
updateConnection("localhost:8080", "connections", ["localhost:7070"]);
|
||||
updateConnection("localhost:7070", "connections", ["localhost:6060"]);
|
||||
expect(() => {
|
||||
mirror.extract("localhost:8080");
|
||||
}).toThrow(
|
||||
'"localhost:8080" transitively depends on "localhost:6060", ' +
|
||||
'but that object\'s "connections" connection has never been fetched'
|
||||
);
|
||||
});
|
||||
|
||||
it("handles objects that only have primitive fields", () => {
|
||||
const db = new Database(":memory:");
|
||||
const mirror = new Mirror(db, buildTestSchema());
|
||||
mirror.registerObject({typename: "Caveman", id: "brog"});
|
||||
const updateId = mirror._createUpdate(new Date(123));
|
||||
mirror._updateOwnData(updateId, [
|
||||
{__typename: "Caveman", id: "brog", only: "ugg", primitives: "ook"},
|
||||
]);
|
||||
const result: Caveman = (mirror.extract("brog"): any);
|
||||
expect(result).toEqual({
|
||||
id: "brog",
|
||||
only: "ugg",
|
||||
primitives: "ook",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles objects that only have link fields", () => {
|
||||
const db = new Database(":memory:");
|
||||
const mirror = new Mirror(db, buildTestSchema());
|
||||
mirror.registerObject({typename: "Feline", id: "alpha"});
|
||||
mirror.registerObject({typename: "Feline", id: "beta"});
|
||||
mirror.registerObject({typename: "Feline", id: "gamma"});
|
||||
const updateId = mirror._createUpdate(new Date(123));
|
||||
mirror._updateOwnData(updateId, [
|
||||
{
|
||||
__typename: "Feline",
|
||||
id: "alpha",
|
||||
only: null,
|
||||
lynx: {__typename: "Feline", id: "beta"},
|
||||
},
|
||||
{
|
||||
__typename: "Feline",
|
||||
id: "beta",
|
||||
only: null,
|
||||
lynx: {__typename: "Feline", id: "gamma"},
|
||||
},
|
||||
{
|
||||
__typename: "Feline",
|
||||
id: "gamma",
|
||||
only: null,
|
||||
lynx: null,
|
||||
},
|
||||
]);
|
||||
const result = mirror.extract("alpha");
|
||||
expect(result).toEqual({
|
||||
id: "alpha",
|
||||
only: null,
|
||||
lynx: {
|
||||
id: "beta",
|
||||
only: null,
|
||||
lynx: {
|
||||
id: "gamma",
|
||||
only: null,
|
||||
lynx: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("handles objects that only have connection fields", () => {
|
||||
const db = new Database(":memory:");
|
||||
const mirror = new Mirror(db, buildTestSchema());
|
||||
mirror.registerObject({typename: "Socket", id: "localhost:8080"});
|
||||
mirror.registerObject({typename: "Socket", id: "localhost:7070"});
|
||||
mirror.registerObject({typename: "Socket", id: "localhost:6060"});
|
||||
const updateId = mirror._createUpdate(new Date(123));
|
||||
mirror._updateOwnData(updateId, [
|
||||
{__typename: "Socket", id: "localhost:8080"},
|
||||
{__typename: "Socket", id: "localhost:7070"},
|
||||
{__typename: "Socket", id: "localhost:6060"},
|
||||
]);
|
||||
const updateConnection = (
|
||||
objectId: Schema.ObjectId,
|
||||
fieldname: Schema.Fieldname,
|
||||
ids: $ReadOnlyArray<Schema.ObjectId>
|
||||
) => {
|
||||
mirror._updateConnection(updateId, objectId, fieldname, {
|
||||
totalCount: ids.length,
|
||||
pageInfo: {hasNextPage: false, endCursor: String(ids.length)},
|
||||
nodes: ids.map((id) => ({__typename: "Socket", id})),
|
||||
});
|
||||
};
|
||||
updateConnection("localhost:8080", "only", []);
|
||||
updateConnection("localhost:7070", "only", []);
|
||||
updateConnection("localhost:6060", "only", []);
|
||||
updateConnection("localhost:8080", "connections", ["localhost:7070"]);
|
||||
updateConnection("localhost:7070", "connections", [
|
||||
"localhost:6060",
|
||||
"localhost:6060",
|
||||
]);
|
||||
updateConnection("localhost:6060", "connections", []);
|
||||
const result = mirror.extract("localhost:8080");
|
||||
expect(result).toEqual({
|
||||
id: "localhost:8080",
|
||||
only: [],
|
||||
connections: [
|
||||
{
|
||||
id: "localhost:7070",
|
||||
only: [],
|
||||
connections: [
|
||||
{id: "localhost:6060", only: [], connections: []},
|
||||
{id: "localhost:6060", only: [], connections: []},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("handles objects with no fields", () => {
|
||||
const db = new Database(":memory:");
|
||||
const mirror = new Mirror(db, buildTestSchema());
|
||||
mirror.registerObject({typename: "Empty", id: "mt"});
|
||||
const updateId = mirror._createUpdate(new Date(123));
|
||||
mirror._updateOwnData(updateId, [{__typename: "Empty", id: "mt"}]);
|
||||
const result = mirror.extract("mt");
|
||||
expect(result).toEqual({id: "mt"});
|
||||
});
|
||||
|
||||
it("handles boolean primitives", () => {
|
||||
const db = new Database(":memory:");
|
||||
const mirror = new Mirror(db, buildTestSchema());
|
||||
mirror.registerObject({typename: "Caveman", id: "brog"});
|
||||
const updateId = mirror._createUpdate(new Date(123));
|
||||
mirror._updateOwnData(updateId, [
|
||||
{__typename: "Caveman", id: "brog", only: false, primitives: true},
|
||||
]);
|
||||
expect(mirror.extract("brog")).toEqual({
|
||||
id: "brog",
|
||||
only: false,
|
||||
primitives: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles null primitives", () => {
|
||||
const db = new Database(":memory:");
|
||||
const mirror = new Mirror(db, buildTestSchema());
|
||||
mirror.registerObject({typename: "Caveman", id: "brog"});
|
||||
const updateId = mirror._createUpdate(new Date(123));
|
||||
mirror._updateOwnData(updateId, [
|
||||
{__typename: "Caveman", id: "brog", only: null, primitives: null},
|
||||
]);
|
||||
expect(mirror.extract("brog")).toEqual({
|
||||
id: "brog",
|
||||
only: null,
|
||||
primitives: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles numeric primitives", () => {
|
||||
const db = new Database(":memory:");
|
||||
const mirror = new Mirror(db, buildTestSchema());
|
||||
mirror.registerObject({typename: "Caveman", id: "brog"});
|
||||
const updateId = mirror._createUpdate(new Date(123));
|
||||
mirror._updateOwnData(updateId, [
|
||||
{__typename: "Caveman", id: "brog", only: 123, primitives: 987},
|
||||
]);
|
||||
expect(mirror.extract("brog")).toEqual({
|
||||
id: "brog",
|
||||
only: 123,
|
||||
primitives: 987,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles cyclic link structures", () => {
|
||||
const db = new Database(":memory:");
|
||||
const mirror = new Mirror(db, buildTestSchema());
|
||||
mirror.registerObject({typename: "Feline", id: "alpha"});
|
||||
mirror.registerObject({typename: "Feline", id: "beta"});
|
||||
mirror.registerObject({typename: "Feline", id: "gamma"});
|
||||
const updateId = mirror._createUpdate(new Date(123));
|
||||
mirror._updateOwnData(updateId, [
|
||||
{
|
||||
__typename: "Feline",
|
||||
id: "alpha",
|
||||
only: null,
|
||||
lynx: {__typename: "Feline", id: "beta"},
|
||||
},
|
||||
{
|
||||
__typename: "Feline",
|
||||
id: "beta",
|
||||
only: {__typename: "Feline", id: "beta"},
|
||||
lynx: {__typename: "Feline", id: "gamma"},
|
||||
},
|
||||
{
|
||||
__typename: "Feline",
|
||||
id: "gamma",
|
||||
only: {__typename: "Feline", id: "beta"},
|
||||
lynx: null,
|
||||
},
|
||||
]);
|
||||
const result: Feline = (mirror.extract("alpha"): any);
|
||||
expect(result).toEqual({
|
||||
id: "alpha",
|
||||
only: null,
|
||||
lynx: {
|
||||
id: "beta",
|
||||
only: result.lynx,
|
||||
lynx: {
|
||||
id: "gamma",
|
||||
only: result.lynx,
|
||||
lynx: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect((result: any).lynx.only).toBe(result.lynx);
|
||||
expect((result: any).lynx.lynx.only).toBe(result.lynx);
|
||||
});
|
||||
|
||||
it("handles cyclic connection structures", () => {
|
||||
const db = new Database(":memory:");
|
||||
const mirror = new Mirror(db, buildTestSchema());
|
||||
mirror.registerObject({typename: "Socket", id: "localhost:8080"});
|
||||
mirror.registerObject({typename: "Socket", id: "localhost:7070"});
|
||||
mirror.registerObject({typename: "Socket", id: "localhost:6060"});
|
||||
const updateId = mirror._createUpdate(new Date(123));
|
||||
mirror._updateOwnData(updateId, [
|
||||
{__typename: "Socket", id: "localhost:8080"},
|
||||
{__typename: "Socket", id: "localhost:7070"},
|
||||
{__typename: "Socket", id: "localhost:6060"},
|
||||
]);
|
||||
const updateConnection = (
|
||||
objectId: Schema.ObjectId,
|
||||
fieldname: Schema.Fieldname,
|
||||
ids: $ReadOnlyArray<Schema.ObjectId>
|
||||
) => {
|
||||
mirror._updateConnection(updateId, objectId, fieldname, {
|
||||
totalCount: ids.length,
|
||||
pageInfo: {hasNextPage: false, endCursor: String(ids.length)},
|
||||
nodes: ids.map((id) => ({__typename: "Socket", id})),
|
||||
});
|
||||
};
|
||||
updateConnection("localhost:8080", "only", []);
|
||||
updateConnection("localhost:7070", "only", []);
|
||||
updateConnection("localhost:6060", "only", []);
|
||||
updateConnection("localhost:8080", "connections", ["localhost:7070"]);
|
||||
updateConnection("localhost:7070", "connections", [
|
||||
"localhost:8080",
|
||||
"localhost:7070",
|
||||
"localhost:6060",
|
||||
]);
|
||||
updateConnection("localhost:6060", "connections", ["localhost:7070"]);
|
||||
const result: Socket = (mirror.extract("localhost:8080"): any);
|
||||
expect(result).toEqual({
|
||||
id: "localhost:8080",
|
||||
only: [],
|
||||
connections: [
|
||||
{
|
||||
id: "localhost:7070",
|
||||
only: [],
|
||||
connections: [
|
||||
result,
|
||||
result.connections[0],
|
||||
{
|
||||
id: "localhost:6060",
|
||||
only: [],
|
||||
connections: [result.connections[0]],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const s8080: Socket = result;
|
||||
const s7070: Socket = ((s8080.connections[0]: Socket | null): any);
|
||||
const s6060: Socket = ((s7070.connections[2]: Socket | null): any);
|
||||
expect(s7070.connections[0]).toBe(s8080);
|
||||
expect(s7070.connections[1]).toBe(s7070);
|
||||
expect(s7070.connections[2]).toBe(s6060);
|
||||
expect(s6060.connections[0]).toBe(s7070);
|
||||
});
|
||||
|
||||
it("handles connections with null and repeated values", () => {
|
||||
const db = new Database(":memory:");
|
||||
const mirror = new Mirror(db, buildTestSchema());
|
||||
mirror.registerObject({typename: "Socket", id: "localhost"});
|
||||
const updateId = mirror._createUpdate(new Date(123));
|
||||
mirror._updateOwnData(updateId, [
|
||||
{__typename: "Socket", id: "localhost"},
|
||||
]);
|
||||
mirror._updateConnection(updateId, "localhost", "only", {
|
||||
totalCount: 0,
|
||||
pageInfo: {hasNextPage: false, endCursor: null},
|
||||
nodes: [],
|
||||
});
|
||||
mirror._updateConnection(updateId, "localhost", "connections", {
|
||||
totalCount: 6,
|
||||
pageInfo: {hasNextPage: false, endCursor: "#6"},
|
||||
nodes: [
|
||||
null,
|
||||
{__typename: "Socket", id: "localhost"},
|
||||
null,
|
||||
{__typename: "Socket", id: "localhost"},
|
||||
{__typename: "Socket", id: "localhost"},
|
||||
null,
|
||||
],
|
||||
});
|
||||
const result: Socket = (mirror.extract("localhost"): any);
|
||||
expect(result).toEqual({
|
||||
id: "localhost",
|
||||
only: [],
|
||||
connections: [null, result, null, result, result, null],
|
||||
});
|
||||
expect(result.connections[1]).toBe(result);
|
||||
expect(result.connections[3]).toBe(result);
|
||||
expect(result.connections[4]).toBe(result);
|
||||
});
|
||||
|
||||
it("handles a representative normal case", () => {
|
||||
// In this test case, we have:
|
||||
//
|
||||
// - objects that are not relevant
|
||||
// - object types with no relevant instances
|
||||
// - object types with no instances at all
|
||||
// - relevant objects that are not direct dependencies
|
||||
// - relevant objects with cyclic links and connections
|
||||
// - relevant objects with only primitive fields
|
||||
// - relevant objects with empty connections
|
||||
// - relevant objects with links pointing to `null`
|
||||
// - relevant objects with links of union type
|
||||
//
|
||||
// (An object is "relevant" if it is a transitive dependency of
|
||||
// the root.)
|
||||
const db = new Database(":memory:");
|
||||
const mirror = new Mirror(db, buildGithubSchema());
|
||||
|
||||
const objects = {
|
||||
repo: () => ({typename: "Repository", id: "repo:foo/bar"}),
|
||||
issue1: () => ({typename: "Issue", id: "issue:#1"}),
|
||||
issue2: () => ({typename: "Issue", id: "issue:#2"}),
|
||||
issue3: () => ({typename: "Issue", id: "issue:#3"}),
|
||||
alice: () => ({typename: "User", id: "user:alice"}),
|
||||
bob: () => ({typename: "User", id: "user:bob"}),
|
||||
ethereal: () => ({typename: "User", id: "user:ethereal"}),
|
||||
nobody: () => ({typename: "User", id: "user:nobody"}),
|
||||
noboty: () => ({typename: "Bot", id: "bot:noboty"}),
|
||||
comment1: () => ({typename: "IssueComment", id: "comment:#2.1"}),
|
||||
comment2: () => ({typename: "IssueComment", id: "comment:#2.2"}),
|
||||
closedEvent: () => ({
|
||||
typename: "ClosedEvent",
|
||||
id: "issue:#2!closed#0",
|
||||
}),
|
||||
};
|
||||
const asNode = ({typename, id}) => ({__typename: typename, id});
|
||||
|
||||
const update1 = mirror._createUpdate(new Date(123));
|
||||
const update2 = mirror._createUpdate(new Date(234));
|
||||
const update3 = mirror._createUpdate(new Date(345));
|
||||
|
||||
const emptyConnection = () => ({
|
||||
totalCount: 0,
|
||||
pageInfo: {
|
||||
endCursor: null,
|
||||
hasNextPage: false,
|
||||
},
|
||||
nodes: [],
|
||||
});
|
||||
|
||||
// Update #1: Own data for the repository and issues #1 and #2
|
||||
// and their authors. Connection data for issue #1 as a child of
|
||||
// the repository, but not issue #2. No comments on any issue.
|
||||
mirror.registerObject(objects.repo());
|
||||
mirror.registerObject(objects.issue1());
|
||||
mirror.registerObject(objects.issue2());
|
||||
mirror.registerObject(objects.alice());
|
||||
mirror.registerObject(objects.ethereal());
|
||||
mirror._updateOwnData(update1, [
|
||||
{
|
||||
...asNode(objects.repo()),
|
||||
url: "url://foo/bar",
|
||||
},
|
||||
]);
|
||||
mirror._updateOwnData(update1, [
|
||||
{
|
||||
...asNode(objects.issue1()),
|
||||
url: "url://issue/1",
|
||||
author: asNode(objects.alice()),
|
||||
repository: asNode(objects.repo()),
|
||||
title: "this project looks dead; let's make some issues",
|
||||
},
|
||||
{
|
||||
...asNode(objects.issue2()),
|
||||
url: "url://issue/2",
|
||||
author: asNode(objects.ethereal()),
|
||||
repository: asNode(objects.repo()),
|
||||
title: "by the time you read this, I will have deleted my account",
|
||||
},
|
||||
// issue:#3 remains unloaded
|
||||
]);
|
||||
mirror._updateOwnData(update1, [
|
||||
{
|
||||
...asNode(objects.alice()),
|
||||
url: "url://alice",
|
||||
login: "alice",
|
||||
},
|
||||
{
|
||||
...asNode(objects.ethereal()),
|
||||
login: "ethereal",
|
||||
url: "url://ethereal",
|
||||
},
|
||||
// "nobody" and "noboty" remain unloaded
|
||||
]);
|
||||
mirror._updateConnection(update1, objects.repo().id, "issues", {
|
||||
totalCount: 2,
|
||||
pageInfo: {
|
||||
endCursor: "cursor:repo:foo/bar.issues@update1",
|
||||
hasNextPage: true,
|
||||
},
|
||||
nodes: [asNode(objects.issue1())],
|
||||
});
|
||||
mirror._updateConnection(
|
||||
update1,
|
||||
objects.issue1().id,
|
||||
"comments",
|
||||
emptyConnection()
|
||||
);
|
||||
mirror._updateConnection(
|
||||
update1,
|
||||
objects.issue1().id,
|
||||
"timeline",
|
||||
emptyConnection()
|
||||
);
|
||||
mirror._updateConnection(
|
||||
update1,
|
||||
objects.issue2().id,
|
||||
"comments",
|
||||
emptyConnection()
|
||||
);
|
||||
mirror._updateConnection(
|
||||
update1,
|
||||
objects.issue2().id,
|
||||
"timeline",
|
||||
emptyConnection()
|
||||
);
|
||||
|
||||
// Update #2: Issue #2 author changes to `null`. Alice posts a
|
||||
// comment on issue #2 and closes it. Issue #2 is loaded as a
|
||||
// child of the repository.
|
||||
mirror.registerObject(objects.comment1());
|
||||
mirror._updateOwnData(update2, [
|
||||
{
|
||||
...asNode(objects.issue2()),
|
||||
url: "url://issue/2",
|
||||
author: null,
|
||||
repository: asNode(objects.repo()),
|
||||
title: "by the time you read this, I will have deleted my account",
|
||||
},
|
||||
// issue:#3 remains unloaded
|
||||
]);
|
||||
mirror._updateOwnData(update2, [
|
||||
{
|
||||
...asNode(objects.comment1()),
|
||||
body: "cya",
|
||||
author: asNode(objects.alice()),
|
||||
},
|
||||
]);
|
||||
mirror._updateConnection(update2, objects.repo().id, "issues", {
|
||||
totalCount: 2,
|
||||
pageInfo: {
|
||||
endCursor: "cursor:repo:foo/bar.issues@update2",
|
||||
hasNextPage: true,
|
||||
},
|
||||
nodes: [asNode(objects.issue2())],
|
||||
});
|
||||
mirror._updateConnection(update2, objects.issue2().id, "comments", {
|
||||
totalCount: 1,
|
||||
pageInfo: {
|
||||
endCursor: "cursor:issue:#2.comments@update2",
|
||||
hasNextPage: false,
|
||||
},
|
||||
nodes: [asNode(objects.comment1())],
|
||||
});
|
||||
mirror._updateConnection(update2, objects.issue2().id, "timeline", {
|
||||
totalCount: 1,
|
||||
pageInfo: {
|
||||
endCursor: "cursor:issue:#2.timeline@update2",
|
||||
hasNextPage: false,
|
||||
},
|
||||
nodes: [asNode(objects.closedEvent())],
|
||||
});
|
||||
|
||||
// Update #3: Bob comments on issue #2. An issue #3 is created
|
||||
// but not yet added to the repository connection. The details
|
||||
// for the closed event are fetched.
|
||||
mirror.registerObject(objects.bob());
|
||||
mirror.registerObject(objects.comment2());
|
||||
mirror.registerObject(objects.issue3());
|
||||
mirror._updateOwnData(update3, [
|
||||
{
|
||||
...asNode(objects.bob()),
|
||||
url: "url://bob",
|
||||
login: "bob",
|
||||
},
|
||||
]);
|
||||
mirror._updateOwnData(update3, [
|
||||
{
|
||||
...asNode(objects.comment2()),
|
||||
body: "alas, I did not know them well",
|
||||
author: asNode(objects.bob()),
|
||||
},
|
||||
]);
|
||||
mirror._updateOwnData(update3, [
|
||||
{
|
||||
...asNode(objects.issue3()),
|
||||
url: "url://issue/3",
|
||||
author: asNode(objects.bob()),
|
||||
repository: asNode(objects.repo()),
|
||||
title: "duly responding to the call for spurious issues",
|
||||
},
|
||||
]);
|
||||
mirror._updateOwnData(update3, [
|
||||
{
|
||||
...asNode(objects.closedEvent()),
|
||||
actor: asNode(objects.alice()),
|
||||
},
|
||||
]);
|
||||
mirror._updateConnection(update3, objects.issue2().id, "comments", {
|
||||
totalCount: 2,
|
||||
pageInfo: {
|
||||
endCursor: "cursor:issue:#2.comments@update3",
|
||||
hasNextPage: false,
|
||||
},
|
||||
nodes: [asNode(objects.comment2())],
|
||||
});
|
||||
|
||||
// The following entities are never referenced...
|
||||
mirror.registerObject(objects.nobody());
|
||||
mirror.registerObject(objects.noboty());
|
||||
mirror.registerObject(objects.issue3());
|
||||
|
||||
const result = mirror.extract("repo:foo/bar");
|
||||
expect(result).toEqual({
|
||||
id: "repo:foo/bar",
|
||||
url: "url://foo/bar",
|
||||
issues: [
|
||||
{
|
||||
id: "issue:#1",
|
||||
url: "url://issue/1",
|
||||
author: {
|
||||
id: "user:alice",
|
||||
url: "url://alice",
|
||||
login: "alice",
|
||||
},
|
||||
repository: result, // circular
|
||||
title: "this project looks dead; let's make some issues",
|
||||
comments: [],
|
||||
timeline: [],
|
||||
},
|
||||
{
|
||||
id: "issue:#2",
|
||||
url: "url://issue/2",
|
||||
author: null,
|
||||
repository: result, // circular
|
||||
title:
|
||||
"by the time you read this, I will have deleted my account",
|
||||
comments: [
|
||||
{
|
||||
id: "comment:#2.1",
|
||||
body: "cya",
|
||||
author: {
|
||||
id: "user:alice",
|
||||
url: "url://alice",
|
||||
login: "alice",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "comment:#2.2",
|
||||
body: "alas, I did not know them well",
|
||||
author: {
|
||||
id: "user:bob",
|
||||
url: "url://bob",
|
||||
login: "bob",
|
||||
},
|
||||
},
|
||||
],
|
||||
timeline: [
|
||||
{
|
||||
id: "issue:#2!closed#0",
|
||||
actor: {
|
||||
id: "user:alice",
|
||||
url: "url://alice",
|
||||
login: "alice",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("_buildSchemaInfo", () => {
|
||||
|
|
Loading…
Reference in New Issue