Lucky Logo

# Introduction

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.

# Generate a model

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.

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

# Setting up a model

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.

# Mapping a model to a table

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

# Mapping a model to a view

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

# Defining a column

# Default columns

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 uses view, and you need any of these, you’ll need to add them manually

# Customizing the default columns

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:

  • Add created_at and/or updated_at columns to specific models
  • Set the autogenerated option to true

Here’s an example from Avram:

macro timestamps
  column created_at : Time, autogenerated: true
  column updated_at : Time, autogenerated: true
end

# Skipping default columns

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

# Setting the primary key

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

# Adding a column

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

# Setting a column default value

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.

# Column types

Avram supports several types that map to Postgres column types.

  • String - text column type. In Postgres text can store strings of any length
  • Int16 - 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.
  • Avram Enum - see using enums

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).

# Additional postgres types

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.

# JSON Serialized columns

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

# Using enums

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

# Array Enums

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.

# Model associations

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

# Belongs to

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

# Optional association

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?

# Has one (one to one)

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 a foreign_key option like belongs_to.

# Has many (one to many)

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 a foreign_key option like belongs_to.

# Has many through (many to many)

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

# Polymorphic associations

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]

# Preloading polymorphic associations

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

# Schema Enforcer

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

# Skipping the Schema Enforcer

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
See a problem? Have an idea for improvement? Edit this page on GitHub