Version 5.0

Core » Schemas

Schemas define explicit attribute names and types within a relation. All adapters support relation schemas, and adapter-specific extensions can be provided as well, for example rom-sql extends schema DSL with support for database-specific types.

Apart from adapter-specific extensions, schemas can be extended by you since you can define your own types as well as your own custom methods available on attribute objects.

Why?

First of all, because schemas give an explicit definition for the data structures a given relation returns.

Both relations and commands use schemas to process data, this gives you type-safe commands out-of-the-box, with optional ability to perform low-level database coercions (like coercing a hash to a PG hash etc.), as well as optional coercions when reading data.

Furthermore, schemas can provide meta-data that can be used to automate many common tasks, like generating relations automatically for associations.

Defining schemas explicitly

The DSL is simple. Provide a symbol name with a type from the Types module:

class Users < ROM::Relation[:http]
  schema do
    attribute :id, Types::Int
    attribute :name, Types::String
    attribute :age, Types::Int
  end
end

Inferring schemas

If the adapter that you use supports inferring schemas, your schemas can be defined as:

class Users < ROM::Relation[:sql]
  schema(infer: true)
end

You can also override inferred attributes:

class Users < ROM::Relation[:sql]
  schema(infer: true) do
    # this overrides inferred :meta attribute
    attribute :meta, Types::MyCustomMetaType
  end
end

Types namespace

All builtin types are defined in ROM::Types namespace, and individual adapters may provide their own namespace which extends the builtin one. For example rom-sql provides ROM::SQL::Types and ROM::SQL::Types::PG.

Primary keys

You can set up a primary key, either a single attribute or a composite:

class Users < ROM::Relation[:http]
  schema do
    attribute :id, Types::Int
    attribute :name, Types::String
    attribute :age, Types::Int

    primary_key :id
  end
end

For a composite primary key, pass the relevant attribute names:

class UsersGroups < ROM::Relation[:http]
  schema do
    attribute :user_id, Types::Int
    attribute :group_id, Types::Int

    primary_key :id, :group_id
  end
end
Info

primary_key is a shortcut for the annotation: Types::Int.meta(primary_key: true)

Foreign Keys

You can set up foreign keys pointing to a specific relation:

class Posts < ROM::Relation[:http]
  schema do
    attribute :user_id, Types::ForeignKey(:users)
    # defaults to `Types::Int` but can be overridden:
    attribute :user_id, Types::ForeignKey(:users, Types::UUID)
  end
end
Info

foreign_key is a shortcut for the annotation: Types::Int.meta(foreign_key: true, relation: :users)

Annotations

Schema types provide an API for adding arbitrary meta-information. This is mostly useful for adapters, or anything that may need to introspect relation schemas.

Here's an example:

class Users < ROM::Relation[:http]
  schema do
    attribute :name, Types::String.meta(namespace: 'details')
  end
end

Here we defined a :namespace meta-information, that can be used accessed via :name type:

Users.schema[:name].meta[:namespace] # 'details'

Using write types

Relations commands will automatically use schema attributes when processing the input. This allows us to perform database-specific coercions, setting default values or applying low-level constraints.

Let's say our setup requires generating a UUID prior executing a command:

class Users < ROM::Relation[:http]
  UUID = Types::String.default { SecureRandom.uuid }

  schema do
    attribute :id, UUID
    attribute :name, Types::String
    attribute :age, Types::Int
  end
end

Now when you persist data using repositories or commands, your schema will be used to process the input data, and our :id value will be handled by the UUID type.

Using read types

Apart from write types, you can also specify read types, these are used by relations when they read data from a database. You can define them using :read option:

class Users < ROM::Relation[:http]
  schema do
    attribute :id, Types::Serial
    attribute :name, Types::String
    attribute :birthday, Types::String, read: Types::Coercible::Date
  end
end

Now when Users relation reads it data, birthday values will be processed via Types::Coercible::Date.

Type System

Schemas use a type system from dry-types and you can define your own schema types however you want. What types you need really depends on your application requirements, the adapter you're using, specific use cases of your application and so on.

Here are a couple of guidelines that should help you in making right decisions:

  • Don't treat relation schemas as a complex coercion system that is used against data received at the HTTP boundary (ie rack request params)
  • Coercion logic for input should be low-level (eg. Hash => PGHash in rom-sql)
  • Default values should be used as a low-level guarantee that some value is always set before making a change in your database. Generating a unique id is a good example. For default values that are closer to your application domain it's better to handle this outside of the persistence layer. For example, setting draft as the default value for post's :status attribute is part of your domain more than it is part of your persistence layer.
  • Strict types can be used and they will raise TypeError when invalid data was accidentally passed to a command. Use this with caution, typically you want to validate the data prior sending them to a command, but there might be use cases where you expect data to be valid already, and any type error is indeed an exception and you want your system to crash

Learn more

You can learn more about adapter-specific schemas: