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:
parent
bf35161d87
commit
fe50ca83f6
|
@ -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);
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue