mirror: add EAV reading to `extract`, behind flag (#1343)
Summary: This completes the end-to-end EAV mode pipeline, but does not yet set it as default or use it in production. A note about indentation: we take care to avoid reindenting the entire block of `extract` test cases, which is over 900 lines long. As to the implementation code, reindenting the legacy type-specific primitives branch is not easily avoidable, but when we remove that branch we won’t have to reindent the EAV mode branch: we can replace its `if` block with two scope blocks (which is the right thing to do, anyway). Test Plan: We reuse existing tests, which suffice for full coverage in both implementation branches. Note that these tests cover the case of object types with no primitive fields (the `Feline` and `Socket` types), which are more likely to fail in a broken EAV implementation than in a broken type-specific primitives implementation due to deletion anomalies. To check that all relevant calls to `mirror.extract(…)` have been properly replaced with `extract(mirror, …)`, run yarn coverage -f graphql/mirror -t 'EAV primitives' and note that the “else” path of the `if (fullOptions.useEavPrimitives)` branch is not taken; then, run yarn coverage -f graphql/mirror -t 'legacy type-specific primitives' and note that the “if” path of the same branch is not taken. To check that the table hiding logic is working, invert the branch that checks `if (fullOptions.useEavPrimitives)`, and note that every test case using the table hiding logic fails (except for some of the error handling test cases, which do not actually need to read primitive data). Finally, `yarn test --full` passes after flipping the `useEavPrimitives` default to `true`. wchargin-branch: mirror-eav-extract
This commit is contained in:
parent
e1a73ac368
commit
e5a77488de
|
@ -1636,8 +1636,19 @@ export class Mirror {
|
|||
* 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.
|
||||
*
|
||||
* The `options` argument to `extract` has no stability guarantees and
|
||||
* may be removed or changed at any time. For information about its
|
||||
* semantics, read the current source code.
|
||||
*/
|
||||
extract(rootId: Schema.ObjectId): mixed {
|
||||
extract(
|
||||
rootId: Schema.ObjectId,
|
||||
options?: {|+useEavPrimitives?: boolean|}
|
||||
): mixed {
|
||||
const fullOptions = {
|
||||
...{useEavPrimitives: false},
|
||||
...(options || {}),
|
||||
};
|
||||
const db = this._db;
|
||||
return _inTransaction(db, () => {
|
||||
// We'll compute the transitive dependencies and store them into a
|
||||
|
@ -1726,81 +1737,55 @@ export class Mirror {
|
|||
// 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 and typename. Then, we link in node references
|
||||
// and connection entries.
|
||||
// typename and ID (and, in legacy non-EAV mode, all primitive
|
||||
// data). Then, we link in primitives (except in legacy non-EAV
|
||||
// mode), 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: $ReadOnlyArray<string> = [].concat(
|
||||
[`${primitivesTableName}.id AS id`],
|
||||
objectType.primitiveFieldNames.map(
|
||||
(fieldname) =>
|
||||
`${primitivesTableName}."${fieldname}" AS "${fieldname}"`
|
||||
),
|
||||
objectType.nestedFieldNames.map(
|
||||
(fieldname) =>
|
||||
`${primitivesTableName}."${fieldname}" AS "${fieldname}"`
|
||||
),
|
||||
...objectType.nestedFieldNames.map((f1) =>
|
||||
Object.keys(objectType.nestedFields[f1].primitives).map(
|
||||
(f2) => `${primitivesTableName}."${f1}.${f2}" AS "${f1}.${f2}"`
|
||||
)
|
||||
)
|
||||
if (fullOptions.useEavPrimitives) {
|
||||
// Initialize `allObjects`.
|
||||
const getObjects = db.prepare(
|
||||
`SELECT id AS id, typename AS typename FROM ${temporaryTableName}`
|
||||
);
|
||||
const rows: $ReadOnlyArray<{|
|
||||
+id: Schema.ObjectId,
|
||||
+[Schema.Fieldname]: string | 0 | 1,
|
||||
|}> = db
|
||||
.prepare(
|
||||
dedent`\
|
||||
SELECT ${selections.join(", ")}
|
||||
FROM ${temporaryTableName} JOIN ${primitivesTableName}
|
||||
USING (id)
|
||||
`
|
||||
)
|
||||
.all();
|
||||
for (const row of rows) {
|
||||
const object = {};
|
||||
object.id = row.id;
|
||||
object.__typename = typename;
|
||||
for (const fieldname of objectType.nestedFieldNames) {
|
||||
const isPresent: string | 0 | 1 = row[fieldname];
|
||||
// istanbul ignore if: should not be reachable
|
||||
if (isPresent !== 0 && isPresent !== 1) {
|
||||
const s = JSON.stringify;
|
||||
const id = object.id;
|
||||
throw new Error(
|
||||
`Corruption: nested field ${s(fieldname)} on ${s(id)} ` +
|
||||
`set to ${String(isPresent)}`
|
||||
);
|
||||
}
|
||||
if (isPresent) {
|
||||
// We'll add primitives and links onto this object.
|
||||
object[fieldname] = {};
|
||||
} else {
|
||||
object[fieldname] = null;
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(row)) {
|
||||
if (key === "id") continue;
|
||||
const rawValue = row[key];
|
||||
if (rawValue === 0 || rawValue === 1) {
|
||||
// Name of a nested field; already processed.
|
||||
continue;
|
||||
}
|
||||
const value = JSON.parse(rawValue);
|
||||
const parts = key.split(".");
|
||||
for (const object of getObjects.iterate()) {
|
||||
allObjects.set(object.id, {
|
||||
id: object.id,
|
||||
__typename: object.typename,
|
||||
});
|
||||
}
|
||||
|
||||
// Fill in primitive data.
|
||||
const getPrimitives = db.prepare(
|
||||
dedent`\
|
||||
SELECT
|
||||
object_id AS objectId,
|
||||
fieldname AS name,
|
||||
value AS value
|
||||
FROM ${temporaryTableName} JOIN primitives
|
||||
ON ${temporaryTableName}.id = primitives.object_id
|
||||
-- Order by field name to ensure that nested fields appear
|
||||
-- before their eggs: e.g., "author" before "author.user".
|
||||
ORDER BY fieldname ASC
|
||||
`
|
||||
);
|
||||
for (const field: {|
|
||||
+objectId: Schema.ObjectId,
|
||||
+name: Schema.Fieldname,
|
||||
+value: string | 0 | 1,
|
||||
|} of getPrimitives.iterate()) {
|
||||
const object = NullUtil.get(allObjects.get(field.objectId));
|
||||
if (field.value === 0) {
|
||||
// Nested object is absent.
|
||||
object[field.name] = null;
|
||||
} else if (field.value === 1) {
|
||||
// Nested object is present.
|
||||
object[field.name] = {};
|
||||
} else {
|
||||
// Normal field, not nested object indicator.
|
||||
const value = JSON.parse(field.value);
|
||||
const parts = field.name.split(".");
|
||||
switch (parts.length) {
|
||||
case 1:
|
||||
object[key] = value;
|
||||
object[field.name] = value;
|
||||
break;
|
||||
case 2: {
|
||||
const [nestName, eggName] = parts;
|
||||
|
@ -1812,11 +1797,103 @@ export class Mirror {
|
|||
// istanbul ignore next: should not be possible
|
||||
default:
|
||||
throw new Error(
|
||||
`Corruption: bad field name: ${JSON.stringify(key)}`
|
||||
`Corruption: bad field name: ${JSON.stringify(field.name)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
allObjects.set(object.id, object);
|
||||
}
|
||||
} else {
|
||||
// Legacy non-EAV mode.
|
||||
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: $ReadOnlyArray<string> = [].concat(
|
||||
[`${primitivesTableName}.id AS id`],
|
||||
objectType.primitiveFieldNames.map(
|
||||
(fieldname) =>
|
||||
`${primitivesTableName}."${fieldname}" AS "${fieldname}"`
|
||||
),
|
||||
objectType.nestedFieldNames.map(
|
||||
(fieldname) =>
|
||||
`${primitivesTableName}."${fieldname}" AS "${fieldname}"`
|
||||
),
|
||||
...objectType.nestedFieldNames.map((f1) =>
|
||||
Object.keys(objectType.nestedFields[f1].primitives).map(
|
||||
(f2) =>
|
||||
`${primitivesTableName}."${f1}.${f2}" AS "${f1}.${f2}"`
|
||||
)
|
||||
)
|
||||
);
|
||||
const rows: $ReadOnlyArray<{|
|
||||
+id: Schema.ObjectId,
|
||||
+[Schema.Fieldname]: string | 0 | 1,
|
||||
|}> = db
|
||||
.prepare(
|
||||
dedent`\
|
||||
SELECT ${selections.join(", ")}
|
||||
FROM ${temporaryTableName} JOIN ${primitivesTableName}
|
||||
USING (id)
|
||||
`
|
||||
)
|
||||
.all();
|
||||
for (const row of rows) {
|
||||
const object = {};
|
||||
object.id = row.id;
|
||||
object.__typename = typename;
|
||||
for (const fieldname of objectType.nestedFieldNames) {
|
||||
const isPresent: string | 0 | 1 = row[fieldname];
|
||||
// istanbul ignore if: should not be reachable
|
||||
if (isPresent !== 0 && isPresent !== 1) {
|
||||
const s = JSON.stringify;
|
||||
const id = object.id;
|
||||
throw new Error(
|
||||
`Corruption: nested field ${s(fieldname)} on ${s(id)} ` +
|
||||
`set to ${String(isPresent)}`
|
||||
);
|
||||
}
|
||||
if (isPresent) {
|
||||
// We'll add primitives and links onto this object.
|
||||
object[fieldname] = {};
|
||||
} else {
|
||||
object[fieldname] = null;
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(row)) {
|
||||
if (key === "id") continue;
|
||||
const rawValue = row[key];
|
||||
if (rawValue === 0 || rawValue === 1) {
|
||||
// Name of a nested field; already processed.
|
||||
continue;
|
||||
}
|
||||
const value = JSON.parse(rawValue);
|
||||
const parts = key.split(".");
|
||||
switch (parts.length) {
|
||||
case 1:
|
||||
object[key] = value;
|
||||
break;
|
||||
case 2: {
|
||||
const [nestName, eggName] = parts;
|
||||
if (object[nestName] !== null) {
|
||||
object[nestName][eggName] = value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// istanbul ignore next: should not be possible
|
||||
default:
|
||||
throw new Error(
|
||||
`Corruption: bad field name: ${JSON.stringify(key)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
allObjects.set(object.id, object);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2502,7 +2502,7 @@ describe("graphql/mirror", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("extract", () => {
|
||||
function testExtract(extract: (Mirror, Schema.ObjectId) => mixed) {
|
||||
// A schema with some useful edge cases.
|
||||
function buildTestSchema(): Schema.Schema {
|
||||
const s = Schema;
|
||||
|
@ -2571,7 +2571,7 @@ describe("graphql/mirror", () => {
|
|||
const db = new Database(":memory:");
|
||||
const mirror = new Mirror(db, buildTestSchema());
|
||||
expect(() => {
|
||||
mirror.extract("wat");
|
||||
extract(mirror, "wat");
|
||||
}).toThrow('No such object: "wat"');
|
||||
});
|
||||
|
||||
|
@ -2580,7 +2580,7 @@ describe("graphql/mirror", () => {
|
|||
const mirror = new Mirror(db, buildTestSchema());
|
||||
mirror.registerObject({typename: "Caveman", id: "brog"});
|
||||
expect(() => {
|
||||
mirror.extract("brog");
|
||||
extract(mirror, "brog");
|
||||
}).toThrow(
|
||||
'"brog" transitively depends on "brog", ' +
|
||||
"but that object's own data has never been fetched"
|
||||
|
@ -2604,7 +2604,7 @@ describe("graphql/mirror", () => {
|
|||
nodes: [],
|
||||
});
|
||||
expect(() => {
|
||||
mirror.extract("localhost");
|
||||
extract(mirror, "localhost");
|
||||
}).toThrow(
|
||||
'"localhost" transitively depends on "localhost", ' +
|
||||
'but that object\'s "only" connection has never been fetched'
|
||||
|
@ -2616,7 +2616,7 @@ describe("graphql/mirror", () => {
|
|||
nodes: [],
|
||||
});
|
||||
expect(() => {
|
||||
mirror.extract("loopback");
|
||||
extract(mirror, "loopback");
|
||||
}).toThrow(
|
||||
'"loopback" transitively depends on "loopback", ' +
|
||||
'but that object\'s "connections" connection has never been fetched'
|
||||
|
@ -2645,7 +2645,7 @@ describe("graphql/mirror", () => {
|
|||
},
|
||||
]);
|
||||
expect(() => {
|
||||
mirror.extract("alpha");
|
||||
extract(mirror, "alpha");
|
||||
}).toThrow(
|
||||
'"alpha" transitively depends on "gamma", ' +
|
||||
"but that object's own data has never been fetched"
|
||||
|
@ -2681,7 +2681,7 @@ describe("graphql/mirror", () => {
|
|||
updateConnection("localhost:8080", "connections", ["localhost:7070"]);
|
||||
updateConnection("localhost:7070", "connections", ["localhost:6060"]);
|
||||
expect(() => {
|
||||
mirror.extract("localhost:8080");
|
||||
extract(mirror, "localhost:8080");
|
||||
}).toThrow(
|
||||
'"localhost:8080" transitively depends on "localhost:6060", ' +
|
||||
'but that object\'s "connections" connection has never been fetched'
|
||||
|
@ -2696,7 +2696,7 @@ describe("graphql/mirror", () => {
|
|||
mirror._updateOwnData(updateId, [
|
||||
{__typename: "Caveman", id: "brog", only: "ugg", primitives: "ook"},
|
||||
]);
|
||||
const result: Caveman = (mirror.extract("brog"): any);
|
||||
const result: Caveman = (extract(mirror, "brog"): any);
|
||||
expect(result).toEqual({
|
||||
__typename: "Caveman",
|
||||
id: "brog",
|
||||
|
@ -2732,7 +2732,7 @@ describe("graphql/mirror", () => {
|
|||
lynx: null,
|
||||
},
|
||||
]);
|
||||
const result = mirror.extract("alpha");
|
||||
const result = extract(mirror, "alpha");
|
||||
expect(result).toEqual({
|
||||
__typename: "Feline",
|
||||
id: "alpha",
|
||||
|
@ -2783,7 +2783,7 @@ describe("graphql/mirror", () => {
|
|||
"localhost:6060",
|
||||
]);
|
||||
updateConnection("localhost:6060", "connections", []);
|
||||
const result = mirror.extract("localhost:8080");
|
||||
const result = extract(mirror, "localhost:8080");
|
||||
expect(result).toEqual({
|
||||
__typename: "Socket",
|
||||
id: "localhost:8080",
|
||||
|
@ -2818,7 +2818,7 @@ describe("graphql/mirror", () => {
|
|||
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");
|
||||
const result = extract(mirror, "mt");
|
||||
expect(result).toEqual({__typename: "Empty", id: "mt"});
|
||||
});
|
||||
|
||||
|
@ -2830,7 +2830,7 @@ describe("graphql/mirror", () => {
|
|||
mirror._updateOwnData(updateId, [
|
||||
{__typename: "Caveman", id: "brog", only: false, primitives: true},
|
||||
]);
|
||||
expect(mirror.extract("brog")).toEqual({
|
||||
expect(extract(mirror, "brog")).toEqual({
|
||||
__typename: "Caveman",
|
||||
id: "brog",
|
||||
only: false,
|
||||
|
@ -2846,7 +2846,7 @@ describe("graphql/mirror", () => {
|
|||
mirror._updateOwnData(updateId, [
|
||||
{__typename: "Caveman", id: "brog", only: null, primitives: null},
|
||||
]);
|
||||
expect(mirror.extract("brog")).toEqual({
|
||||
expect(extract(mirror, "brog")).toEqual({
|
||||
__typename: "Caveman",
|
||||
id: "brog",
|
||||
only: null,
|
||||
|
@ -2862,7 +2862,7 @@ describe("graphql/mirror", () => {
|
|||
mirror._updateOwnData(updateId, [
|
||||
{__typename: "Caveman", id: "brog", only: 123, primitives: 987},
|
||||
]);
|
||||
expect(mirror.extract("brog")).toEqual({
|
||||
expect(extract(mirror, "brog")).toEqual({
|
||||
__typename: "Caveman",
|
||||
id: "brog",
|
||||
only: 123,
|
||||
|
@ -2895,7 +2895,7 @@ describe("graphql/mirror", () => {
|
|||
lynx: null,
|
||||
},
|
||||
]);
|
||||
const result: Nest = (mirror.extract("eyrie"): any);
|
||||
const result: Nest = (extract(mirror, "eyrie"): any);
|
||||
expect(result).toEqual({
|
||||
__typename: "Nest",
|
||||
id: "eyrie",
|
||||
|
@ -2939,7 +2939,7 @@ describe("graphql/mirror", () => {
|
|||
lynx: null,
|
||||
},
|
||||
]);
|
||||
const result: Feline = (mirror.extract("alpha"): any);
|
||||
const result: Feline = (extract(mirror, "alpha"): any);
|
||||
expect(result).toEqual({
|
||||
__typename: "Feline",
|
||||
id: "alpha",
|
||||
|
@ -2993,7 +2993,7 @@ describe("graphql/mirror", () => {
|
|||
"localhost:6060",
|
||||
]);
|
||||
updateConnection("localhost:6060", "connections", ["localhost:7070"]);
|
||||
const result: Socket = (mirror.extract("localhost:8080"): any);
|
||||
const result: Socket = (extract(mirror, "localhost:8080"): any);
|
||||
expect(result).toEqual({
|
||||
__typename: "Socket",
|
||||
id: "localhost:8080",
|
||||
|
@ -3050,7 +3050,7 @@ describe("graphql/mirror", () => {
|
|||
null,
|
||||
],
|
||||
});
|
||||
const result: Socket = (mirror.extract("localhost"): any);
|
||||
const result: Socket = (extract(mirror, "localhost"): any);
|
||||
expect(result).toEqual({
|
||||
__typename: "Socket",
|
||||
id: "localhost",
|
||||
|
@ -3323,7 +3323,7 @@ describe("graphql/mirror", () => {
|
|||
mirror.registerObject(objects.noboty());
|
||||
mirror.registerObject(objects.issue3());
|
||||
|
||||
const result = mirror.extract("repo:foo/bar");
|
||||
const result = extract(mirror, "repo:foo/bar");
|
||||
expect(result).toEqual({
|
||||
__typename: "Repository",
|
||||
id: "repo:foo/bar",
|
||||
|
@ -3413,6 +3413,75 @@ describe("graphql/mirror", () => {
|
|||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("extract", () => {
|
||||
// We'll run under both legacy and EAV modes. In each case, hide
|
||||
// the tables corresponding to the other mode to catch any
|
||||
// accidental reads. (We hide and unhide rather than deleting
|
||||
// because some test cases call `extract` multiple times.)
|
||||
function hiddenName(name) {
|
||||
return `${name}_DO_NOT_READ`;
|
||||
}
|
||||
function hideTable(db, name) {
|
||||
db.prepare(`ALTER TABLE ${name} RENAME TO ${hiddenName(name)}`).run();
|
||||
}
|
||||
function unhideTable(db, name) {
|
||||
db.prepare(`ALTER TABLE ${hiddenName(name)} RENAME TO ${name}`).run();
|
||||
}
|
||||
|
||||
describe("with legacy type-specific primitives", () => {
|
||||
testExtract((mirror, id) => {
|
||||
hideTable(mirror._db, "primitives");
|
||||
try {
|
||||
return mirror.extract(id, {useEavPrimitives: false});
|
||||
} finally {
|
||||
unhideTable(mirror._db, "primitives");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("with EAV primitives", () => {
|
||||
testExtract((mirror, id) => {
|
||||
const legacyTables = mirror._db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master " +
|
||||
"WHERE type = 'table' AND name LIKE 'primitives_%'"
|
||||
)
|
||||
.pluck()
|
||||
.all();
|
||||
if (legacyTables.length === 0) {
|
||||
throw new Error("Found no type-specific primitives tables?");
|
||||
}
|
||||
for (const table of legacyTables) {
|
||||
hideTable(mirror._db, table);
|
||||
}
|
||||
try {
|
||||
return mirror.extract(id, {useEavPrimitives: true});
|
||||
} finally {
|
||||
for (const table of legacyTables) {
|
||||
unhideTable(mirror._db, table);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("works with default options", () => {
|
||||
// Simple sanity check.
|
||||
const db = new Database(":memory:");
|
||||
const schema = buildGithubSchema();
|
||||
const mirror = new Mirror(db, schema);
|
||||
mirror.registerObject({typename: "SubscribedEvent", id: "sub"});
|
||||
const update = mirror._createUpdate(new Date(123));
|
||||
mirror._updateOwnData(update, [
|
||||
{__typename: "SubscribedEvent", id: "sub", actor: null},
|
||||
]);
|
||||
expect(mirror.extract("sub")).toEqual({
|
||||
__typename: "SubscribedEvent",
|
||||
id: "sub",
|
||||
actor: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("end-to-end typename guessing", () => {
|
||||
|
|
Loading…
Reference in New Issue