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