diff --git a/src/graphql/mirror.js b/src/graphql/mirror.js index d63cffd..04ecf82 100644 --- a/src/graphql/mirror.js +++ b/src/graphql/mirror.js @@ -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 = 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 = [].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 = [].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); + } } } diff --git a/src/graphql/mirror.test.js b/src/graphql/mirror.test.js index 2df37e2..4dd8ffd 100644 --- a/src/graphql/mirror.test.js +++ b/src/graphql/mirror.test.js @@ -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", () => {