Mapping
Several model examples
class Contact < Jennifer::Model::Base
with_timestamps
mapping(
id: Primary64, # same as {type: Int64, primary: true}
name: String,
gender: {type: String?, default: "male"},
age: {type: Int32, default: 10},
description: String?,
created_at: Time?,
updated_at: Time | Nil
)
has_many :addresses, Address
has_many :facebook_profiles, FacebookProfile
has_and_belongs_to_many :countries, Country
has_and_belongs_to_many :facebook_many_profiles, FacebookProfile, join_foreign: :profile_id
has_one :main_address, Address, {where { _main }}
has_one :passport, Passport
validates_inclusion :age, 13..75
validates_length :name, minimum: 1, maximum: 15
validates_with_method :name_check
scope :main { where { _age > 18 } }
scope :older { |age| where { _age >= age } }
scope :ordered { order(name: :asc) }
def name_check
if @description && @description.not_nil!.size > 10
errors.add(:description, "Too large description")
end
end
end
class Address < Jennifer::Model::Base
mapping(
id: {type: Int64, primary: true},
main: Bool,
street: String,
contact_id: Int64?,
details: JSON::Any?
)
validates_format :street, /st\.|street/
belongs_to :contact, Contact
scope :main { where { _main } }
end
class Passport < Jennifer::Model::Base
mapping(
enn: {type: String, primary: true},
contact_id: {type: Int64, null: true}
)
validates_with EnnValidator
belongs_to :contact, Contact
end
class Profile < Jennifer::Model::Base
mapping(
id: Primary64,
login: String,
contact_id: Int64?,
type: String
)
belongs_to :contact, Contact
end
class FacebookProfile < Profile
mapping(
uid: String
)
has_and_belongs_to_many :facebook_contacts, Contact, foreign: :profile_id
end
class TwitterProfile < Profile
mapping(
email: String
)
end
class Country < Jennifer::Model::Base
mapping(
id: Primary64,
name: String
)
validates_exclusion :name, ["asd", "qwe"]
validates_uniqueness :name
has_and_belongs_to_many :contacts, Contact
end
Mapping definition
You should define all fields that you’d like to grep from the particular table, other words - define model’s mapping.
.mapping(options, strict = true)
macro stands for defining all model attributes. If field has no extra parameter,
you can just specify name and type (type in case of crystal language): field_name: :Type
. Named tuple can be used
instead of type. Next keys are supported:
argument | description |
---|---|
:type |
crystal data type |
:primary |
mark field as primary key (default is false ) |
:null |
allows field to be nil (default is false for all fields except primary key |
:default |
default value which will be set during creating new object |
:column |
database column name associated with this attribute (default is attribute name) |
:getter |
if getter should be created (default - true ) |
:setter |
if setter should be created (default - true ) |
:virtual |
mark field as virtual - will not be stored and retrieved from DB |
:generated |
field represents generated column (is only read from the DB) |
:converter |
class/module/object that is used to serialize/deserialize field |
:auto |
indicate whether primary field is autoincrementable (by default true for Int32 and Int64 ) |
Every .mapping
call generates type alias AttrType
which is a union of Jennifer::DBAny
and any used arbitrary type.
To make some field nillable tou can use any of the next options:
- pass
null: true
option to the named tuple - use
?
in type declaration (e.g.some_field: String?
orsome_filed: {type: String?}
) - use union with
Nil
in the type declaration (e.g.some_field: String | Nil
orsome_filed: {type: String | Nil}
)
If you don’t want to define all the table fields - pass false
as second argument (this will disable default strict mapping mode).
.mapping
defines next methods:
method | args | description |
---|---|---|
.new |
Hash(String \| Symbol, DB::Any), NamedTuple, MySql::ResultSet |
constructors |
.field_count |
number of fields | |
.field_names |
all fields names | |
._{{field_name}} |
helper method for building queries | |
.coerce_{{field_name}} |
String |
coerces string to field_name type |
.primary |
returns criterion for primary field (query DSL) | |
.primary_field_name |
name of primary field | |
.create |
Hash(String \| Symbol, DB::Any) , NamedTuple |
creates object, stores it to db and returns it |
.create! |
Hash(String \| Symbol, DB::Any) , NamedTuple |
creates object, stores it to db and returns it; otherwise raise exception |
.build |
Hash(String \| Symbol, DB::Any), NamedTuple |
builds object |
.create |
Hash(String \| Symbol, DB::Any) , NamedTuple |
builds object from hash and saves it to db with all callbacks |
.create! |
Hash(String \| Symbol, DB::Any) , NamedTuple |
builds object from hash and saves it to db with callbacks or raise exception |
#{{field_name}} |
getter | |
#{{field_name}}_changed? |
presents whether field is changed | |
#{{field_name}}! |
getter with not_nil! if null: true was passed |
|
#{{field_name}}= |
setter | |
#{{field_name}}_changed? |
shows if field was changed | |
#new_record? |
returns true if record has nil primary key (is not stored to db) |
|
#changed? |
shows if any field was changed | |
#primary |
value of primary key field | |
#save |
saves object to db; returns true if success and false elsewhere |
|
#save! |
saves object to db; returns true if success or rise exception otherwise |
|
#to_h |
returns hash with all attributes | |
#to_str_h |
same as #to_h but with String keys |
|
#attribute |
String \| Symbol |
returns attribute value by it’s name |
#changed? |
check if any field was changed | |
#set_attribute |
String \| Symbol , DB::Any |
sets attribute by given name |
#attribute |
String \| Symbol |
returns attribute value by it’s name |
Also #{{field_name}}?
predicate method for the case when it is boolean.
All allowed types are listed on the Migration page.
All defined mapping properties are accessible via COLUMNS_METADATA
constant and .columns_tuple
method.
It may be useful to have one parent class for all your models - just make it abstract and everything will work well:
abstract class ApplicationRecord < Jennifer::Model::Base
end
class SomeModel < ApplicationRecord
mapping(
id: Int64,
name: String
)
end
Important restrictions:
- models currently must have a
primary
field. - if your model also uses
JSON.mapping
,JSON::Serializable
or other kinds of mapping macros, you must be careful to use Jennifer’smapping
macro last in order for all model features to work correctly.
class User < Jennifer::Model::Base
# JSON.mapping used *before* model mapping:
JSON.mapping(id: Int64, name: String)
# Model mapping used last:
mapping(id: Primary64, name: String)
end
Converters
To define a field converter create a class/module which implements next static methods:
.from_db(DB::ResultSet, NamedTuple)
- converts field reading it from db result set;.to_db(T, NamedTuple)
- converts field to the db format;.from_hash(Hash(String, Jennifer::DBAny | T), String, NamedTuple)
- converts field (which name is the 2nd argument) from the given hash (this method is called only if hash has required key).
As an optional feature it also can implement .coerce(String, NamedTuple)
method to be used for coercing. Doing this custom string parsing mechanism can be specified (for reference see Jennifer::Model::TimeZoneConverter
).
Jennifer::Model::TimeZoneConverter
This is default converter for Time
field class - is applied automatically if other isn’t specified. It converts time object from UTC time to application time zone.
Next additional options can be specified in a field mapping:
time_zone_aware: Bool
- disable time zone coverting for the fieldtime_format: String
- custom time format that will be used to parse time-only strings (%H:%M
by default)date_format: String
- custom date format that will be used to parse date-only string (%F
by default)date_time_format: String
- custom date-time format that will be applied to parse date-time string (%F %T
by default).
Also it is possible to customize how converter determines whether string is time/date/date-time. To do some you can inherit from Jennifer::Model::TimeZoneConverter
converter and customize .time?
and .date_time?
methods.
Also you can manky-patch it
Jennifer::Model::JSONConverter
This is default converter for JSON::Any
field class - is applied automatically if other isn’t specified. It takes care of JSON-string to JSON::Any
conversion.
Jennifer::Model::EnumConverter
This converter allows to map enums to strings and back:
enum Category
GOOD
BAD
end
class Note < Jennifer::Model::Base
mapping(
category: {type: Category?, converter: Jennifer::Model::EnumConverter(Category)}
)
end
Jennifer::Model::BigDecimalConverter(T)
Converts numeric database type to BigDecimal
value which allows to perform operations with specific scale. It is expected that Float64
or PG::Numeric
are used as an argument T
.
It expects next options to be specified in a field mapping:
scale: Int32
- the count of decimal digits in the fractional part, to the right of decimal point.
class Order < Jennifer::Model::Base
mapping(
# ...
total: { type: BigDecimal?, converter: Jennifer::Model::BigDecimalConverter(PG::Numeric), scale: 2 }
# for MySQL use Float64
)
end
Jennifer::Model::JSONSerializableConverter(T)
converts JSON to T
class. T
class should includes `JSON::Serializable.
class Location
include JSON::Serializable
property latitude : Float64
property longitude : Float64
end
class User < Jennifer::Model::Base
mapping(
# ...
location: { type: Location, converter: Jennifer::Model::JSONSerializableConverter(Location) }
)
end
Jennifer::Model::NumericToFloat64Converter
Converts PG::Numeric
to Float64
. Can be used only when PostgreSQL adapter is used.
Jennifer::Model::PgEnumConverter
Converts ENUM
database column type value to String
. Can be used only when PostgreSQL adapter is used.
Arbitrary type
Model field can be of any type it is required to. But to achieve this you should specify corresponding converter to serialize/deserialize value to/from database format. One of the most popular examples is “embedded document” - JSON field that has known mapping and is mapped to crystal class.
For example see Jennifer::Model::JSONSerializableConverter(T) section above.
Now instances of Location
class can be used in all constructors/setters/update methods. The only exception is query methods - they support only Jennifer::DBAny
values.
Mapping Types
Jennifer has built-in system of predefined options for some usage. They are not data types on language level (you can’t defined variable of Primary64
type) and can be used only in mapping definition (standard usage).
class Post < Jennifer::Model::Base
mapping(
id: Primary64,
# or even with full definition
pk: {type: Primary64, primary: false, virtual: true}
)
end
All overrides from full definition will be respected and used instead of predefined for such a type.
To defined your own type define it such a way it may be lexically accessible from place you want to use it:
class ApplicationRecord < Jennifer::Model::Base
EmptyString = {
type: String,
default: "",
}
{% TYPES << "EmptyString" %}
# or if this is outside of model or view scope
{% ::Jennifer::Macros::TYPES << "EmptyString" %}
end
Obviously, registered type added to the
TYPES
should be the same as defined constant; also it should be stringified.
Existing mapping types:
Primary32 = { type: Int32, primary: true }
Primary64 = { type: Int64, primary: true }
Password = { type: String?, virtual: true, setter: false }
Virtual and generated attributes
If you pass virtual: true
option for some field - it will not be stored to db and tried to be retrieved from there. Such behavior is useful if you have model-level attributes but it is not obvious to store them into db. Such approach allows mass assignment and dynamic get/set based on their name.
class User < Jennifer::Model::Base
mapping(
id: Primary64,
password_hash: String,
password: {type: String?, virtual: true},
password_confirmation: {type: String?, virtual: true}
)
validate_confirmation :password
before_create :crypt_password
def crypt_password
self.password_hash = SomeCryptAlgorithm.call(self.password)
end
end
User.create!(password: "qwe", password_confirmation: "qwe")
It is important to distinguish between virtual and generated attributes. Generated attributes belongs to the database level (they are created using GENERATED ALWAYS AS
statements). As their values are handled by database itself we should only read them from database but don’t write. To achieve this use generated: true
.
class User < Jennifer::Model::Base
mapping(
id: Primary64,
first_name: String,
last_name: String,
full_name: { type: String?, generated: true}
)
end
For convenient generated: true
doesn’t disable setter from being generated, but you can do this explicitly by setter: false
option.
Some database versions doesn’t support this feature, e.g.
postgresql >= 12.0
starts to supported stored generated columns.
Table name
By default model determines related table name by underscoring and pluralizing own class name. In the case when model is define under some namespace, it’s underscored name is considered as table name prefix.
User.table_name # "users"
API::Admin::User.table_name # "api_admin_users"
To override table name prefix define own .table_prefix
module Admin
class Base < Jennifer::Model::Base
def self.table_prefix
"private_"
end
end
class User < Base
mapping(id: Primary32)
end
end
Admin::User.table_name # "private_users"
As you see
.table_prefix
should return"_"
at the end to keep naming across application consistent.
Also to prevent adding table prefix at all - return
nil
.
To override table name just call .table_name
:
class User < Jennifer::Model::Base
table_name :posts
# ...
end
class Admin::User < Jennifer::Model::Base
table_name "users"
end
User.table_name # "posts"
Admin::User.table_name # "users"
.table_name
accepts table name that already includes prefix.
Converting form Web options
In the Web world all data got submitted forms will be recognized as Hash(String, String)
which is not acceptable by your models. To resolve this use form_object shard.