Skip to main content
Version: Next

Create custom model fields

Marten gives you the ability to create your own custom model field implementations, which can involve custom validation logics, errors, and custom behaviors. You can choose to leverage these custom fields as part of your project's model definitions, and you can even distribute them to let other projects use them.

Model fields: scope and responsibilities

Model fields have the following responsibilities:

  • they define the type and properties of the underlying column at the database level
  • they define the necessary Crystal bindings at the model class level: this means that they can contribute custom methods or instance variables to the models making use of them (eg. getters, setters, etc)
  • they define how field values are validated and/or sanitized

Creating a custom model field does not necessarily mean that all of these responsibilities need to be taken care of as part of the custom field implementation. It really depends on whether you want to:

  • leverage an existing built-in model field (eg. integer, string, etc)
  • or create a new model field from scratch.

Registering new model fields

Regardless of the approach you take in order to define new model 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 models.

To do so, you will have to call the Marten::DB::Field#register method with the identifier of the field you wish to use, and the actual field class. For example:

Marten::DB::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 model classes in order to define their fields:

class MyModel < Marten::DB::Model
field :id, :big_int, primary_key: true, auto: true
field :test, :foo, blank: true, null: true
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 model 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 model definition.

Subclassing existing model fields

The easiest way to introduce a model field is probably to subclass one of the built-in model fields provided by Marten. This can make a lot of sense if the "type" of the field you are trying to implement is already supported by Marten.

For example, implementing a custom "email" field could be done by subclassing the existing Marten::DB::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::DB::Field::String
def initialize(
@id : ::String,
@max_size : ::Int32 = 254,
@primary_key = false,
@default : ::String? = nil,
@blank = false,
@null = false,
@unique = false,
@index = false,
@db_column = nil
)
end

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

# Leverage string's built-in validations (max size).
super

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

macro check_definition(field_id, kwargs)
# No-op max_size automatic checks...
end
end

Everything that is described in the following section about creating model fields from scratch also applies to the case of subclassing existing model fields: the same methods can be overridden if necessary, but leveraging an existing class can save you some work.

Creating new model fields from scratch

Creating new model fields from scratch involves subclassing the Marten::DB::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

default

The #default method is responsible for returning the field's default value, if any. Not all fields support default values; if this does not apply to your field use case, you can simply "no-op" this method:

def default
# no-op
end

On the other hand, if your field can be initialized with a default argument (and if it defines a @default instance variable), another possibility is to define a #default getter:

getter default

from_db

The #from_db method is responsible for converting the passed raw DB value to the right field value. Indeed, the value that is read from the database will usually need to be converted to another format. For example, a uuid field might need to convert a String value to a proper UUID object:

def from_db(value) : ::UUID?
case value
when Nil
value.as?(Nil)
when ::String
::UUID.new(value.as(::String))
when ::UUID
value.as(::UUID)
else
raise_unexpected_field_value(value)
end
end

It should be noted that you will usually want to handle the case of nil values as part of this method since fields can be configured as nullable via the null: true option.

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::DB::Errors::UnexpectedFieldValue exception.

from_db_result_set

The #from_db_result_set method is responsible for extracting the field value from a DB result set and returning the right object corresponding to this value. This method will usually be called when retrieving your field's value from the database (when using the Marten ORM). The method takes a standard DB::ResultSet object as argument and it is expected that you use #read to retrieve the intended column value. See the Crystal reference documentation for more details around these objects and methods.

For example:

def from_db_result_set(result_set : ::DB::ResultSet) : ::UUID?
from_db(result_set.read(Nil | ::String | ::UUID))
end

The #from_db_result_set method is supposed to return the read value into the right "representation", that is the final object representing the field value that users will interact with when manipulating model records (for example a UUID object created from a string). As such, you will usually want to call #from_db once you get the value from the database result set in order to return the final value.

to_column

Most model fields will contribute a corresponding column at the database level; these columns are read by Marten in order to generate migrations from model definitions. The column returned by the #to_column method should be an instance of a subclass of Marten::DB::Management::Column::Base.

For example, an "email" field could return a string column as part of its #to_column method:

def to_column : Marten::DB::Management::Column::Base?
Marten::DB::Management::Column::String.new(
name: db_column!,
max_size: max_size,
primary_key: primary_key?,
null: null?,
unique: unique?,
index: index?,
default: to_db(default)
)
end

If for some reason your custom field does not contribute any columns to the database model, it is possible to simply "no-op" the #to_column method by returning nil instead.

to_db

The #to_db method converts a field value from the "Crystal" representation to the database representation. As such, this method performs the reverse operation of the #from_db method.

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

def to_db(value) : ::DB::Any
case value
when Nil
nil
when ::UUID
value.hexstring
else
raise_unexpected_field_value(value)
end
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::DB::Errors::UnexpectedFieldValue exception.

Other useful methods

initialize

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

def initialize(
@id : ::String,
@primary_key = false,
@blank = false,
@null = false,
@unique = false,
@index = false,
@db_column = nil
)
end

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

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 model record being validated and the field value as arguments, which allows you to easily run validation checks and to add validation errors to the model record.

For example:

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

if !EmailValidator.valid?(value)
record.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 model fields. The exact same field could be implemented from scratch with the following snippet:

class EmailField < Marten::DB::Field::Base
getter default
getter max_size

def initialize(
@id : ::String,
@max_size : ::Int32 = 254,
@primary_key = false,
@default : ::String? = nil,
@blank = false,
@null = false,
@unique = false,
@index = false,
@db_column = nil
)
end

def from_db(value) : ::String?
case value
when Nil | ::String
value.as?(Nil | ::String)
else
raise_unexpected_field_value(value)
end
end

def from_db_result_set(result_set : ::DB::ResultSet) : ::String?
result_set.read(::String?)
end

def to_column : Marten::DB::Management::Column::Base?
Marten::DB::Management::Column::String.new(
name: db_column!,
max_size: max_size,
primary_key: primary_key?,
null: null?,
unique: unique?,
index: index?,
default: to_db(default)
)
end

def to_db(value) : ::DB::Any
case value
when Nil
nil
when ::String
value
when Symbol
value.to_s
else
raise_unexpected_field_value(value)
end
end

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

if value.size > @max_size
record.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