# 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 differ.

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 rename or generate the ID field.

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.

# 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()
}