module

Lustra::Model

Inherits Lustra::Model::FullTextSearchable < Lustra::Model::JSONDeserialize < Lustra::Model::Initializer < Lustra::Model::HasFactory < Lustra::Model::Introspection < Lustra::Model::ClassMethods < Lustra::Model::HasScope < Lustra::Model::HasRelations < Lustra::Model::HasValidation < Lustra::Validation::Helper < Lustra::Model::HasSaving < Lustra::Model::HasSerialPkey < Lustra::Model::HasTimestamps < Lustra::Model::HasColumns < Lustra::Model::HasHooks < Lustra::Model::Connection < Lustra::ErrorMessages

Model definition is made by adding the Lustra::Model mixin in your class.

Simple Model

class MyModel
  include Lustra::Model

  column my_column : String
end

We just created a new model, linked to your database, mapping the column my_column of type String (text in postgres).

Now, you can play with your model:

row = MyModel.new # create an empty row
row.my_column = "This is a content"
row.save! # insert the new row in the database !

By convention, the table name will follow an underscore, plural version of your model: my_models. A model into a module will prepend the module name before, so Logistic::MyModel will check for logistic_my_models in your database. You can force a specific table name using:

class MyModel
  include Lustra::Model
  self.table = "another_table_name"
end

Presence validation

Unlike many ORM around, Lustra carry about non-nullable pattern in crystal. Meaning column my_column : String assume than a call to row.my_column will return a String.

But it exists cases where the column is not yet initialized:

  • When the object is built with constructor without providing the value (See above).
  • When an object is semi-fetched through the database query. This is useful to ignore some large fields non-interesting in the body of the current operation.

For example, this code will compile:

row = MyModel.new # create an empty row
puts row.my_column

However, it will throw a runtime exception You cannot access to the field 'my_column' because it never has been initialized

Same way, trying to save the object will raise an error:

row.save      # Will return false
pp row.errors # Will tell you than `my_column` presence is mandatory.

Thanks to expressiveness of the Crystal language, we can handle presence validation by simply using the Nilable type in crystal:

class MyModel
  include Lustra::Model

  column my_column : String? # Now, the column can be NULL or text in postgres.
end

This time, the code above will works; in case of no value, my_column will be nil by default.

Querying your code

Whenever you want to fetch data from your database, you must create a new collection query:

MyModel.query # Will setup a vanilla 'SELECT * FROM my_models'

Queries are fetchable using each:

MyModel.query.each do |model|
  # Do something with your model here.
end

Refining your query

A collection query offers a lot of functionalities.

Column type

By default, Lustra map theses columns types:

  • String => text
  • Numbers (any from 8 to 64 bits, float, double, big number, big float) => int, large int etc... (depends of your choice)
  • Bool => text or bool
  • Time => timestamp without timezone or text
  • JSON::Any => json and jsonb
  • Nilable => NULL (treated as special !)

NOTE: The crystal-pg gems map also some structures like GIS coordinates, but their implementation is not tested in Lustra. Use them at your own risk. Tell me if it's working 😉

If you need to map special structure, see Mapping Your Data guides for more informations.

Primary key

Primary key is essential for relational mapping. Currently Lustra support only one column primary key.

A model without primary key can work in sort of degraded mode, throwing error in case of using some methods on them:

  • collection#first will be throwing error if no order_by has been setup

To setup a primary key, you can add the modifier primary: true to the column:

class MyModel
  include Lustra::Model

  column id : Int32, primary: true, presence: false
  column my_column : String?
end

Note the flag presence: false added to the column. This tells Lustra than presence checking on save is not mandatory. Usually this happens if you setup a default value in postgres. In the case of our primary key id, we use a serial auto-increment default value. Therefore, saving the model without primary key will works. The id will be fetched after insertion:

m = MyModel
m.save!
m.id # Now the id value is setup.

Helpers

Lustra provides various built-in helpers to facilitate your life:

Timestamps

class MyModel
  include Lustra::Model
  timestamps # Will map the two columns 'created_at' and 'updated_at', and map some hooks to update their values.
end

Theses fields are automatically updated whenever you call save methods, and works as Rails ActiveRecord.

With Serial Pkey

class MyModel
  include Lustra::Model
  primary_key "my_primary_key"
end

Basically rewrite column id : UInt64, primary: true, presence: false

Argument is optional (default = id)

Instance methods

__pkey__

Alias method for primary key.

If Model#id IS the primary key, then calling Model#__pkey__ is exactly the same as Model#id.

This method exists to tremendously simplify the meta-programming code. If no primary key has been setup to this model, raise an exception.

Source
cache
Source

Macros

default_scope

Define a default scope that will be automatically applied to all queries. Useful for soft deletes, multi-tenancy, or any filter that should always apply.

Warning: Default scopes can be confusing as they're implicit. Use sparingly and document clearly.

Usage:

class Post
  include Lustra::Model

  column deleted_at : Time?

  default_scope { where { deleted_at.null? } }
end

Post.query       # SELECT * FROM posts WHERE deleted_at IS NULL
Post.query.first # Also applies default scope

To bypass default scope, use unscoped:

Post.query.unscoped       # SELECT * FROM posts (no default scope)
Post.query.unscoped.count # Works with any query method
Source
scope(name, &block)

A scope allow you to filter in a very human way a set of data.

Usage:

scope("admin") { where({role: "admin"}) }

for example, instead of writing:

User.query.where { (role == "admin") & (active == true) }

You can write:

User.admin.active

Scope can be used for other purpose than just filter (e.g. ordering), but I would not recommend it.

Source

Nested types