From f3e1cd45997929156ba8c85e859e19fa00547b01 Mon Sep 17 00:00:00 2001 From: Aaron Carlino Date: Wed, 30 Jun 2021 19:05:18 +1200 Subject: [PATCH] DOCS: new union and interface inheritance pattern (#9912) * DOCS: new union and interface inheritance pattern * Update docs for interface queries * Remove config references in updateSchema --- .../04_inheritance.md | 456 +++++++++++++++--- .../19_GraphQL/05_plugins/01_overview.md | 2 +- 2 files changed, 382 insertions(+), 76 deletions(-) diff --git a/docs/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/04_inheritance.md b/docs/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/04_inheritance.md index fba5bb6c7..94f37bde5 100644 --- a/docs/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/04_inheritance.md +++ b/docs/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/04_inheritance.md @@ -19,121 +19,427 @@ Docs for the current stable version (3.x) can be found The inheritance pattern used in the ORM is a tricky thing to navigate in a GraphQL API, mostly owing to the fact that there is no concept of inheritance in GraphQL types. The main tools we have at our -disposal are [interfaces](https://graphql.org/learn/schema/#interfaces) and [unions](https://graphql.org/learn/schema/#union-types) to deal with this type of architecture, but in practise, it quickly becomes cumbersome. -For instance, just adding a subclass to a DataObject can force the return type to change from a simple list -of types to a union of multiple types, and this would break frontend code. +disposal are [interfaces](https://graphql.org/learn/schema/#interfaces) and [unions](https://graphql.org/learn/schema/#union-types) to deal with this type of architecture, and we leverage both of them when +working with dataobjects. -While more conventional, unions and interfaces introduce more complexity, and given how much we rely -on inheritance in Silverstripe CMS, particularly with `SiteTree`, inheritance in GraphQL is handled in a less -conventional but more ergonomic way using a plugin called `inheritance`. +### Key concept: Querying types that have descendants -### Introducing pseudo-unions +When you query a type that has descendant classes, you are by definition getting a polymorphic return. There +is no guarantee that each result will be of one specific type. Take this example: -Let's take a simple example. Imagine we have this design: - -``` -> SiteTree (fields: title, content) - > Page (fields: pageField) - > NewsPage (fields: newsPageField) - > Contact Page (fields: contactPageField) +```graphql +query { + readPages { + nodes { + title + content + } + } +} ``` -Now, let's expose `Page` to graphql: +This is fine when the two fields are common to across the entire inheritance chain, but what happens +when we need the `date` field on `BlogPage`? -*app/_graphql/models.yml* -```yaml +```graphql +query { + readPages { + nodes { + title + content + date # fails! + } + } +} +``` + +To solve this problem, the graphql module will automatically change these types of queries to return interfaces. + +```graphql +query { + readPages { + nodes { # <--- [PageInterface] + title + content + } + } +} +``` + +But what about when we want more than `title` and `content`? In some cases, we'll want fields that are specific to +`BlogPage`. When accessing fields for a specific implementation, we need to use an [inline fragment](https://graphql.org/learn/queries/#inline-fragments) to select them. + +```graphql +query { + readPages { + nodes { + title + content + ... on HomePage { + heroImage { + url + } + } + ... on BlogPage { + date + author { + firstName + } + } + } + } +} +``` + +So the fields that are common to every possible type in the result set can be directly selected (with no `...on` +syntax), because they're part of the common interface. They're guaranteed to exist on every type. But for fields +that only appear on some types, we need to be explicit. + +But let's take this a step further. What if there's another class in between? Imagine this ancestry: + +``` Page: - fields: + EventPage: + ConferencePage + WebinarPage +``` + +We can use the intermediary interface `EventPageInterface` to consolidate fields that are unique to +`ConferencePage` and `WebinarPage`. + +``` +query { + readPages { + nodes { + title + content + ... on EventPageInterface { + numberOfTickets + featuredSpeaker { + firstName + email + } + } + ... on WebinarPage { + zoomLink + } + ... on ConferencePage { + venueSize + } + ... on BlogPage { + date + author { + firstName + } + } + } + } +} +``` + +You can think of interfaces in this context as abstractions of *parent classes*. + +[info] +A good way to determine whether you need an inline fragment is to ask, +"Can this field appear on any other types in the query?" If the answer is yes, you want to use an interface, +which is usually the parent class with the "Interface" suffix. +[/info] + +### Inheritance: A deep dive + +There are two components to the way inheritance is handled at build time: + +* Implicit field / type exposure +* Interface generation and assignment to types + +We'll look at each of these in detail. + +#### Inherited fields / implicit exposures + +Here are the rules for how inheritance affects types and fields: + +* Exposing a type implicitly exposes all of its ancestors. +* Ancestors receive any fields exposed by their descendants, if applicable. +* Exposing a type applies all of its fields to descendants only if they are explicitly exposed also. + +All of this is serviced by: `SilverStripe\GraphQL\Schema\DataObject\InheritanceBuilder` + +##### Example: + +```yaml +BlogPage: + fields: title: true content: true - pageField: true - operations: '*' -NewsPage: + date: true +GalleryPage: fields: - newsPageField: true + images: true + urlSegment: true ``` -Here's how we can query the inherited fields: +This results in these two types being exposed with the fields as shown, but also results in a `Page` type: + +``` +type Page { + id: ID! # always exposed + title: String + content: String + urlSegment: String +} +``` + +#### Interface generation and assignment to types + +Any type that's part of an inheritance chain will generate interfaces. Each applicable ancestral interface is added +to the type. Like the type inheritance pattern shown above, interfaces duplicate fields from their ancestors as well. + +Additionally, a **base interface** is provided for all types containing common fields across the entire DataObject +schema. + +All of this is serviced by: `SilverStripe\GraphQL\Schema\DataObject\InterfaceBuilder` + +##### Example + +``` +Page: + BlogPage extends Page + EventsPage extends Page + ConferencePage extends EventsPage + WebinarPage extends EventsPage +``` + +This will create the following interfaces: + +``` +interface PageInterface { + title: String + content: String +} + +interface BlogPageInterface { + id: ID! + title: String + content: String + date: String +} + +interface EventsPageInterface { + id: ID! + title: String + content: String + numberOfTickets: Int +} + +interface ConferencePageInterface { + id: ID! + title: String + content: String + numberOfTickets: Int + venueSize: Int + venurAddress: String +} + +interface WebinarPageInterface { + id: ID! + title: String + content: String + numberOfTickets: Int + zoomLink: String +} +``` + +Types then get these interfaces applied, like so: + +``` +type Page implements PageInterface {} +type BlogPage implements BlogPageInterface & PageInterface {} +type EventsPage implements EventsPageInterface & PageInterface {} +type ConferencePage implements ConferencePageInterface & EventsPageInterface & PageInterface {} +type WebinarPage implements WebinarPageInterface & EventsPageInterface & PageInterface {} +``` + +Lastly, for good measure, we create a `DataObjectInterface` that applies to everything. + +``` +interface DataObjectInterface { + id: ID! + # Any other fields you've explicitly exposed in config.modelConfig.DataObject.base_fields +} +``` + +``` +type Page implements PageInterface & DataObjectInterface {} +``` + +#### Elemental + +Almost by definition, content blocks are always abstractions. You're never going to query for a `BaseElement` type +specifically. You're always asking for an assortment of its descendants, which adds a lot of polymorphism to +the query. ```graphql -query readPages { - nodes { - title - content - pageField - _extend { - NewsPage { - newsPageField +query { + readElementalPages { + nodes { + elementalArea { + elements { + nodes { + title + id + ... on ContentBlock { + html + } + ... on CTABlock { + link + linkText + } + } + } } } } } ``` -The `_extend` field is semantically aligned with is PHP counterpart -- it's an object whose fields are the -names of all the types that are descendants of the parent type. Each of those objects contains all the fields -on that type, both inherited and native. -[info] -The `_extend` field is only available on base classes, e.g. `Page` in the example above. -[/info] +### Optional: Use unions instead of interfaces -### Implicit exposure +You can opt out of using interfaces as your return types for queries and instead use a union of all the concrete +types. This comes at a cost of potentially breaking your API unexpectedly (described below), so it is not enabled by +default. There is no substantive advantage to using unions over interfaces for your query return types. It would +typically only be done for conceptual purposes. -By exposing `Page`, we implicitly expose *all of its ancestors* and *all of its descendants*. Adding `Page` -to our schema implies that we also want its parent `SiteTree` in the schema (after all, that's where most of its fields -come from), but we also need to be mindful that queries for page will return descendants of `Page`, as well. +To use unions, turn on the `useUnionQueries` setting. -But these types are implicitly added to the schema, what are their fields? +```yaml +SilverStripe\GraphQL\Schema\Schema: + schemas: + default: + config: + modelConfig: + DataObject: + plugins: + inheritance: + useUnionQueries: true +``` -The answer is *only the fields you've already opted into*. Parent classes will apply the fields exposed -by their descendants, and descendant classes will only expose their ancestors' exposed fields. -If you are opting into all fields on a model (`fields: "*"`), this only applies to the -model itself, not its subclasses. +This means that models that have descendants will create unions that include themselves and all of their descendants. +For queries that return those models, a union is put in its place. -In our case, we've exposed: +Serviced by: `SilverStripe\GraphQL\Schema\DataObject\InheritanceUnionBuilder` -* `title` (on `SiteTree`) -* `content` (on `SiteTree`) -* `pageField` (on `Page`) -* `newsPageField` (on `NewsPage`) +##### Example -The `Page` type will contain the following fields: +``` +type Page implements PageInterface {} +type BlogPage implements BlogPageInterface & PageInterface {} +type EventsPage implements EventsPageInterface & PageInterface {} +type ConferencePage implements ConferencePageInterface & EventsPageInterface & PageInterface {} +type WebinarPage implements WebinarPageInterface & EventsPageInterface & PageInterface {} +``` -* `id` (required for all DataObject types) -* `title` -* `content` -* `pageField` +Creates the following unions: -And the `NewsPage` type would contain the following fields: +``` +union PageInheritanceUnion = Page | BlogPage | EventsPage | ConferencePage | WebinarPage +union EventsPageInheritanceUnion = EventsPage | ConferencePage | WebinarPage +``` -* `newsPageField` +"Leaf" models like `BlogPage`, `ConferencePage`, and `WebinarPage` that have no exposed descendants will not create +unions, as they are functionally useless. -[info] -Operations are not implicitly exposed. If you add a `read` operation to `SiteTree`, you will not get one for -`NewsPage` and `ContactPage`, etc. You have to opt in to those. -[/info] - -### Pseudo-unions fields are de-duped - -To keep things tidy, the pseudo unions in the `_extend` field remove any fields that are already in -the parent. +This means that queries for `readPages` and `readEventsPages` will now return unions. ```graphql -query readPages { - nodes { - title - content - _extend { - NewsPage { - title <---- Doesn't exist - newsPageField +query { + readPages { + nodes { + ... on PageInterface { + id # in theory, this common field could be done on DataObjectInterface, but that gets a bit verbose + title + content + } + ... on EventsPageInterface { + numberOfTickets + } + ... on BlogPage { + date + } + ... on WebinarPage { + zoomLink } } } } ``` +#### Lookout for the footgun! + +Because unions are force substituted for your queries when a model has exposed descendants, it is possible that adding +a subclass to a model will break your queries without much warning to you. + +For instance: + +```php +class Product extends DataObject +{ + private static $db = ['Price' => 'Int']; +} +``` + +We might query this with: + +```graphql +query { + readProducts { + nodes { + price + } + } +} +``` + +But if we create a subclass for product and expose it to graphql: + +```php +class DigitalProduct extends Product +{ + private static $db = ['DownloadURL' => 'Varchar']; +} +``` + +Now our query breaks: + +``` +query { + readProducts { + nodes { + price # Error: Field "price" not found on ProductInheritanceUnion + } + } +} +``` + +We need to revise it: + +``` +query { + readProducts { + nodes { + ... on ProductInterface { + price + } + ... on DigitalProduct { + downloadUrl + } + } + } +} +``` + +Had we used interfaces, this wouldn't have broken, because the `price` field would have been on `ProductInterface` +and directly queryable (without the inline fragment). ### Further reading diff --git a/docs/en/02_Developer_Guides/19_GraphQL/05_plugins/01_overview.md b/docs/en/02_Developer_Guides/19_GraphQL/05_plugins/01_overview.md index 494349a5f..dd83acbe8 100644 --- a/docs/en/02_Developer_Guides/19_GraphQL/05_plugins/01_overview.md +++ b/docs/en/02_Developer_Guides/19_GraphQL/05_plugins/01_overview.md @@ -30,7 +30,7 @@ Plugins are used to distribute reusable functionality across your schema. Some e By default, all schemas ship with some plugins installed that will benefit most use cases: * The `DataObject` model (i.e. any dataobject based type) has: - * An `inheritance` plugin that builds the `__extends` field, and merges ancestral fields. + * An `inheritance` plugin that builds the interfaces, unions, and merges ancestral fields. * An `inheritedPlugins` plugin (a bit meta!) that merges plugins from ancestral types into descendants. installed). * The `read` and `readOne` operations have: