/pg-orm

Postgres ORM for Crystal Lang

Primary LanguageCrystalMIT LicenseMIT

Postgres ORM for Crystal Lang

Extending ActiveModel for attribute definitions, callbacks and validations

Configuration

# Below is the list of settings exposed by PgORM and their defaults
PgORM::Database.configure do |settings|
  setting host : String = ENV["PG_HOST"]? || "localhost"
  setting port : Int32 = (ENV["PG_PORT"]? || 5432).to_i
  setting db : String = ENV["PG_DB"]? || ENV["PG_DATABASE"]? || "test"
  setting user : String = ENV["PG_USER"]? || "postgres"
  setting password : String = ENV["PG_PASSWORD"]? || ""
  setting query : String = ENV["PG_QUERY"]? || ""
  # Postgresql Advisory Lock wait time-out
  setting lock_timeout : Time::Span = (ENV["PG_LOCK_TIMEOUT"]? || 5).to_i.seconds

end

# OR

PgORM::Database.parse(ENV["PG_DATABASE_URL"])

Primary Key

attribute named id of type Int64 is generated by default if you don't provide one for your model. You can change the default settings via macro

default_primary_key(name, autogenerated = true, converter = nil)

class BaseModel < PgORM::Base
default_primary_key my_pk : String, autogenerated: true
end

class Foo < BaseModel
 attribute name : String
 attribute foo : Hash(String, String)
 attribute baz : Array(Float64)
 ......
end

If your models have different primary key, you can mark your attribute with primary_key: true

class Bar < PgORM::Base
  attribute uuid : UUID, primary_key: true
end

Table Name

Table name is inferred from class name if none is provided. You can override this behavior via table macro

class Baz < PgORM::Base
  table "awsome_table"

  attribute id : Int32, primary_key: true
  attribute name : String
  attribute about : String? = nil
end

Callbacks

Register callbacks for save, update, create and destroy by setting the corresponding before/after callback handler.

class ModelWithCallbacks < PgORM::Base
  attribute id : Int32, primary_key: true
  attribute address : String
  attribute age : Int32 = 10

  before_create :set_address
  after_update :set_age

  before_destroy do
    self.name = "joe"
  end

  def set_address
    self.address = "23"
  end

  def set_age
    self.age = 30
  end
end

Associations

Set associations with belongs_to, has_one, and has_many.

Access children in parent by accessing the method correpsonding to the name.

Note: The has_many association requires the belongs_to association on the child.

class Parent < PgORM::Base
  attribute name : String
  has_many :children, class_name: Child
end

class Child < PgORM::Base
  attribute age : Int32
  belongs_to :parent
  has_many :pet
end

class Pet < PgORM::Base
  attribute name : String
  belongs_to :child
end

parent = Parent.new(name: "Phil")
parent.children.to_a.empty? # => true

child = Child.new(age: 99)
child.pets.to_a.empty? # => true

belongs_to

This will add the following methods:

Note: association below refers to the name parameter provided when defining this association, e.g belongs_to :child here child is the association name):

  • association returns the associated object (or nil);
  • association= assigns the associated object, assigning the foreign key;
  • build_association builds the associated object, assigning the foreign key if the parent record is persisted, or delaying it to when the new record is saved;
  • create_association creates the associated object, assigning the foreign key, granted that validation passed on the associated object;
  • create_association! same as create_association but raises a PgORM::Error::RecordNotSaved exception when validation fails;
  • reload_association to reload the associated object.

For example a Child class declares belongs_to :parent which will add:

  • Child#parent (similar to Parent.find(parent_id))
  • Child#parent=(parent) (similar to child.parent_id = parent.id)
  • Child#build_parent (similar to child.parent = Parent.new)
  • Child#create_parent (similar to child.parent = Parent.create)
  • Child#create_parent! (similar to child.parent = Parent.create!)
  • Child#reload_parent (force reload child.parent)
