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
This commit is contained in:
Aaron Carlino 2021-06-30 19:05:18 +12:00 committed by GitHub
parent 82e0d8f24b
commit f3e1cd4599
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 382 additions and 76 deletions

View File

@ -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

View File

@ -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: