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