diff --git a/README.md b/README.md index bda4f58..69144f4 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ const schema = { }, fields: { name: StringField(), - members: ForeignKeyField("manyToMany"), + members: ForeignKeyField("manyToMany", "Person"), }, }, Person: { @@ -101,14 +101,12 @@ const MyComponent = () => { id name members { - ... on Person { - id - name - groups { - ... on Group { - id - name - } + id + name + groups { + ... on Group { + id + name } } } @@ -137,6 +135,14 @@ const MyComponent = () => { ## Relationships +Foreign keys and inverse fields are typed as the `Node` interface in the GraphQL schema, so selecting fields other than `id` requires an inline fragment (`... on Person`). Passing the target type as the second argument to `ForeignKeyField` types the field as that type instead, so the fragment isn't needed: + +```js +members: ForeignKeyField("manyToMany", "Person"), +``` + +Only records of the target type are returned for the field, and `addRelationship` will throw if the target record isn't of that type. + Relationship lists are never `null` and never contain `null` (`[Node!]!`). IDs that don't resolve to a record (e.g. one that has been deleted) are left out. ## Generating a GraphQL schema file @@ -233,9 +239,7 @@ const GroupQuery = graphql(` name members { id - ... on Person { - name - } + name } } } @@ -279,9 +283,7 @@ const GroupQuery = graphql(` name members { id - ... on Person { - name - } + name } } } diff --git a/index.ts b/index.ts index dad9383..b43ee53 100644 --- a/index.ts +++ b/index.ts @@ -65,6 +65,7 @@ export type SchemaField = | { type: "ForeignKey"; cardinality: "oneToOne" | "oneToMany" | "manyToMany" | "manyToOne"; + target?: string; } | { type: "Inverse"; @@ -197,24 +198,34 @@ class RecordSet extends EventTarget { const f = schema[type].fields[field]; switch (f.type) { case "ForeignKey": { + const target = f.target; + if (target !== undefined && !Object.prototype.hasOwnProperty.call(schema, target)) { + throw Error( + `Expected the target of ${type}#${field} (${target}) to be in the schema.`, + ); + } + // Records of the wrong type are skipped so they can't masquerade as the target type. + const find = (id: unknown) => + this.records.find( + (r) => r.id === id && (target === undefined || r.type === target), + ); + const nodeType = target === undefined ? NodeType : dynamicTypes[target]; if (f.cardinality === "manyToMany" || f.cardinality === "oneToMany") { result[field] = { - type: listOf(NodeType), + type: listOf(nodeType), resolve: (record: Record) => { const source = record[field]; // Dangling IDs (e.g. left behind by a delete) are skipped because list items // are non-null. return Array.isArray(source) - ? source - .map((id) => this.records.find((r) => r.id === id)) - .filter((r): r is Record => r !== undefined) + ? source.map(find).filter((r): r is Record => r !== undefined) : []; }, }; } else { result[field] = { - type: NodeType, - resolve: (record: Record) => this.records.find((r) => r.id === record[field]), + type: nodeType, + resolve: (record: Record) => find(record[field]), }; } break; @@ -493,6 +504,12 @@ class RecordSet extends EventTarget { `Unable to add relationship: ${field} on ${sourceRecord.type} in not a foreign key.`, ); } + if ( + f.target !== undefined && + !this.records.find(({ id, type }) => id === target && type === f.target) + ) { + throw Error(`Unable to add relationship: ${target} is not a ${f.target}.`); + } if (f.cardinality === "manyToMany" || f.cardinality === "oneToMany") { if (f.cardinality === "oneToMany") { for (let record of this.records) { @@ -696,10 +713,15 @@ const DateField = (): { type: "Date" } => ({ type: "Date" }); const ForeignKeyField = ( cardinality: "oneToOne" | "oneToMany" | "manyToMany" | "manyToOne", -): { type: "ForeignKey"; cardinality: "oneToOne" | "oneToMany" | "manyToMany" | "manyToOne" } => ({ - type: "ForeignKey", - cardinality, -}); + target?: string, +): { + type: "ForeignKey"; + cardinality: "oneToOne" | "oneToMany" | "manyToMany" | "manyToOne"; + target?: string; +} => + target === undefined + ? { type: "ForeignKey", cardinality } + : { type: "ForeignKey", cardinality, target }; const InverseField = ( source: String | Array, diff --git a/test.ts b/test.ts index 1af58a7..e5424ed 100644 --- a/test.ts +++ b/test.ts @@ -1864,6 +1864,11 @@ describe("schema helpers", () => { type: "ForeignKey", cardinality: "manyToMany", }); + expect(ForeignKeyField("manyToOne", "Person")).toEqual({ + type: "ForeignKey", + cardinality: "manyToOne", + target: "Person", + }); }); test("DateField", () => { @@ -2152,6 +2157,128 @@ describe("useRecordSet", () => { }); }); +describe("foreign key targets", () => { + const TARGETS_SCHEMA: Schema = { + Group: { + meta: { + singular: "group", + plural: "groups", + }, + fields: { + name: StringField(), + owner: ForeignKeyField("manyToOne", "Person"), + members: ForeignKeyField("manyToMany", "Person"), + }, + }, + Person: { + meta: { + singular: "person", + plural: "people", + }, + fields: { + name: StringField(), + }, + }, + }; + + const init = () => [ + { + type: "Group", + id: "3d6ff8ff-d23e-4421-bfe1-b2fabd1ccb8c", + name: "Group A", + owner: "7de3a1cf-5f26-4c56-b453-5dd2a33697fc", + members: [ + "7de3a1cf-5f26-4c56-b453-5dd2a33697fc", + "3d6ff8ff-d23e-4421-bfe1-b2fabd1ccb8c", + "a5e1b0a4-9a5b-4f3e-9d7e-3c1f0b0f4a77", + ], + }, + { + type: "Person", + id: "7de3a1cf-5f26-4c56-b453-5dd2a33697fc", + name: "Person A", + }, + ]; + + test("schema generation", () => { + expect(printRecordSetSchema(TARGETS_SCHEMA)).toContain( + dedent` + type Group implements Node { + id: String! + name: String + owner: Person + members: [Person!]! + } + `, + ); + }); + + test("unknown target", () => { + expect(() => { + printRecordSetSchema({ + Group: { + meta: { + singular: "group", + plural: "groups", + }, + fields: { + owner: ForeignKeyField("manyToOne", "Person"), + }, + }, + }); + }).toThrow("Expected the target of Group#owner (Person) to be in the schema."); + }); + + test("query resolution skips records of other types and missing records", () => { + const recordSet = new RecordSet(TARGETS_SCHEMA, { init: init() }); + + expect( + recordSet.query(gql` + query { + group(id: "3d6ff8ff-d23e-4421-bfe1-b2fabd1ccb8c") { + owner { + name + } + members { + name + } + } + } + `), + ).toEqual({ + group: { + owner: { name: "Person A" }, + members: [{ name: "Person A" }], + }, + }); + }); + + test("addRelationship rejects records of other types", () => { + const consoleLogSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + const recordSet = new RecordSet(TARGETS_SCHEMA, { init: init() }); + + expect( + recordSet.query(gql` + mutation { + addRelationship( + field: "owner" + source: "3d6ff8ff-d23e-4421-bfe1-b2fabd1ccb8c" + target: "3d6ff8ff-d23e-4421-bfe1-b2fabd1ccb8c" + ) { + id + } + } + `), + ).toEqual({ addRelationship: null }); + + expect(consoleLogSpy.mock.calls[0][0].message).toBe( + "Unable to add relationship: 3d6ff8ff-d23e-4421-bfe1-b2fabd1ccb8c is not a Person.", + ); + + consoleLogSpy.mockRestore(); + }); +}); + describe("typed documents", () => { type ThingQuery = { thing: { name: string | null } | null }; type ThingQueryVariables = { id: string };