Working with DataObject
models
- Adding DataObject models to the schema
- An overview of how the DataObject model can influence the creation of types, queries, and mutations
- DataObject operation permissions
- A look at how permissions work for DataObject queries and mutations
- DataObject inheritance
- Learn how inheritance is handled in DataObject model types
- DataObject query plugins
- Learn about some of the useful goodies that come pre-packaged with DataObject queries
- Versioned content
- A guide on how DataObject models with the Versioned extension behave in GraphQL schemas
- Property mapping and dot syntax
- Learn how to customise field names, use dot syntax, and use aggregate functions
- Nested type definitions
- Define dependent types inline with a parent type
The DataObject
model type
In Silverstripe CMS projects, our data tends to be contained in DataObjects almost exclusively,
and the silverstripe/graphql
schema API is designed so that adding DataObject
content to your
GraphQL schema definition is fast and simple.
Using model types
While it is possible to add DataObjects to your schema as generic types under the types
section of the configuration, and their associated queries and mutations under queries
and
mutations
, this will lead to a lot of boilerplate code and repetition. Unless you have some
really custom needs, a much better approach is to embrace convention over configuration
and use the models
section of the config.
Model types are types that rely on external classes to tell them who they are and what they can and cannot do. The model can define and resolve fields, auto-generate queries and mutations, and more.
Naturally, this module comes bundled with a model type for subclasses of DataObject
.
Let's use the models
config to expose some content.
# app/_graphql/models.yml
Page:
fields: '*'
operations: '*'
The class Page
is a subclass of DataObject
, so the bundled model
type will kick in here and provide a lot of assistance in building out this part of our API.
Case in point, by supplying a value of *
for fields
, we're saying that we want all of the fields
on the Page
class. This includes the first level of relationships, as defined on has_one
, has_many
,
or many_many
.
Fields on relationships will not inherit the *
fields selector, and will only expose their ID by default.
To add additional fields for those relationships you will need to add the corresponding DataObject
model types.
The *
value on operations
tells the schema to create all available queries and mutations
for the DataObject, including:
read
readOne
create
update
delete
Now that we've changed our schema, we need to build it using the dev/graphql/build
command:
vendor/bin/sake dev/graphql/build schema=default
Now we can access our schema on the default GraphQL endpoint, /graphql
.
Test it out!
Note the use of the default arguments on date
. Fields created from DBFields
generate their own default sets of arguments. For more information, see
DBFieldArgs.
A query:
query {
readPages {
nodes {
title
content
... on BlogPage {
date(format: NICE)
comments {
nodes {
comment
author {
firstName
}
}
}
}
}
}
}
The ... on BlogPage
syntax is called an inline fragment.
You can learn more about this syntax in the Inheritance section.
A mutation:
mutation {
createPage(input: {
title: "my page"
}) {
title
id
}
}
Did you get a permissions error? Make sure you're authenticated as someone with appropriate access.
Configuring operations
You may not always want to add all operations with the *
wildcard. You can allow those you
want by setting them to true
(or false
to remove them).
# app/_graphql/models.yml
Page:
fields: '*'
operations:
read: true
create: true
App\Model\Product:
fields: '*'
operations:
'*': true
delete: false
Operations are also configurable, and accept a nested map of config.
# app/_graphql/models.yml
Page:
fields: '*'
operations:
create: true
read:
name: getAllThePages
Customising the input types
The input types, specifically in create
and update
, can be customised with a
list of fields. The list can include explicitly disallowed fields.
# app/_graphql/models.yml
Page:
fields: '*'
operations:
create:
fields:
title: true
content: true
update:
fields:
'*': true
immutableField: false
Adding more fields
Let's add some more DataObjects, but this time, we'll only add a subset of fields and operations.
# app/_graphql/models.yml
Page:
fields: '*'
operations: '*'
App\Model\Product:
fields:
onSale: true
title: true
price: true
operations:
delete: true
App\Model\ProductCategory:
fields:
title: true
featured: true
A couple things to note here:
-
By assigning a value of
true
to the field, we defer to the model to infer the type for the field. To override that, we can always add atype
property:App\Model\Product: fields: onSale: type: Boolean
- The mapping of our field names to the
DataObject
property is case-insensitive. It is a convention in GraphQL APIs to use lowerCamelCase fields, so this is given by default.
Bulk loading models
It's likely that in your application you have a whole collection of classes you want exposed to the API with roughly
the same fields and operations exposed on them. It can be really tedious to write a new declaration for every single
DataObject
in your project, and as you add new ones, there's a bit of overhead in remembering to add it to the
GraphQL schema.
Common use cases might be:
- Add everything in
App\Model
- Add every implementation of
BaseElement
- Add anything with the
Versioned
extension - Add everything that matches
src/*Model.php
You can create logic like this using the bulkLoad
configuration file, which allows you to specify groups of directives
that load a bundle of classes and apply the same set of configuration to all of them.
# app/_graphql/bulkLoad.yml
elemental: # An arbitrary key to define what these directives are doing
# Load all elemental blocks except MySecretElement
load:
inheritanceLoader:
include:
- DNADesign\Elemental\Models\BaseElement
exclude:
- App\Model\Elemental\MySecretElement
# Add all fields and read operations
apply:
fields:
'*': true
operations:
read: true
readOne: true
app:
# Load everything in our App\Model\ namespace that has the Versioned extension
# unless the filename ends with .secret.php
load:
namespaceLoader:
include:
- App\Model\*
extensionLoader:
include:
- SilverStripe\Versioned\Versioned
filepathLoader:
exclude:
- app/src/Model/*.secret.php
apply:
fields:
'*': true
operations:
'*': true
By default, four loaders are provided to you to help gather specific classnames:
By namespace
- Identifier:
namespaceLoader
- Description: Include or exclude classes based on their namespace
- Example:
include: [App\Model\*]
By inheritance
- Identifier:
inheritanceLoader
- Description: Include or exclude everything that matches or extends a given base class
- Example:
include: [DNADesign\Elemental\Models\BaseElement]
By applied extension
- Identifier:
extensionLoader
- Description: Include or exclude any class that has a given extension applied
- Example:
include: [SilverStripe\Versioned\Versioned]
By filepath
- Identifier:
filepathLoader
- Description: Include or exclude any classes in files matching a given glob expression, relative to the base path. Module syntax is allowed.
-
Examples:
include: [ 'src/Model/*.model.php' ]
include: [ 'somevendor/somemodule: src/Model/*.php' ]
exclude
directives will always supersede include
directives.
Each block starts with a collection of all classes that gets filtered as each loader runs. The primary job of a loader is to remove classes from the entire collection, not add them in.
If you find that this paints with too big a brush, you can always override individual models explicitly in models.yml
.
The bulk loaders run before the models.yml
config is loaded.
DataObject
subclasses are the default starting point
Because this is Silverstripe CMS, and it's likely that you're using DataObject
models only, the bulk loaders start with an
initial filter which is defined as follows:
inheritanceLoader:
include:
- SilverStripe\ORM\DataObject
This ensures that at a bare minimum, you're always filtering by DataObject
classes only. If, for some reason, you
have a non-DataObject
class in App\Model\*
, it will automatically be filtered out due to this default setting.
This default is configured in the defaultBulkLoad
setting in your schema config. Should you ever want to disable
that, just set it to false
.
# app/_graphql/config.yml
defaultBulkLoad: false
Creating your own bulk loader
Bulk loaders must extend AbstractBulkLoader
. They
need to declare an identifier (e.g. namespaceLoader
) to be referenced in the config, and they must implement
collect()
which returns a new Collection
instance once the loader has done its work parsing through the include
and exclude
directives.
Bulk loaders are automatically registered. Just creating the class is all you need to do to have it available for use
in your bulkLoad.yml
file.
Customising model fields
You don't have to rely on the model to tell you how fields should resolve. Just like generic types, you can customise them with arguments and resolvers.
# app/_graphql/models.yml
App\Model\Product:
fields:
title:
type: String
resolver: ['App\GraphQL\Resolver\ProductResolver', 'resolveSpecialTitle']
'price(currency: String = "NZD")': true
For more information on custom arguments and resolvers, see the adding arguments and resolver discovery documentation.
Excluding or customising "*" declarations
You can use *
as a field or operation, and anything that follows it will override the
all-inclusive collection. This is almost like a spread operator in JavaScript:
const newObj = { ...oldObj, someProperty: 'custom' };
Here's an example:
# app/_graphql/models.yml
Page:
fields:
'*': true # Get everything
sensitiveData: false # hide this field
'content(summaryLength: Int)': true # add an argument to this field
operations:
'*': true
read:
plugins:
paginateList: false # don't paginate the read operation
Disallowed fields
While selecting all fields via *
is useful, there are some fields that you
don't want to accidentally expose, especially if you're a module author
and expect models within this code to be used through custom GraphQL endpoints.
For example, a module might add a secret "preview token" to each SiteTree
.
A custom GraphQL endpoint might have used fields: '*'
on SiteTree
to list pages
on the public site, which now includes a sensitive field.
The graphql_blacklisted_fields
property on DataObject
allows you to
disallow fields globally for all GraphQL schemas.
This block list applies for all operations (read, update, etc).
# app/_config/graphql.yml
SilverStripe\CMS\Model\SiteTree:
graphql_blacklisted_fields:
myPreviewTokenField: true
Model configuration
There are several settings you can apply to your model class (typically DataObjectModel
),
but because they can have distinct values per schema, the standard _config
layer is not
an option. Model configuration has to be done within the schema config in the modelConfig
subsection.
Customising the type name
Most DataObject
classes are namespaced, so converting them to a type name ends up
being very verbose. As a default, the DataObjectModel
class will use the "short name"
of your DataObject
as its typename (see: ClassInfo::shortName()
).
That is, App\Model\Product
becomes Product
.
Given the brevity of these type names, it's not inconceivable that you could run into naming collisions, particularly if you use feature-based namespacing. Fortunately, there are hooks you have available to help influence the typename.
Explicit type mapping
You can explicitly provide type name for a given class using the typeMapping
setting in your schema config.
# app/_graphql/config.yml
typeMapping:
App\PageType\Page: SpecialPage
It may be necessary to use typeMapping
in projects that have a lot of similar class names in different namespaces, which will cause a collision
when the type name is derived from the class name. The most case for this
is the Page
class, which may be both at the root namespace and in your
app namespace, e.g. App\PageType\Page
.
The type formatter
The type_formatter
is a callable that can be set on the DataObjectModel
config. It takes
the $className
as a parameter.
Let's turn the type for App\Model\Product
from Product
into the more specific AppProduct
# app/_graphql/config.yml
modelConfig:
DataObject:
type_formatter: ['App\GraphQL\Formatter', 'formatType']
In the above example, DataObject
is the result of DataObjectModel::getIdentifier()
.
Each model class must declare one of these.
The formatting function in your App\GraphQL\Formatter
class could look something like:
namespace App\GraphQL;
class Formatter
{
public static function formatType(string $className): string
{
$parts = explode('\\', $className);
if (count($parts) === 1) {
return $className;
}
$first = reset($parts);
$last = end($parts);
return $first . $last;
}
}
The type prefix
You can also add prefixes to all your DataObject
types. This can be a scalar value or a callable,
using the same signature as type_formatter
.
# app/_graphql/config.yml
modelConfig:
DataObject:
type_prefix: 'App'
This would automatically set the type name for your App\Model\Product
class to AppProduct
without needing to declare a type_formatter
.