Skip to content

Features Guide ​

This guide highlights the features that are implemented in Crecto today and how to use them safely.

Schema Definition ​

Models extend Crecto::Model and describe their table structure inside a schema block. Supported field types include the Crystal primitives listed in src/crecto/schema.cr, plus JSON, UUID, nullable variants, and arrays of those types.

crystal
class Product < Crecto::Model
  schema "products" do
    field :name, String
    field :price, Float64
    field :active, Bool, default: true
    field :metadata, Json?
    field :sku, UUID?

    belongs_to :category
    has_many :inventory_entries, InventoryEntry
  end

  validate_required [:name, :price]
end

Associations support:

  • belongs_to :category
  • has_many :inventory_entries, InventoryEntry
  • has_one :profile
  • has_many :tags, Tag, through: :inventory_entries

Many-to-many helpers are not generated automatically; using has_many ... through: is the recommended pattern.

Validations and Changesets ​

Validations are declared on the model class and run when you build a changeset.

crystal
class User < Crecto::Model
  schema "users" do
    field :name, String
    field :email, String
    field :age, Int32?
  end

  validate_required [:name, :email]
  validate_format :email, /^[^@\s]+@[^@\s]+\.[^@\s]+$/
  validate_length :name, min: 2
  unique_constraint :email
end

user = User.new
changeset = User.changeset(user)
changeset.valid?         # => false
changeset.errors         # => [{"name", "is required"}, {"email", "is required"}]

Keyword initialization uses the same casting pipeline, so User.new(name: "Ada") assigns attributes before you build a changeset.

Per-record validations run every time a changeset is instantiated. Mutating repository calls (insert, update, delete) require a valid changeset.

CRUD Operations ​

crystal
Query = Crecto::Repo::Query

# Create
user = User.new.tap do |u|
  u.name = "Sandi"
  u.email = "[email protected]"
end

changeset = User.changeset(user)
insert_result = Repo.insert(changeset)

# Update
if insert_result.valid?
  saved_user = insert_result.instance
  saved_user.name = "Sandi Metz"

  update_changeset = User.changeset(saved_user)
  Repo.update(update_changeset)
end

# Delete
Repo.delete(User.changeset(user))

# Fetch
Repo.get(User, 1)
Repo.get_by(User, email: "[email protected]")
Repo.all(User, Query.where(active: true).order_by("name ASC"))

Remember that Repo.insert/update return Changeset objects. Read operations return model instances directly.

Query Builder Tips ​

Crecto::Repo::Query supports where, or_where, string fragments with parameters, ordering, limiting, joining, preloading, and grouping.

crystal
recent_posts = Query
  .where(published: true)
  .where("published_at > ?", [Time.utc - 30.days])
  .order_by("published_at DESC")
  .limit(10)

Repo.all(Post, recent_posts.preload(:author))

To preload multiple associations:

crystal
Repo.all(User,
  Query.preload(:posts, Query.where(published: true))
       .preload(:profile)
)

Bulk Operations ​

Use Repo.insert_all to insert many records efficiently.

crystal
records = (1..3).map do |n|
  {name: "Batch User #{n}", email: "batch#{n}@example.com"}
end

result = Repo.insert_all(User, records)

result.successful_count  # number of inserted records
result.failed_count      # zero if all succeeded
result.errors            # details for records that failed validation/DB checks

Repo.update_all and Repo.delete_all accept a Query to target matching rows:

crystal
inactive = Query.where(active: false)
Repo.update_all(User, inactive, {archived_at: Time.utc})
Repo.delete_all(User, Query.where("archived_at < ?", [Time.utc - 90.days]))

Transactions ​

Group operations with Crecto::Multi or use a live transaction when you need immediate feedback.

crystal
multi = Crecto::Multi.new
multi.insert(User.changeset(user))
multi.insert(Profile.changeset(profile))

result = Repo.transaction(multi)
if result.errors.empty?
  puts "Batch committed"
else
  pp result.errors
end
crystal
Repo.transaction! do |tx|
  tx.insert!(User.changeset(user))
  tx.insert!(Profile.changeset(profile))
end

Live transactions yield the same changeset objects returned by the repository. Rolling back is automatic when an exception is raised inside the block.

Raw SQL Escape Hatch ​

When the query builder is too limiting, reach for Repo.query or Repo.raw_exec.

crystal
Repo.query("UPDATE users SET last_login = ? WHERE id = ?", [Time.utc, 1])

Repo.raw_query("SELECT COUNT(*) FROM users") do |rs|
  rs.each { puts rs.read(Int64) }
end

These helpers expect you to close the result set when you work with the lower-level DB::ResultSet API directly.