Parameter Default
name Name of the association
class_name overrides the association class name (inferred from name by default) name.camelcase
foreign_key overrides the foreign key on the association (inferred as name + "_id" name + "_id"
autosave Set auto save behavior. One of nil, true, false . Set nil (default) to only save newly built associations when the parent record is saved, true to always save the associations (new or already persisted), false to never save the associations automatically. nil
dependent Sets destroy behaviour. One of nil, :delete, :destroy. Set nil when no deletion should occur. :delete to delete associated record in SQL, :destroy to call #destroy on the associated object. nil

has_one

Declares a has one relationship.

This will add the following methods:

  • association returns the associated object (or nil).
  • association= assigns the associated object, assigning the association's foreign key, then saving the association; permanently deletes the previously associated object;
  • reload_association to reload the associated object.

For example an Account class declares has_one :supplier which will add:

  • Account#supplier (similar to Supplier.find_by(account_id: account.id))
  • Account#supplier=(supplier) (similar to supplier.account_id = account.id)
  • Account#build_supplier
  • Account#create_supplier
  • Account#create_supplier!
  • Account#reload_supplier
Parameter Default
name Name of the association
class_name overrides the association class name (inferred from name by default) name.camelcase
foreign_key overrides the foreign key on the association (inferred as name + "_id" name + "_id"
autosave Set auto save behavior. One of nil, true, false . Set nil (default) to only save newly built associations when the parent record is saved, true to always save the associations (new or already persisted), false to never save the associations automatically. nil
dependent Sets destroy behaviour. One of :nullify, :delete, :destroy. Set :nullify to set the foreign key nil in SQL, :delete to delete associated record in SQL, :destroy to call #destroy on the associated object. :nullify

has_many

Declares a has many relationship.

This will add method

  • association returns Relation object of the associated object.

For example a Parent class declares has_many :children, class_name: Child which will add:

  • Parent#children : Relation(Child) (similar to child.find(parent_id))
Parameter Default
name Name of the association
class_name overrides the association class name (inferred from name by default) name.camelcase
foreign_key overrides the foreign key on the association (inferred as name + "_id" name + "_id"
autosave Set auto save behavior. One of nil, true, false . Set nil (default) to only save newly built associations when the parent record is saved, true to always save the associations (new or already persisted), false to never save the associations automatically. nil
dependent Sets destroy behaviour. One of :nullify, :delete, :destroy. Set :nullify to set the foreign key nil in SQL, :delete to delete associated record in SQL, :destroy to call #destroy on the associated object. :nullify
serialize When true will add linked attribute to to_json representation false by default

Dependency

dependent param in the association definition macros defines the fate of the association on model destruction. Refer to descriptions in specific association for more details.

Changefeeds

Access the changefeed (CRUD Events) of a table through the changes class method.

Defaults to watch for change events on a table if no id provided.

Parameter Default
id id of record to watch for changes or nil to watch for whole table nil

Returns a ChangeFeed instance which provides methods for event based or blocking iterator

  • ChangeFeed#on expects a block to be passed, which will get invoked asynchronously when an event is received.
  • ChangeFeed#each an Iterator, whose next call will block till an event is received.

Emits Change instance consisting of event : Event and value : T where T is the model.

Events:

  • ChangeReceiver::Event::Deleted events yield the deleted model
  • ChangeReceiver::Event::Created events yield the created model
  • ChangeReceiver::Event::Updated events yield the updated model
class Game < PgORM::Base
  attribute type : String
  attribute score : Int32, default: 0
end

ballgame = Game.create!(type: "footy")

# Observe changes on a single row
spawn do
  Game.changes(ballgame.id).each do |change|
    game = change.value
    puts "looks like the score is #{game.score}"
  end
end

# Observe changes on a table
spawn do
  Game.changes.each do |change|
    game = change.value
    puts "#{game.type}: #{game.score}" 
    puts "game event: #{change.event}"
  end
end

Advisory Locks

PgORM::PgAdvisoryLock class provides a means for creating PostgreSQL Advisory Locks.

 lock = PgORM::PgAdvisoryLock.new("name or label to uniquely identify this lock")
 lock.synchronize do
   # Do some work
 end

 # OR if you need control on when to release the lock
 lock.lock
 # do some work
 # some more work
 lock.unlock

Column Types

Shard doesn't impose any restrictions on the types used in attributes and you are free to use any of the standard library or custom data types. For complex or custom data types, you are provided with an option to either provide custom converter which will be invoked when reading and writing to the table or shard assumes your complex data type supports JSON serialization method and field in stored in Postgres as JSONB data type.

Below is a list of several Crystal type that shard maps to Postgres column types

Crystal Type Postgres column Type
String TEXT
Int16 SMALLINT
Int32 INTEGER
Int64 BIGINT
Float64 NUMERIC
Bool BOOLEAN
Time TIMESTAMP with time zone (TIMESTAMPTZ)
UUID UUID
JSON::Any JSONB
JSON::Serializable JSONB
Array(T) [] where T is any other supported type.
Enum INTEGER
Set(T) [] where T is any other supported type
Custom type JSONB

Any of your columns can also define “nilable” types by adding Crystal Nil Union ?. This is to let shard knows that your database table column allows for a NULL value.

Validations

Builds on active-model's validation

ensure_unique

Fails to validate if field with duplicate value present in db. If scope is set, the callback/block signature must be a tuple with types matching that of the scope. The field(s) are set with the result of the transform block upon successful validation

Parameter Default
field Model attribute on which to guarantee uniqueness
scope Attributes passed to the transform, defaults to :field nil
create_index Whether or not to generate a secondary index true
callback : T -> T Optional function to transform field value nil
block : T -> T Optional block to transform field value before querying nil

Timestamps

Adds creates created_at and updated_at attributes.

  • updated_at is set through the before_update callback, and initially set in the before_save callback.
  • created_at is set through the before_create callback.

The generated timestamp is UTC.

class Timo < PgORM::Base
  # Simply include the module
  include PgORM::Timestamps

  attribute name : String
end

Join

Supports:

  • INNER JOIN: Returns records that have matching values in both tables
  • LEFT JOIN: Returns all records from the left table, and the matched records from the right table
  • RIGHT JOIN: Returns all records from the right table, and the matched records from the left table
  • FULL JOIN: Returns all records when there is a match in either left or right table

When a join SQL is performed, model associated records will be cached and accessing linked relations will use the cached result instead of hitting the database.

Default behavior of associations is to perform a lazy load. So linked associations aren't fetched unless accessed.

class Parent < PgORM::Base
  attribute name : String
  has_many :children, class_name: Child, serialize: true # serialize tag will serialize `children` when `to_json` is invoked on parent object
end

class Child < PgORM::Base
  attribute age : Int32
  belongs_to :parent
  has_many :pet
end

class Pet < PgORM::Base
  attribute name : String
  belongs_to :child
end

parent = Parent.new(name: "Phil")
parent.save!

child1 = parent.children.create(age: 6)
child2 = parent.children.create(age: 3)

parent.save!

result = Parent.where(id: parent.id).join(:left, Child, :parent_id).to_a.first
children = JSON.parse(result.to_json).as_h["children"]?
children.should_not be_nil
children.try &.size.should eq(2)

Installation

  1. Add the dependency to your shard.yml:

    dependencies:
      pg-orm:
        github: spider-gazelle/pg-orm
  2. Run shards install

Usage

require "pg-orm"

Testing

Given you have the following dependencies...

It is simple to develop the service with docker.

With Docker

  • Run specs, tearing down the docker-compose environment upon completion.
$ ./test
  • Run specs on changes to Crystal files within the src and spec folders.
$ ./test --watch

Without Docker

  • To run tests
$ crystal spec

NOTE: The upstream dependencies specified in docker-compose.yml are required...

Compiling

$ shards build