A Model is an object used to map a corresponding database table to a class. These objects model real-world objects to give you a better understanding on how they should interact within your application.
Models in Lucky allow you to define methods associated with each column in the table. These methods return the value set in that column.
Avram models also generate other classes you can use to save new records, and query existing ones.
Lucky gives you a task for generating a model along with several other files that you will need for interacting with your database.
Use the lucky gen.model {ModelName}
task to generate your model. If you’re generating a
User
model, you would run lucky gen.model User
. Running this will generate a few files for you.
./src/models/user.cr
./src/operations/save_user.cr
./src/queries/user_query.cr
./db/migrations/20241221121203_create_users.cr
You can even supply the model generator with typed columns, and Lucky will take care of adding those to the appropriate templates, too:
lucky gen.model User name:String email:String age:Int32 admin:Bool
Once you run the model generator, you’ll have a file that looks like this
# src/models/user.cr
class User < BaseModel
table do
# You will define columns here. For example:
# column name : String
end
end
Your model will inherit from BaseModel
which is an abstract class that
defines what database this model should use, and optionally customizes
the default columns a model has. You can also use BaseModel
to define
methods all of your models should have access to.
Next you’ll see the table
block that defines which table this model is
connected to and what columns are added.
By default the table
macro will use the underscored and pluralized
version of the model’s class name. So CompletedProject
would have the
table name :completed_projects
.
class CompletedProject < BaseModel
# Will use :completed_projects as the table name
table do
end
end
However, if you want to use a different table name you can provide to to the
table
macro:
class CompletedProject < BaseModel
table :legacy_completed_projects do
end
end
Models are normally associated to a SQL TABLE
, but you can also use them with a SQL VIEW
.
Just like the table
macro, the name will be assumed based on the name of model; however, you
can pass in a custom name if you’d like.
class GeoReport < BaseModel
view do
end
end
By default, Lucky will add a few columns to your model for you.
id
- Your primary key column. Default Int64
created_at
- default Time
type.updated_at
- default Time
type.These columns are only added to
table
models. If your model usesview
, and you need any of these, you’ll need to add them manually
To change your defaults, define a macro called default_columns
in your
BaseModel
and add whatever columns should automatically be added:
# In src/models/base_model.cr
abstract class BaseModel < Avram::Model
macro default_columns
# Defines a custom primary key name and type
primary_key custom_key : UUID
# adds the `created_at` and `updated_at`
timestamps
end
end
If you remove timestamps
from default_columns
and you still want the automatic default behavior, you’ll need to:
created_at
and/or updated_at
columns to specific modelsautogenerated
option to true
Here’s an example from Avram:
macro timestamps
column created_at : Time, autogenerated: true
column updated_at : Time, autogenerated: true
end
If you have a specific model that needs different columns than the
defaults, call the skip_default_columns
macro at the top of the model
class.
Now your model won’t define id
, created_at
, or updated_at
fields. It will be up to you
to specify your primary key field.
class CustomModel < Avram::Model
skip_default_columns
table do
primary_key something_different : Int64
end
end
The primary key is Int64
by default. If that’s what you need, then everything is already set for
you. If you need Int32
, Int16
, UUID
, or your own custom String
, you’ll need to update the
primary_key
in your BaseModel
or set one in the table
macro.
Setting your primary key with the primary_key
method works the same as you did in
your migration.
# src/base_model.cr
abstract class BaseModel < Avram::Model
macro default_columns
# Sets the type for `id` to `UUID`
primary_key id : UUID
timestamps
end
end
For String
primary keys, you will need to define a method that generates the value when the record is saved.
abstract class BaseModel < Avram::Model
macro default_columns
# Sets the type for `id` to `text`
primary_key id : String = UUID.v7.to_s
timestamps
end
end
Inside of the table
block, you’ll add the columns your model will define using the column
method.
table do
column email : String
column active : Bool
# This column is optional (can be `nil`) because the type ends in `?`
column ip_address : String?
column last_active_at : Time
end
While you can always define database-level default values in a migration or set default values in a SaveOperation, it’s also possible to set a default value at the model level. For example:
class User < BaseModel
table do
column email
column encrypted_password : String
column greeting : String = "Hello there!"
column admin : Bool = false
column money : Float64 = 0.0
end
end
This helps to avoid boilerplate application code, and instead allows your models to do the heavy lifting for you. It also provides one convenient source of truth for the business logic behind any given model.
Avram supports several types that map to Postgres column types.
String
- text
column type. In Postgres text
can store strings of any lengthInt16
- smallint
column type.Int32
- integer
column type.Int64
- bigint
column type.Float64
- numeric
column type.Bool
- boolean
column type.Time
- timestamp with time zone
(timestamptz
) column type.UUID
- uuid
column type.Bytes
- bytea
column type.JSON::Any
- jsonb
column type.JSON::Serializable
- jsonb
column type.Array(T)
- []
column type where T
is any other supported type.Any of your columns can also define “nilable” types by adding Crystal Nil
Union ?
.
This is if your column allows for a NULL
value. (e.g. column age : Int32?
allows an
int
or NULL
value).
Postgres supports a lot more types than what Avram does out of the box. If you need access to a type that crystal-pg supports that isn’t listed above, you can add in support for your app.
Let’s take postgres’s double precision
type for example. This currently maps to Float64
, but
Lucky maps numeric
to Float64
. To use the double precision
type, create an alias.
alias Double = Float64
# then in your model
table do
column price : Double
end
Avram is constantly being updated, and some types may not “patch” as easily. If you tried this method, and it doesn’t work for you, be sure to open an issue so we can get support for that as soon as possible.
The serialized columns are stored as a jsonb
field in your database. When the data
is fetched, Avram can convert it to a serialized object type.
Valid JSON can be stored in many ways, but serialized columns assume your JSON is structed in a simple key/value way.
To enable the serialized column, you’ll need to make sure the column in your migration
is set to JSON::Any
. For this example, we will use a User::Preferences
struct.
Your model will define a column of type User::Preferences
, and set the serialize
option
to true
.
# src/models/user.cr
class User < BaseModel
struct Preferences
include JSON::Serializable
property? receive_email : Bool = true
end
table do
column preferences : User::Preferences, serialize: true
end
end
When calling the preferences
method on your user instance, you’ll have the User::Preferences
struct which pulls the values from the preferences
column of the users record.
user = UserQuery.new.first
user.preferences.receive_email? #=> true
For info on using serialized column in SaveOperations, see Saving Serialized JSON
Enums are a way to map an Integer to a named value. Computers handle numbers better, but people handle words better. This is a happy medium between the two. For example, a user status may be “active”, but we store it as the number 1.
Read more on Crystal enums.
class User < BaseModel
enum Status
# Default value starts at 0
Guest # 0
Active # 1
Expired # 2
end
enum Role
# Assign custom values
Member = 1
Admin = 2
Superadmin = 3
end
table do
column status : User::Status
column role : User::Role
end
end
The column will return an instance of your enum
. (e.g. User::Role::Admin
). This gives
you access to a few helper methods for handling the enum.
User::Status::Active.value #=> 1
User::Role::Superadmin.value #=> 3
user = UserQuery.new.first
user.status.value #=> 1
user.status.active? #=> true
user.role.value #=> 3
user.role.member? #=> false
In order to store the values in the database, the table must have columns named accordingly and of type Int32
.
create table_for(User) do
primary_key id : Int64
add status : Int32
add role : Int32
end
For Array(SomeEnum)
columns, you will store these on the postgres side as array of ints. Then on the model side, you must
use the PG::EnumArrayConverter
custom converter.
# Use this for your migrations
create table_for(Post) do
primary_key id : Int64
add reactions : Array(Int32), default: [] of Int32
end
# src/models/post.cr
class Post < BaseModel
enum Reaction
Like
Love
Funny
Shocked
end
table do
column reactions : Array(Reaction) = [] of Post::Reaction, converter: PG::EnumArrayConverter(Post::Reaction)
end
end
To learn more about using enums, read up on saving with enums and querying with enums.
In a RDBMS you may have tables that are related to each other. With Avram, we can associate two models to make some common queries a lot more simple.
All associations will be defined in the table
block. You can use has_one
, has_many
, and belongs_to
.
class User < BaseModel
table do
has_one supervisor : Supervisor
has_many tasks : Task
belongs_to company : Company
end
end
A belongs_to
will assume you have a foreign key column related to the other model defined
as {model_name}_id
.
table do
column name : String
# gives you the `company_id`, and `company` methods
belongs_to company : Company
end
When you create the migration, be sure you’ve set add_belongs_to.
If you need to set the foreign key to a different value, you can pass the foreign_key
option.
table do
# gives you the `business_id`, and `company` methods`
belongs_to company : Company, foreign_key: :business_id
end
You can preload these associations in your queries, and return the associated model.
# Will return the company associated with the User
UserQuery.new.preload_company.find(1).company
Sometimes associations are not required. To do that add a ?
to the end of the type.
belongs_to company : Company?
Make sure to make the column nilable in your migration as well:
add_belongs_to company : Company?
class User < BaseModel
table do
has_one supervisor : Supervisor
end
end
This would match up with the Supervisor
having belongs_to
.
class Supervisor < BaseModel
table do
belongs_to user : User
end
end
The
has_one
macro also supports aforeign_key
option likebelongs_to
.
table do
has_many tasks : Task
end
The name of the association should be the plural version of the model’s name, and the type is the model. (e.g.
Task
model,tasks
association)
The
has_many
macro also supports aforeign_key
option likebelongs_to
.
Let’s say we want to have many tags that can belong to any number of posts.
Here are the models:
# This is what will join the posts and tags together
class Tagging < BaseModel
table do
belongs_to tag : Tag
belongs_to post : Post
end
end
class Tag < BaseModel
table do
column name : String
has_many taggings : Tagging
# In the has_many :through example below, the `:taggings`
# in the array [:taggings, :post] refers to the
# `has_many taggings` above and the
# `:post` refers to the `belongs_to post` of the
# Tagging's schema.
has_many posts : Post, through: [:taggings, :post]
end
end
class Post < BaseModel
table do
column title : String
has_many taggings : Tagging
has_many tags : Tag, through: [:taggings, :tag]
end
end
In the example above, we have defined a has_many :through association named :tags. A :through association always expects an array and the first element of the array must be a previously defined association in the current model. For example, :tags first points to :taggings in the same model (Post), which then points to :tag in the next schema, Tagging.
The associations must be declared on both ends (the Post and the Tag in this example), otherwise you will get a compile time error
Polymorphism describes the concept that objects of different types can be accessed through the same interface.
This allows us to have a single method to define an association, but that method can return many different types. A bit confusing, but best explained with some code!
class Photo < BaseModel
table do
has_many comments : Comment
end
end
class Video < BaseModel
table do
has_many comments : Comment
end
end
class Comment < BaseModel
table do
# Note that both these `belongs_to` *must* be nilable
belongs_to photo : Photo?
belongs_to video : Video?
# Now `commentable` could be a `photo` or `video`
polymorphic commentable, associations: [:photo, :video]
end
end
And to use it
photo = SavePhoto.create!
comment = SaveComment.create!(photo_id: photo.id)
comment.commentable == photo
The Comment
model now has a commentable
method which could return a photo
object
or a video
object depending on which was associated.
For each polymorphic association, you’ll need to add a belongs_to
. This helps to keep
our polymorphic associations type-safe! See migrations for add_belongs_to
.
You’ll also note that the belongs_to
has nilable models. This is required for the polymorphic
association. Even though these are set as nilable, the association still requires at least 1 of the
associations
to exist. This means that commentable
is never actually nil
.
If you need this association to be fully optional where commentable
could be nil
, you’ll add the
optional
option.
# commentable can now be nil
polymorphic commentable, optional: true, associations: [:photo, :video]
Since the polymorphic associations are just regular belongs_to
associations with some sweet
helper methods, all of the preloading still exists.
comment = CommentQuery.new.preload_commentable
comment.commentable #=> Safely access this association
To skip preloading the polymorphic association, just add a bang !
.
comment = CommentQuery.first
comment.commentable! #=> no preloading required here
While Crystal can provide a great deal of type-safety, when it comes to your database, there’s plenty of room to accidentally set up the integration between the database table and the model incorrectly.
To help with this, Lucky includes some validations that are run by the Schema Enforcer. It starts by querying the database for all of the tables and columns. It then goes through every model and verifies that they are connected to existing tables and that all of the columns defined in each model exist and have the correct type and nullability.
These validations are only run in development and test and can be disable if desired.
If you’d like to add additional validations or modify the existing ones, you can override the setup_table_schema_enforcer_validations
macro in any of your models.
macro setup_table_schema_enforcer_validations(type, *args, **named_args)
schema_enforcer_validations << EnsureExistingTable.new(model_class: {{ type.id }})
schema_enforcer_validations << EnsureMatchingColumns.new(model_class: {{ type.id }})
# add additional validations here...
end
You can add the skip_schema_enforcer
macro to any of your models that you would like
to skip the Schema Enforcer on. This is helpful when you need a custom setup, or maybe
a temporary model.
class TempOTPUser < Avram::Model
skip_default_columns
# Add this line to skip checking this model
skip_schema_enforcer
def self.database : Avram::Database.class
AppDatabase
end
table :users do
primary_key id : UUID # or whatever your PKEY type is...
column otp_code : String?
end
end