Skip to main content
Version: Next

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.

serialize

The #serialize method is responsible for serializing a field value, which is essentially the reverse of the #deserialize method. As such, this method must convert a field value from the "Crystal" representation to the "raw" schema representation.

For example, this method could return the string representation of a UUID object:

def serialize(value) : ::String?
value.try(&.to_s)
end

Again, if the value can't be processed properly by the field class, 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.

Other useful methods

initialize

The default #initialize method that is provided by the Marten::Schema::Field::Base is fairly simply and looks like this:

def initialize(
@id : ::String,
@required : ::Bool = true
)
end

Depending on your field requirements, you might want to override this method completely in order to support additional parameters (such as default validation-related options for example).

validate

The #validate method does nothing by default and can be overridden on a per-field class basis in order to implement custom validation logic. This method takes the schema object being validated and the field value as arguments, which allows you to easily run validation checks and to add validation errors to the schema object.

For example:

def validate(schema, value)
return if !value.is_a?(::String)

if !EmailValidator.valid?(value)
schema.errors.add(id, "Provide a valid email address")
end
end

An example

Let's consider the use case of the "email" field highlighted in Subclassing existing schema fields. The exact same field could be implemented from scratch with the following snippet:

class EmailField < Marten::Schema::Field::Base
getter max_size
getter min_size

def initialize(
@id : ::String,
@required : ::Bool = true,
@max_size : ::Int32? = 254,
@min_size : ::Int32? = nil,
@strip : ::Bool = true
)
end

def deserialize(value) : ::String?
strip? ? value.to_s.strip : value.to_s
end

def serialize(value) : ::String?
value.try(&.to_s)
end

def strip?
@strip
end

def validate(schema, value)
return if !value.is_a?(::String)

if !min_size.nil? && value.size < min_size.not_nil!
schema.errors.add(id, "The minimum allowed length is #{min_size} characters")
end

if !max_size.nil? && value.size > max_size.not_nil!
schema.errors.add(id, "The maximum allowed length is #{max_size} characters")
end

if !EmailValidator.valid?(value)
record.errors.add(id, "Provide a valid email address")
end
end
end