mirror: allow blacklisting objects by ID (#972)

Summary:
This enables us to deal with GraphQL remotes that violate their contract
guarantees and provide a node of the wrong type. An instance in which
the GitHub GraphQL API does this is documented here:
<https://gist.github.com/wchargin/a2b8561b81bcc932c84e493d2485ea8a>

A Mirror cache is only valid for a fixed set of blacklisted IDs. This is
necessary to ensure consistency. If the set of blacklisted IDs changes,
simply remove the existing database and download again from scratch.

Test Plan:
Unit tests added, with full coverage.

wchargin-branch: mirror-blacklist
This commit is contained in:
William Chargin 2018-11-01 11:04:49 -07:00 committed by GitHub
parent bf35161d87
commit fe50ca83f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 156 additions and 13 deletions

View File

@ -32,6 +32,7 @@ export class Mirror {
+_db: Database; +_db: Database;
+_schema: Schema.Schema; +_schema: Schema.Schema;
+_schemaInfo: SchemaInfo; +_schemaInfo: SchemaInfo;
+_blacklistedIds: {|+[Schema.ObjectId]: true|};
/** /**
* Create a GraphQL mirror using the given database connection and * Create a GraphQL mirror using the given database connection and
@ -39,21 +40,41 @@ export class Mirror {
* *
* The connection must be to a database that either (a) is empty and * The connection must be to a database that either (a) is empty and
* unused, or (b) has been previously used for a GraphQL mirror with * unused, or (b) has been previously used for a GraphQL mirror with
* an identical GraphQL schema. The database attached to the * an identical GraphQL schema and options. The database attached to
* connection must not be modified by any other clients. In other * the connection must not be modified by any other clients. In other
* words, passing a connection to this constructor entails transferring * words, passing a connection to this constructor entails
* ownership of the attached database to this module. * transferring ownership of the attached database to this module.
* *
* If the database attached to the connection has been used with an * If the database attached to the connection has been used with an
* incompatible GraphQL schema or an outdated version of this module, * incompatible GraphQL schema or an outdated version of this module,
* an error will be thrown and the database will remain unmodified. * an error will be thrown and the database will remain unmodified.
*
* If a list of blacklisted IDs is provided, then any object with
* matching ID will be treated as `null` when it appears as the target
* of a node reference, nested node reference, or connection entry.
* This can be used to work around bugs in remote schemas.
*/ */
constructor(db: Database, schema: Schema.Schema): void { constructor(
db: Database,
schema: Schema.Schema,
options?: {|+blacklistedIds?: $ReadOnlyArray<Schema.ObjectId>|}
): void {
if (db == null) throw new Error("db: " + String(db)); if (db == null) throw new Error("db: " + String(db));
if (schema == null) throw new Error("schema: " + String(schema)); if (schema == null) throw new Error("schema: " + String(schema));
const fullOptions = {
...{blacklistedIds: []},
...(options || {}),
};
this._db = db; this._db = db;
this._schema = schema; this._schema = schema;
this._schemaInfo = _buildSchemaInfo(this._schema); this._schemaInfo = _buildSchemaInfo(this._schema);
this._blacklistedIds = (() => {
const result: {|[Schema.ObjectId]: true|} = ({}: any);
for (const id of fullOptions.blacklistedIds) {
result[id] = true;
}
return result;
})();
this._initialize(); this._initialize();
} }
@ -153,23 +174,29 @@ export class Mirror {
// interpreted. If you've made a change and you're not sure whether // interpreted. If you've made a change and you're not sure whether
// it requires bumping the version, bump it: requiring some extra // it requires bumping the version, bump it: requiring some extra
// one-time cache resets is okay; doing the wrong thing is not. // one-time cache resets is okay; doing the wrong thing is not.
const blob = stringify({version: "MIRROR_v2", schema: this._schema}); const blob = stringify({
version: "MIRROR_v3",
schema: this._schema,
options: {
blacklistedIds: this._blacklistedIds,
},
});
const db = this._db; const db = this._db;
_inTransaction(db, () => { _inTransaction(db, () => {
// We store the metadata in a singleton table `meta`, whose unique row // We store the metadata in a singleton table `meta`, whose unique row
// has primary key `0`. Only the first ever insert will succeed; we // has primary key `0`. Only the first ever insert will succeed; we
// are locked into the first schema. // are locked into the first config.
db.prepare( db.prepare(
dedent`\ dedent`\
CREATE TABLE IF NOT EXISTS meta ( CREATE TABLE IF NOT EXISTS meta (
zero INTEGER PRIMARY KEY, zero INTEGER PRIMARY KEY,
schema TEXT NOT NULL config TEXT NOT NULL
) )
` `
).run(); ).run();
const existingBlob: string | void = db const existingBlob: string | void = db
.prepare("SELECT schema FROM meta") .prepare("SELECT config FROM meta")
.pluck() .pluck()
.get(); .get();
if (existingBlob === blob) { if (existingBlob === blob) {
@ -177,10 +204,11 @@ export class Mirror {
return; return;
} else if (existingBlob !== undefined) { } else if (existingBlob !== undefined) {
throw new Error( throw new Error(
"Database already populated with incompatible schema or version" "Database already populated with " +
"incompatible schema, options, or version"
); );
} }
db.prepare("INSERT INTO meta (zero, schema) VALUES (0, ?)").run(blob); db.prepare("INSERT INTO meta (zero, config) VALUES (0, ?)").run(blob);
// First, create those tables that are independent of the schema. // First, create those tables that are independent of the schema.
const structuralTables = [ const structuralTables = [
@ -450,6 +478,8 @@ export class Mirror {
): Schema.ObjectId | null { ): Schema.ObjectId | null {
if (result == null) { if (result == null) {
return null; return null;
} else if (this._blacklistedIds[result.id]) {
return null;
} else { } else {
const object = {typename: result.__typename, id: result.id}; const object = {typename: result.__typename, id: result.id};
this._nontransactionallyRegisterObject(object); this._nontransactionallyRegisterObject(object);

View File

@ -187,12 +187,12 @@ describe("graphql/mirror", () => {
const data = fs.readFileSync(filename).toJSON(); const data = fs.readFileSync(filename).toJSON();
expect(() => new Mirror(db, schema1)).toThrow( expect(() => new Mirror(db, schema1)).toThrow(
"incompatible schema or version" "incompatible schema, options, or version"
); );
expect(fs.readFileSync(filename).toJSON()).toEqual(data); expect(fs.readFileSync(filename).toJSON()).toEqual(data);
expect(() => new Mirror(db, schema1)).toThrow( expect(() => new Mirror(db, schema1)).toThrow(
"incompatible schema or version" "incompatible schema, options, or version"
); );
expect(fs.readFileSync(filename).toJSON()).toEqual(data); expect(fs.readFileSync(filename).toJSON()).toEqual(data);
@ -200,6 +200,15 @@ describe("graphql/mirror", () => {
expect(fs.readFileSync(filename).toJSON()).toEqual(data); expect(fs.readFileSync(filename).toJSON()).toEqual(data);
}); });
it("rejects when the set of blacklisted IDs has changed", () => {
const db = new Database(":memory:");
const schema = Schema.schema({A: Schema.object({id: Schema.id()})});
new Mirror(db, schema, {blacklistedIds: []});
expect(() => {
new Mirror(db, schema, {blacklistedIds: ["ominous"]});
}).toThrow("incompatible schema, options, or version");
});
it("rejects a schema with SQL-unsafe type name", () => { it("rejects a schema with SQL-unsafe type name", () => {
const s = Schema; const s = Schema;
const schema0 = s.schema({ const schema0 = s.schema({
@ -1571,6 +1580,64 @@ describe("graphql/mirror", () => {
); );
}).toThrow("FOREIGN KEY constraint failed"); }).toThrow("FOREIGN KEY constraint failed");
}); });
it("omits connection entries corresponding to blacklisted IDs", () => {
const db = new Database(":memory:");
const mirror = new Mirror(db, buildGithubSchema(), {
blacklistedIds: ["ominous"],
});
const updateId = mirror._createUpdate(new Date(123));
mirror.registerObject({typename: "Repository", id: "repo"});
mirror._updateConnection(updateId, "repo", "issues", {
totalCount: 3,
nodes: [
{
__typename: "Issue",
id: "normal",
},
{
__typename: "Issue",
id: "ominous",
},
{
__typename: "Issue",
id: "pristine",
},
],
pageInfo: {
hasNextPage: false,
endCursor: "cursor",
},
});
expect(
db
.prepare(
"SELECT * FROM connection_entries ORDER BY connection_id, idx"
)
.all()
).toEqual([
{
rowid: expect.anything(),
connection_id: expect.anything(),
idx: 1,
child_id: "normal",
},
{
rowid: expect.anything(),
connection_id: expect.anything(),
idx: 2,
child_id: null,
},
{
rowid: expect.anything(),
connection_id: expect.anything(),
idx: 3,
child_id: "pristine",
},
]);
expect(() => {
mirror.extract("ominous");
}).toThrow('No such object: "ominous"');
});
it("properly updates under various circumstances", () => { it("properly updates under various circumstances", () => {
const db = new Database(":memory:"); const db = new Database(":memory:");
const mirror = new Mirror(db, buildGithubSchema()); const mirror = new Mirror(db, buildGithubSchema());
@ -2183,6 +2250,52 @@ describe("graphql/mirror", () => {
const post = getState(); const post = getState();
expect(post).toEqual(pre); expect(post).toEqual(pre);
}); });
it("omits node references corresponding to blacklisted IDs", () => {
const db = new Database(":memory:");
const mirror = new Mirror(db, buildGithubSchema(), {
blacklistedIds: ["ominous"],
});
const updateId = mirror._createUpdate(new Date(123));
mirror.registerObject({typename: "IssueComment", id: "comment"});
mirror.registerObject({typename: "Commit", id: "commit"});
mirror._updateOwnData(updateId, [
{
__typename: "IssueComment",
id: "comment",
body: "hmm",
author: {__typename: "User", id: "ominous"},
},
]);
mirror._updateOwnData(updateId, [
{
__typename: "Commit",
id: "commit",
oid: "deadbeef",
author: {
date: "today",
user: {__typename: "User", id: "ominous"},
},
},
]);
expect(mirror.extract("comment")).toEqual({
__typename: "IssueComment",
id: "comment",
body: "hmm",
author: null,
});
expect(mirror.extract("commit")).toEqual({
__typename: "Commit",
id: "commit",
oid: "deadbeef",
author: {
date: "today",
user: null,
},
});
expect(() => {
mirror.extract("ominous");
}).toThrow('No such object: "ominous"');
});
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 can copy-and-paste the snapshot into