PgORM#
PgORM is a PostgreSQL ORM for Crystal. It extends ActiveModel for attribute definitions, callbacks and validations, and adds persistence, associations, a query DSL, pagination and full-text search on top.
Because a model is an ActiveModel::Model, everything you already know about attribute, validations and before_*/after_* callbacks applies. This page covers what PgORM adds: connecting to the database, declaring a table-backed model, and persisting it.
Installation#
Add the shard, then require it.
dependencies:
pg-orm:
github: spider-gazelle/pg-orm
version: ~> 2.5
require "pg-orm"
Connection#
Configure the database once at boot. The simplest option is to parse a connection URL:
PgORM::Database.parse(ENV["PG_DATABASE_URL"])
Or set individual values programmatically:
PgORM::Database.configure do |settings|
settings.host = "db.example.com"
settings.port = 5432
settings.db = "production"
settings.user = "app_user"
settings.password = "secret"
end
Every setting also reads from an environment variable by default: PG_HOST, PG_PORT, PG_DB (or PG_DATABASE), PG_USER, PG_PASSWORD, PG_QUERY and PG_LOCK_TIMEOUT (advisory lock timeout in seconds). The defaults are localhost, 5432, test, postgres, an empty password and 5.
Read replicas
Call PgORM::Database.parse_read(ENV["PG_DATABASE_READ_URL"]?) to route standalone SELECTs (find, where, count, …) to a replica. Writes, and any read inside a transaction or with_connection block, always hit the primary, so wrap a read in with_connection when it must see a write that may not have replicated yet. With no replica URL configured every query uses the primary.
When you configure individual settings rather than calling parse, you can instead set PG_RO_HOST (and optionally PG_RO_QUERY) to point reads at a replica host.
Declaring a model#
Subclass PgORM::Base and declare attributes. The table name is inferred from the class name, underscored but not pluralised (BlogPost → blog_post). The primary key defaults to a column named id, which you must declare as an attribute; the model won't compile without it.
class User < PgORM::Base
attribute id : Int64
attribute name : String
attribute email : String
attribute age : Int32 = 0
end
Table name#
Override the inferred name with table:
class User < PgORM::Base
table "accounts"
attribute id : Int64
attribute name : String
end
Primary key#
Use primary_key to point at a different attribute, or pass several for a composite key:
class Token < PgORM::Base
primary_key :uuid
attribute uuid : UUID
end
class Membership < PgORM::Base
primary_key :user_id, :org_id
attribute user_id : UUID
attribute org_id : UUID
end
To give a hierarchy of models the same primary key, declare it once on an abstract base class with default_primary_key(name, auto_generated = true, converter = nil). It declares the attribute for you:
abstract class ApplicationModel < PgORM::Base
default_primary_key id : Int64
end
class Post < ApplicationModel
attribute title : String
end
Timestamps#
Include PgORM::Timestamps to get created_at and updated_at columns. created_at is set on create, updated_at on every save; both are UTC.
class User < PgORM::Base
include PgORM::Timestamps
attribute id : Int64
attribute name : String
end
Column types#
PgORM doesn't create or migrate tables, so create the schema yourself with column types that match your attributes: String to TEXT, Int32 to INTEGER, Time to TIMESTAMPTZ, UUID to UUID, Array(T) to an array column, Set(T) to an array column, Enum to INTEGER, and any JSON::Serializable (or custom type) to JSONB. Make a column nullable by giving the attribute a nilable type (String?).
Persistence#
Build an instance and save, or create in one step. save returns true or false. create, create! and save! raise PgORM::Error::RecordInvalid when validation fails (create doesn't rescue it), and create/create! return the record.
user = User.new(name: "John", email: "john@example.com")
user.save # => true / false
user.persisted? # => true
User.create(name: "Jane", email: "jane@example.com")
User.create!(name: "Alice", email: "alice@example.com")
Update by assigning attributes, or with the update helpers. Saving a persisted record only writes the columns that actually changed (dirty tracking). Both update and update! raise PgORM::Error::RecordInvalid when validation fails.
user.name = "Johnny"
user.save
user.update(email: "johnny@example.com") # assign + save, => true
user.update!(age: 30)
# skip validations and callbacks, straight UPDATE:
user.update_fields(age: 31)
Delete a record with destroy (runs callbacks, handles dependent associations, wrapped in a transaction) or delete (a bare DELETE, no callbacks).
user.destroy
user.destroyed? # => true
user.persisted? # => false
Finding records#
User.find(1) # raises RecordNotFound if absent
User.find?(1) # => User?
User.find_by(email: "j@ex.com") # raises if absent
User.find_by?(name: "John") # => User?
User.first # raises if the table is empty; first? returns nil
User.last
User.all
find/find? accept a tuple when the model has a composite primary key.
See Associations for belongs_to/has_one/has_many, and Querying for the query DSL, pagination and full-text search.