mirror: initialize a GraphQL database mirror (#847)

Summary:
This commit introduces the `Mirror` class that will be the centerpiece
of the persistent-loading API as described in #622. An instance of this
class represents a mirror of a remote GraphQL database, defined by a
particular schema. In this commit, we add the construction logic, which
includes a safety measure to ensure that the database is used within one
version of the code and schema.

Test Plan:
Unit tests included, with full coverage; run `yarn unit`.

wchargin-branch: mirror-class
This commit is contained in:
William Chargin 2018-09-17 13:53:08 -07:00 committed by GitHub
parent 62d3c180ee
commit a93ad80ebc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 174 additions and 1 deletions

View File

@ -1,6 +1,70 @@
// @flow
import type Database from "better-sqlite3";
import stringify from "json-stable-stringify";
import * as Schema from "./schema";
/**
* A local mirror of a subset of a GraphQL database.
*/
export class Mirror {
+_db: Database;
+_schema: Schema.Schema;
/**
* Create a GraphQL mirror using the given database connection and
* GraphQL schema.
*
* 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
* an identical GraphQL schema. The database attached to the
* connection must not be modified by any other clients. In other
* words, passing a connection to this constructor entails transferring
* ownership of the attached database to this module.
*
* If the database attached to the connection has been used with an
* incompatible GraphQL schema or an outdated version of this module,
* an error will be thrown and the database will remain unmodified.
*/
constructor(db: Database, schema: Schema.Schema): void {
if (db == null) throw new Error("db: " + String(db));
if (schema == null) throw new Error("schema: " + String(schema));
this._db = db;
this._schema = schema;
this._initialize();
}
_initialize() {
// The following version number must be updated if there is any
// change to the way in which a GraphQL schema is mapped to a SQL
// schema or the way in which the resulting SQL schema is
// interpreted. If you've made a change and you're not sure whether
// it requires bumping the version, bump it: requiring some extra
// one-time cache resets is okay; doing the wrong thing is not.
const blob = stringify({version: "MIRROR_v1", schema: this._schema});
// We store the metadata in a singleton table `meta`, whose unique row
// has primary key `0`. Only the first ever insert will succeed; we
// are locked into the first schema.
this._db
.prepare(
"CREATE TABLE IF NOT EXISTS meta\n" +
"(zero INTEGER PRIMARY KEY, schema TEXT NOT NULL)"
)
.run();
this._db
.prepare("INSERT OR IGNORE INTO meta (zero, schema) VALUES (0, ?)")
.run(blob);
const result = this._db
.prepare("SELECT COUNT(1) AS n FROM meta WHERE schema = ?")
.get(blob);
if (result.n !== 1) {
throw new Error(
"Database already populated with incompatible schema or version"
);
}
}
}
/**
* Execute a function inside a database transaction.

View File

@ -1,11 +1,120 @@
// @flow
import Database from "better-sqlite3";
import fs from "fs";
import tmp from "tmp";
import {_inTransaction} from "./mirror";
import * as Schema from "./schema";
import {_inTransaction, Mirror} from "./mirror";
describe("graphql/mirror", () => {
function buildGithubSchema(): Schema.Schema {
const s = Schema;
return s.schema({
Repository: s.object({
id: s.id(),
url: s.primitive(),
issues: s.connection("Issue"),
}),
Issue: s.object({
id: s.id(),
url: s.primitive(),
author: s.node("Actor"),
parent: s.node("Repository"),
title: s.primitive(),
comments: s.connection("IssueComment"),
}),
IssueComment: s.object({
id: s.id(),
body: s.primitive(),
author: s.node("Actor"),
}),
Actor: s.union(["User", "Bot", "Organization"]), // actually an interface
User: s.object({
id: s.id(),
url: s.primitive(),
login: s.primitive(),
}),
Bot: s.object({
id: s.id(),
url: s.primitive(),
login: s.primitive(),
}),
Organization: s.object({
id: s.id(),
url: s.primitive(),
login: s.primitive(),
}),
});
}
describe("Mirror", () => {
describe("constructor", () => {
it("initializes a new database successfully", () => {
const db = new Database(":memory:");
const schema = buildGithubSchema();
expect(() => new Mirror(db, schema)).not.toThrow();
});
it("fails if the database connection is `null`", () => {
// $ExpectFlowError
expect(() => new Mirror(null, buildGithubSchema())).toThrow("db: null");
});
it("fails if the schema is `null`", () => {
// $ExpectFlowError
expect(() => new Mirror(new Database(":memory:"), null)).toThrow(
"schema: null"
);
});
it("is idempotent", () => {
// We use an on-disk database file here so that we can dump the
// contents to ensure that the database is physically unchanged.
const filename = tmp.fileSync().name;
const schema = buildGithubSchema();
const db0 = new Database(filename);
new Mirror(db0, schema);
db0.close();
const data0 = fs.readFileSync(filename).toJSON();
const db1 = new Database(filename);
new Mirror(db1, schema);
db1.close();
const data1 = fs.readFileSync(filename).toJSON();
expect(data0).toEqual(data1);
});
it("rejects a different schema without changing the database", () => {
const s = Schema;
const schema0 = s.schema({A: s.object({id: s.id()})});
const schema1 = s.schema({B: s.object({id: s.id()})});
// We use an on-disk database file here so that we can dump the
// contents to ensure that the database is physically unchanged.
const filename = tmp.fileSync().name;
const db = new Database(filename);
expect(() => new Mirror(db, schema0)).not.toThrow();
const data = fs.readFileSync(filename).toJSON();
expect(() => new Mirror(db, schema1)).toThrow(
"incompatible schema or version"
);
expect(fs.readFileSync(filename).toJSON()).toEqual(data);
expect(() => new Mirror(db, schema1)).toThrow(
"incompatible schema or version"
);
expect(fs.readFileSync(filename).toJSON()).toEqual(data);
expect(() => new Mirror(db, schema0)).not.toThrow();
expect(fs.readFileSync(filename).toJSON()).toEqual(data);
});
});
});
describe("_inTransaction", () => {
it("runs its callback inside a transaction", () => {
// We use an on-disk database file here because we need to open