Create custom schema fields
Marten gives you the ability to create your own custom schema field implementations. Those can involve custom validations, behaviors, and errors. You can leverage these custom fields as part of your project's schema definitions, and you can even distribute them to let other projects use them.
Schema fields: scope and responsibilities
Schema fields have the following responsibilities:
- they define how field values are deserialized and serialized
- they define how these values are validated
When creating a custom schema field, there are usually two approaches that you can consider depending on the amount of customization that you want to implement. You can either:
- leverage an existing built-in schema field (eg. integer, string, etc) and add custom behaviors and validations to it
- or create a new schema field from scratch
Registering new schema fields
Regardless of the approach you take in order to define new schema field classes (subclassing built-in fields, or creating new ones from scratch), these classes must be registered to the Marten's global fields registry in order to make them available for use when defining schemas.
To do so, you will have to call the Marten::Schema::Field#register
method with the identifier of the field you wish to use, and the actual field class. For example:
Marten::Schema::Field.register(:foo, FooField)
The identifier you pass to #register
can be a symbol or a string. This is the identifier that is then made available to schema classes in order to define their fields:
class MySchema < Marten::Schema
field :title, :string
field :test, :foo
end
The call to #register
can be made from anywhere in your codebase, but obviously, you will want to ensure that it is done before requiring your schema classes: indeed, Marten will make the compilation of your project fail if it can't find the field type you are trying to use as part of a schema definition.
Subclassing existing schema fields
This is probably the easiest way to create a custom field: if the field you want to create can be derived from one of the built-in schema fields (usually those correspond to primitive types), then you can easily subclass the corresponding class and customize it so that it suits your needs.
For example, implementing a custom "email" field could be done by subclassing the existing Marten::Schema::Field::String
class. Indeed, an "email" field is essentially a string with a pre-defined maximum size and some additional validation logic:
class EmailField < Marten::Schema::Field::String
def initialize(
@id : ::String,
@required : ::Bool = true,
@max_size : ::Int32? = 254,
@min_size : ::Int32? = nil
)
@strip = true
end
def validate(schema, value)
return if !value.is_a?(::String)
# Leverage string's built-in validations (max size, min size).
super
if !EmailValidator.valid?(value)
schema.errors.add(id, "Provide a valid email address")
end
end
end
In the above snippet, the EmailField
class simply overrides the #validate
method so that it implements validation rules that are specific to the use case of email addresses (while also ensuring that regular string validations are executed as well).
Everything that is described in the following section about creating schema fields from scratch also applies to the case of subclassing existing schema fields: the same methods can be overridden if necessary, but leveraging an existing class can save you some work.
Creating new schema fields from scratch
Creating new schema fields from scratch involves subclassing the Marten::Schema::Field::Base
abstract class. Because of this, the new field class is required to implement a set of mandatory methods. These mandatory methods, and some other ones that are optional (but interesting in terms of capabilities), are described in the following sections.
Mandatory methods
deserialize
The #deserialize
method is responsible for deserializing a schema field value. Indeed, the raw value of a schema field usually comes from a request's data and needs to be converted to another format. For example, a uuid
field might need to convert a String
value to a proper UUID
object:
def deserialize(value) : ::UUID?
return if empty_value?(value)
case value
when Nil
value
when ::String
value.empty? ? nil : ::UUID.new(value)
when JSON::Any
deserialize(value.raw)
else
raise_unexpected_field_value(value)
end
rescue ArgumentError
raise_unexpected_field_value(value)
end
Fields can be configured as required or not (required
option), this means that you will usually want to handle the case of nil
values as part of this methods and return nil
if the incoming value is nil
. It should also be noted that incoming values can be any JSON data (JSON::Any
), which means that you need to handle this case properly as well.
If the value can't be processed properly by your field class, then it may be necessary to raise an exception. To do that you can leverage the #raise_unexpected_field_value
method, which will raise a Marten::Schema::Errors::UnexpectedFieldValue
exception.