mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 12:05:37 +00:00
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:
parent
82e0d8f24b
commit
f3e1cd4599
@ -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
|
||||
|
||||
|
@ -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:
|
||||
|
Loading…
x
Reference in New Issue
Block a user