#
Schema Customization
The schema defines the data structure for your inputs and outputs of your Tilores application. The schema can be fully customized to match your existing data model.
You define the schema by defining how your custom GraphQL API looks like. For that reason, if you already know the basics of how to define a GraphQL schema, then it should be pretty straight forward to define your data model. If you do not have experience with that, we will show you the basics here and additionally would like to point you to the official GraphQL documentation.
By default all files relevant for customizing your schema, can be found in the
schema
folder in your Tilores project. Feel free to add your own files as you
need them.
#
Record
A record represents a single data point of your entity and is the most important data structure to store any kind of information in. It can be very simple, e.g. a single attribute, or very complex, e.g. multiple nested layers of attributes.
You can find the definition for a record in schema/record.graphqls
.
In GraphQL a data structure that can be used in the request as well as in the
response can have separate type definitions. That is the reason, why you will
find the two types RecordInput
and Record
defined. RecordInput
will be
used when submitting data, while Record
will be used when querying data. For
most users these will contain the same fields. Later we will show under which
circumstances these types might
For now, let's look at the current definition of the RecordInput
.
input RecordInput {
"""Id provides a unique identifier for this record."""
id: ID!
"""MyCustomField is an example field that can be changed or dropped."""
myCustomField: String!
}
The input
keyword tells GraphQL that this type is supposed to be used as input
and cannot be queried. RecordInput
obviously defines the name of the type as
it must be used in other places. And our RecordInput
currently has two fields,
id
and myCustomField
.
The ID field is the only field that is required by Tilores. It must be unique
among all records and we highly recommend to use random UUIDs (V4) for this
purpose. If this would break your data model, there is the possibility to
myCustomField
only serves as a simple example and can easily be replaced with
your own fields. However, be aware, that the default configuration uses this
field for matching and searching and the rules configuration
should be modified when removing this field.
Each field can have a multiline description. This description can be queried and displayed by GraphQL clients and serves as the documentation for your custom API - a GraphQL feature called introspection.
#
Adding Fields
Let's assume, you have JSON data and it looks something like this:
{
"id": "ae0965d6-eae4-49ac-9897-55b48d376eda",
"sourceID": 1234,
"source": "SOURCE_A",
"person": {
"name": "John Smith",
"address": "Some Street, Somewhere"
}
}
Adjusting the RecordInput
to have the sourceID
available is a simple task:
input RecordInput {
id: ID!
sourceID: Int!
}
You could proceed the same way with the source. However, in that case you may already know that there is a limited amount of possible sources and you may want to model it as an enumeration.
enum Source {
SOURCE_A
SOURCE_B
SOURCE_C
}
When running or deploying your project, the relevant enumeration type will be created for you.
For the person
field we will have to create a new type:
input PersonInput {
name: String!
address: String
}
Notice, that the address
definition does not end with an exclamation mark !
.
This is meant to show how to define optional fields. In that case you can either
omit the address or submit it using a null
value.
Both, the source
enumeration and the PersonInput
can either be defined
directly inside the schema/record.graphqls
file or in new files. This is
completely up to you.
The final RecordInput
could look something like this:
input RecordInput {
id: ID!
sourceID: Int!
source: Source!
person: PersonInput!
}
Now that the RecordInput
is defined, it is an easy task to apply the changes
to the Record
:
type Record {
id: ID!
sourceID: Int!
source: Source!
person: Person!
}
type Person {
name: String!
address: String
}
Notice, that also the PersonInput
must be copied!
#
SearchParams
The type SearchParams
lets you define which fields can be used during a search
query. You can find its definition in schema/search.graphqls.
.
The default type definition is again just for demonstration purposes and looks like this:
input SearchParams {
myCustomField: String!
}
Again it is up to you to decide how a search is supposed to look like. Using the
previously defined model for the record, you might e.g. provide the possibility
to search using the name and the address. In that case you could alter the
SearchParams
to either use the existing PersonInput
type, define a new type
PersonSearchInput
or just provide the relevant fields directly in the
SearchParams
.
Option 1: reuse existing type:
input SearchParams {
person: PersonInput!
}
Option 2: define a new type:
input PersonSearchInput {
name: String!
address: String
}
input SearchParams {
person: PersonSearchInput!
}
Option 3: provide only relevant fields:
input SearchParams {
personName: String!
address: String
}
Note, that the field names do not have to be equal to the field names as they
were defined in the PersonInput
. In most cases option two or three are the
preferable, because they can evolve independently from the RecordInput
, e.g.
the PersonInput
might introduce a new field dateOfBirth
which is not
relevant for the search.
Also note, that in order to use the fields defined in the SearchParams
they
must be used in the search rules configuration.
#
Default Scalars
GraphQL comes with a list of default types, so called scalars. Additionally a few more scalars are available.
The default scalars can be found in the official documentation.
The following additional scalars are defined by default.
scalar Time
scalar Map
scalar Any
In some cases you may want to define your own scalar types.
#
Advanced Configuration
The following examples are more advanced, because they require small changes or additions in the generated source code.
#
Diverging Record and RecordInput
Defining the structure of Record
and RecordInput
independently seems very
annoying at first. However, it provides great possibilities to evolve the
underlying data model from the query model. Maybe you would like to return
certain available data only in an aggregated form or you would like to enrich
data that is stored in another system. There are probably limitless amounts of
reasons, why you might need this.
#
Data Enrichment
Let's assume we have an InputRecord
that, beside other data, has a
providerID
field. That data about the provider is stored in an external
database. When querying we want that the user can query the providers name and
contract id. Since these values are subject of change and not relevant for our
matching process, we do not want to store them inside Tilores.
Our schema might look like this:
input RecordInput {
id: ID!
providerID: Int!
# further fields
}
type Provider {
name: String!
contractID: Int!
}
type Record {
id: ID!
provider: Provider!
# further fields
}
When running this schema, the provider data would be empty. More precisely, it
would even fail with an error, because the required provider
field will always
be empty.
To fix this, first we need to create a custom model, that has the providerID
field. Create the file graph/model/record.go
:
package model
type Record struct {
ID string `json:"id"`
ProviderID int
// further fields
}
Now tell the schema to use that type instead of generating one:
type Record @goModel(model:"<your module path>/graph/model.Record") {
id: ID!
provider: Provider!
}
Regenerate the models and resolvers by running go generate ./...
on your
project folder.
Since our record does not define a provider
field, but our schema needs an
implementation for one, the new file graph/record.resolvers.go
was created for
you and should contain something like this:
func (r *recordResolver) Provider(ctx context.Context, obj *model.Record) (*model.Provider, error) {
panic(fmt.Errorf("not implemented"))
}
All that is left is to actually implement this resolver method. In a real scenario you would most likely make a database query or a call to another service. In our example, we are going to create a fake implementation.
func (r *recordResolver) Provider(ctx context.Context, obj *model.Record) (*model.Provider, error) {
if obj.ProviderID == 1 {
return &model.Provider{
Name: "Provider A",
ContractID: 1234,
}, nil
}
if obj.ProviderID == 2 {
return &model.Provider{
Name: "Provider B",
ContractID: 9876,
}, nil
}
return nil, fmt.Errorf("unknown provider: %v", obj.ProviderID)
}
#
Requiring New Field With Existing Data
When adding a new required field to the RecordInput
after you have already
submitted data into your Tilores instance, then that same field should not be
required on the Record
. If you ignore this rule, then this will result in
errors when trying to query that new field on records that do not have this
field and therefore would return a null
value.
If you have to mark that field as required on the Record
, then the alternative
would be to enforce the creation of a resolver for that field and return an
appropriate default value if the value would otherwise be null
.
Assuming your schema looks like this:
input RecordInput {
id: ID!
existingField: String!
# further fields
}
type Record {
id: ID!
existingField: String!
# further fields
}
Furthermore assuming you already have data added according to that schema. Now
you want to add another required field to the RecordInput
:
input RecordInput {
id: ID!
existingField: String!
newField: String!
# further fields
}
For the Record
the simplest solution would be to add newField: String
(without an exclamation mark). This would allow newField
, when queried to
return null
.
For cases when you must ensure that this value is not nullable, then you can define the field like this:
type Record @goModel(model:"<your module path>/graph/model.Record") {
id: ID!
existingField: String!
newField: String! @goField(forceResolver: true)
# further fields
}
Additionally, you need to create or modify your Record
model under
graph/model/record.go
like this:
package model
type Record struct {
ID string `json:"id"`
ExistingField string `json:"existingField"`
NewField *string `json:"newField"`
// further fields
}
After you regenerated the code using go generate ./...
, you should find the
following function under graph/record.resolvers.go
:
func (r *recordResolver) NewField(ctx context.Context, obj *model.Record) (string, error) {
panic(fmt.Errorf("not implemented"))
}
It should now be trivial to implement a default value for that field
(value is missing
in our example):
func (r *recordResolver) NewField(ctx context.Context, obj *model.Record) (string, error) {
if obj.NewField != nil {
return *obj.NewField, nil
}
return "value is missing", nil
}
#
Custom Record IDs
As mentioned before, each record must have an ID. Depending on your needs, you may not want to stick with the default implementation. The following three scenarios show you how to adjust it for your use case.
A record ID must never contain a colon :
! Colons are used to separate the IDs
and labels of edges. A record with a colon in the ID will be rejected with an
error message.
#
Scenario 1: Rename ID Field
If your data already contains a unique ID field, but you want to stick to your existing name, you can easily change that.
First you need to change the field name in schema/record.graphqls
for both the
Record
and RecordInput
:
input RecordInput {
customID: ID!
# further fields
}
type Record @goModel(model:"my-tilores-project/graph/model.Record") {
customID: ID!
# further fields
}
We replaced id
with customID
.
Then you have to change the code that extracts the ID field. In
graph/helpers.go
you will find the following function:
func extractRecordID(record *model.RecordInput) string {
return record.ID
}
Change that to the correct field name:
func extractRecordID(record *model.RecordInput) string {
return record.CustomID
}
After regenerating the API code using go generate ./...
, the compile errors
will be gone and your field will be renamed.
#
Scenario 2: Merged ID
Another scenario might be that you have unique ID, but only within each data provider. The steps are similar to the first scenario.
Change the record schema:
input RecordInput {
provider: String!
providerRecordID: String!
# further fields
}
type Record @goModel(model:"my-tilores-project/graph/model.Record") {
provider: String!
providerRecordID: String!
# further fields
}
Change the extractRecordID
function to:
import "fmt"
func extractRecordID(record *model.RecordInput) string {
return fmt.Sprintf("%v-%v", record.Provider, record.ProviderRecordID)
}
#
Scenario 3: Generated Random IDs
If you have no fields available that can serve as the record ID, your only possibility left is to generate a random ID. The steps are similar to the first scenario.
Remove the ID field from the record schema:
input RecordInput {
# further fields
}
type Record @goModel(model:"my-tilores-project/graph/model.Record") {
# further fields
}
Change the extractRecordID
function to:
import "github.com/google/uuid"
func extractRecordID(_ *model.RecordInput) string {
return uuid.Must(uuid.NewRandom()).String()
}