Skip to main content

Customizing Schema

Exograph infers the mapping of a type to a table, which is often good enough. However, if you need to customize the mapping, Exograph provides a few annotations. This section will examine the default Exograph mapping and how to customize it.

Type-level customization

For a given type, Exograph infers:

  • The associated table name
  • The plural version of the type name (used to form query and mutation names).

Exograph sets the default access control rules to disallow anyone executing associated queries and mutations. You can customize this rule using the @access annotation, but we will defer to the Access Control section.

Table name

By default, Exograph uses the pluralized and snake_cased type name as the table name. For example, Exograph will map the Todo type to the todos table, while it will map AuthUser to the auth_users table. There is an exception to this rule if a type carries the @plural annotation, as we'll see next.

You can customize the table's name using the @table annotation, whose sole single argument specifies the table's name. For example, if you want to map the Todo to the t_todo table (typically to match your organization's naming conventions), you can use the @table annotation as follows:

@table("t_todo")
type Todo {
...
}

While the @table annotation affects the table name associated with the type, it leaves the query and mutation names unchanged. So, in the above example, the query to get multiple todos will still be called todos, which is most likely what you want. However, this doesn't work out well in a few cases. For example, consider the following setup:

@table("people")
type Person {
...
}

Exograph will map the Person to the people table here. However, the query to get multiple people will still be persons, which is not ideal. To change the query and mutation names along with the table name, you can use the @plural annotation.

Table schema

By default, Exograph assumes that a table will be in the public schema. You can customize its schema by specifying the schema attribute of the @table annotation. For example, to map the table User to be in the auth schema, you can use the @table annotation as follows:

@table(schema="auth")
type User {
...
}

The User type will be mapped to the auth schema, and the table name will be users (following the default naming convention). However, if you want to customize the table name as well, you can use the @table annotation as follows:

@table(schema="auth", name="t_users")
type User {
...
}

The User type will be mapped to the auth schema, and the table name will be t_users.

Pluralization

By default, Exograph will use a simple algorithm to pluralize the name of the type. However, it doesn't work well for names with irregular pluralization. For example, Exograph will pluralize person to persons, but you will likely want to name it people. You can control the plural form using the @plural annotation:

@plural("people")
type Person {
...
}

The @plural annotation's argument specifies the plural form of the type name. Exograph will use the provided plural form instead of its algorithm when this annotation is present. The plural name affects the table, query, and mutation names. For example, the @plural annotation above will map the Person type to the people table, the query to get multiple people will be people, and the mutation to delete multiple people will be deletePeople.

If both @plural and @table annotations are present, Exograph will use the argument provided to the @table annotation to name the table and the argument supplied to the @plural annotation to name the queries and mutations.

tip

Use the @plural annotation to deal with type names with irregular pluralization and the @table annotation to follow your organization's naming conventions.

Field-level customization

Exograph maps each field to a column in the database and infers a few other aspects of the column.

Specifying column name

Exograph infers the column's name as the snake_cased version of the field name for a scalar-type column. For example, Exograph will map the name field to the name column, while it will map ticketPrice to ticket_price.

You can customize the column's name using the @column annotation. For example, if you want to map the name field to the headline column, you can use the @column annotation as follows:

type Concert {
...
@column("headline") name: String
}

As discussed earlier, Exograph will infer a relationship between these two types. In the following example, it infers that the foreign key column in the Concert table is venue_id. It does so by appending _id to the field's name (in this case, venue).

@postgres
module ConcertModule {
type Concert {
...
venue: Venue
}

type Venue {
...
concerts: Set<Concert>?
}
}

If you want to customize the name of the foreign key column, you can use the @column annotation. For example, to map the venue field to the venue_pk_ column instead of venue_id, you can use the following setup:

@postgres
module ConcertModule {
type Concert {
...
@column("venue_pk") venue: Venue
}

type Venue {
...
@column("venue_pk") concerts: Set<Concert>?
}
}

If you change the name of the foreign key column in the Venue type, you must also change the name of the foreign key column in the Concert type. This way, the column names guide Exograph to infer the relationship between the two types.

Assigning primary key

The @pk annotation designates the primary key of a type. The current implementation of Exograph only supports a single primary key (we will lift this restriction in the future):

Auto-incrementing primary key

To use the primary key of integer type, specify the field to be of the Int type and set its default value of autoIncrement(). In the following example, the id field is the primary key, and it will be automatically assigned a value when you create a new concert. Behind the scenes, Exograph will use the SERIAL type in PostgreSQL by default, but you can customize it to use SMALLSERIAL or BIGSERIAL through the @dbtype annotation, as we will see later.

type Concert {
@pk id: Int = autoIncrement()
...
}

With this arrangement, clients cannot specify the value of the id field when creating a concert. Exograph will automatically assign a value to the id field.

Auto-generated Uuid key

To use the primary key of Uuid type, specify the field's type to be Uuid type with the default value of generate_uuid(). In the following example, the id field is the primary key, and it will be automatically assigned a value when you create a new concert. Behind the scenes, Exograph will use the UUID type in PostgreSQL.

type Concert {
@pk id: Uuid = generate_uuid()
...
}

User-assignable primary key

Auto-generated keys will be the most common form in your model. However, sometimes, you may want to assign a client-specifiable value. For example, you may want to let clients generate a UUID and use it when creating a new entity. You may also need user-assignable primary keys for integration with other systems where you want to sync the primary keys across systems.

To make the primary key user-assignable, skip the default value as follows.

type Venue {
@pk id: Int
...
}

Specifying a default value

The default value of a column is specified using an assignment in the field definition. For example, as we have seen in the previous section, you can set the default value of an Int field to autoIncrement() to make it auto-incrementing and the default value of a Uuid field to generate_uuid() to make it auto-generated.

Similarly, you can set the default value of a scalar column to a constant value. For example, if you want to set the default value of the price field to 50, you can use the following definition:

type Concert {
...
price: Float = 50
}

Setting a default value affects mutations associated with the type. When creating a new concert, the price field will be optional, and the default value will be used if you don't specify a value.

You can set the default value to now() for all date and time field types. For example, if you want to set the default value of the createdAt field to the current time, you can use the following definition:

type Concert {
...
createdAt: Instant = now()
}

When you create a new concert, the createdAt field will be set to optional, and Exograph will use =the current time if you don't specify a value.

Marking read-only

In the previous example, the intention seems to set the createdAt field to the creation time. However, this requires clients to not specify a value for the createdAt field in createConcert or createConcerts API. Furthermore, it can be changed to another value when updating the concert (the field remains part of the updateConcert and updateConcerts API). Therefore, this arrangement doesn't reflect the intention.

Exograph provides the @readonly annotation to make a field read-only for such situations. For example, you can make the createdAt field read-only using the following definition:

type Concert {
...
@readonly createdAt: Instant = now()
}

Now, the createdAt field will not be part of the createConcert, createConcerts, updateConcert, and updateConcertsAPIs. As a result, the field's value will always be the creation time.

Note that a field marked with the @readonly annotation must also have a default value.

Updating automatically

What if you want to introduce another field updatedAt that should be set automatically to the current time whenever the concert is updated? Exograph provides the @update annotation to achieve this. For example, you can make the updatedAt field update automatically using the following definition:

type Concert {
...
@update updatedAt: Instant = now()
}

Every time a client updates a concert, Exograph will set the updatedAt field to its default value, now()—the current time.

Like the @readonly annotation, an @update field must have a default value. The default value usually is a function (like now()) that generates the value at runtime. It can be constant, but a plain @readonly is more appropriate. An @update field is also implicitly considered read-only. Consequently, it will not be part of the createConcert, createConcerts, updateConcert, and updateConcerts APIs.

You may use the @update annotation to track other aspects. For example, if you want to set the last modified version, you can use the @update annotation as follows:

type Concert {
...
@update modificationVersion: Uuid = generate_uuid()
}

Whenever a client updates a concert, Exograph will set the modificationVersion field to a new UUID.

Controlling Nullability

Exograph will make the column nullable if the field is optional. You can control nullability by adding the ? suffix to the field type. For example, if you want to make the name field non-nullable, you can use the following definition:

type TicketPrice {
...
price: Float
details: String?
}

Here, the database schema will have the price field as non-nullable and the details field as nullable.

Constraining Uniqueness

Often, you want to set a constraint on a field to make it unique. You may use the @unique annotation for this purpose. For example, if you want to make sure that the name field is unique, you can use the @unique annotation:

type Concert {
...
@unique name: String
}

Here, Exograph will set a database uniqueness constraint on the name column in the generated schema.

Uniqueness and queries

Exograph infers specialized queries to get an entity by unique fields. We will explore this in the queries section.

If you want to mark a specific combination of fields as unique, you can use the @unique annotation by specifying a name for the unique constraint. For example, if you want to ensure that the combination of emailId and emailDomain is unique, you can use the @unique annotation specifying a name for the unique constraint:

type Person {
...
@unique("email") emailId: String
@unique("email") emailDomain: String
}

You can pass an array of field names to the @unique annotation to specify a unique constraint. For example, if you want to make sure that the combination of primaryEmailId and emailDomain as well as the combination secondaryEmailId and emailDomain is unique, you can use the @unique annotation specifying names for the unique constraint:

type Person {
@unique("primary_email") primaryEmailId: String
@unique("secondary_email") secondaryEmailId: String?
@unique("primary_email", "secondary_email") emailDomain: String
}

Since we use the name primary_email for the primaryEmailId and the emailDomain fields, that combination will be marked unique. We do the same for the secondaryEmailId and the emailDomain fields.

Adding Index

It is a common practice to set up indexes on columns to speed up queries. While indexes speed up queries, they slow down inserts and updates. So, you should analyze the usage pattern of your application and create indexes accordingly.

By default, Exograph will not set up any explicit indexes. However, note that Postgres will set up indices for primary key columns and those with a uniqueness constraint (see @unique above). Exograph offers the @index annotation to allow you to set up appropriate indices. The @index annotation follows the same syntax as the @unique annotation. For example, if you want to create an index on the age column, you can use the @index annotation as follows:

type Person {
...
@index age: Int
}

Here, Exograph will create an index named person_age_idx on the age column. If you want to control the name of the index, you can use the @index annotation as follows:

type Person {
...
@index("person_age_index") age: Int
}

Suppose the application's usage pattern suggests that you must create an index on a combination of fields (typically, a frequent query that supplies a where clause with multiple fields). You can use the @index annotation by specifying a name for the index. For example, if you want to create an index on the combination of firstName and lastName, you can use the @index annotation specifying a name for the index:

type Person {
...
@index("person_name") firstName: String
@index("person_name") lastName: String
}

Here, Exograph will create an index with the name person_name on the columns for the firstName and lastName fields.

Like the @unique annotation, you can pass an array of field names to the @index annotation to specify an index. For example, suppose you need to create an index on the combination of firstName and lastName and those fields individually. You can use the @index annotation specifying names for the index:

type Person {
...
@index("person_name", "person_first_name") firstName: String
@index("person_name", "person_last_name") lastName: String
}

Here, Exograph will set up three indices: one on the firstName field, one on the lastName field, and one on the combination of the firstName and lastName fields.

Customizing field type

Exograph infers the column type based on the field type. For example, if the field type is String, the column type will be inferred as TEXT. However, you may want more precise control over the database column type. Exograph offers a few annotations for this purpose.

Explicit column type

If you need to control the mapping of a database column type directly, you can use the @dbtype annotation. For example, if you want to set the column type of the name field to VARCHAR(100), you can use the @type annotation:

@dbtype("VARCHAR(100)") name: String

The @dbtype annotation takes a string argument: the column type. You can use any valid PostgreSQL type compatible with the field type. For example, if you want to set the column type of the price field to SMALLINT, you can use the @dbtype annotation as follows:

@dbtype("SMALLINT") price: Int
warning

Ensure that the type you specify is compatible with the field type. For example, if you specify the type as VARCHAR(100) for a field of type Int, the generated schema will be invalid.

The @dbtype annotation offers low-level control over the column type. Exograph also provides a few type-specific annotations. These annotations indirectly have the same effect as using the @dbtype annotation but offer a more convenient way to customize the column type.

warning

Specifying both the @dbtype and the type-specific annotations is an error.

String field type

By default, the column type will allow the string to be of any length (to the extent the database allows it). However, you can use the @maxLength annotation to restrict it. For example, if you want to limit the length of the name field to 100 characters, you can use the @maxLength annotation:

@maxLength(100) description: String

Here, the column type will be set to VARCHAR(100) (instead of TEXT).

Integer field type

Exograph offers a few annotations for integer fields to customize the column type. They all have the effect of setting the column type to one of SMALLINT, INTEGER, or BIGINT in addition to setting constraints on the value of the field. The annotations are:

  • @bits16, @bits32, and bits64: These annotations specify the number of bits in the integer. For example, if you want to set the column type of the mask field to contain only 16 bits, you can use the @bits* annotation as follows:
@bits16 mask: Int

You can specify only one of the @bits* annotations.

  • @range: This annotation specifies the range of the integer. It takes two arguments: the minimum and the maximum value. For example, if you want to set the column type of the age field to 0 to 200, you can use the @range annotation:
@range(min = 0, max = 200) age: Int

You may use the @range annotation with one of the @bits* annotations. Exograph will infer the column type based on the range and integer size.

Float field type

Exograph offers the @singlePrecision and @doublePrecision annotations for float fields to customize the column type. They all have the effect of setting the column type to REAL or DOUBLE PRECISION.

Decimal field type

Floating point numbers are OK if you can tolerate some loss of precision. However, you can use the Decimal type to store the exact decimal numbers. For example, if you want to store the price of a concert ticket, you can use the Decimal type. For this type, Exograph offers the following annotations to customize the column type:

  • @precision: This annotation specifies the total number of digits in the decimal number.

  • @scale: This annotation specifies the number of digits after the decimal point. For example, if you want to set the column type of the price field to 2 digits after the decimal point, you can use the @scale annotation:

@precision(5) @scale(2) price: Decimal

The above will set the column type to NUMERIC(5, 2) and allow numeric values between 0.00 and 999.99. See the PostgreSQL documentation for more details.

If you specify @scale, you must also specify @precision.

Date and Time field type

For date and time fields (LocalDateTime, LocalDate, and Instance), Exograph offers the @precision, which then maps it to Postgres's precision. See the PostgreSQL documentation for more details.