Active Record
Active Record
Juntos implements an Active Record-compatible query interface that works across all database adapters. The same Ruby code runs against IndexedDB in browsers, SQLite on Node.js, or PostgreSQL on the edge.
Table of Contents
- Active Record
Overview
Models in Juntos extend ApplicationRecord and support familiar Rails patterns:
class Article < ApplicationRecord
has_many :comments, dependent: :destroy
belongs_to :author
validates :title, presence: true
end
The query interface mirrors Rails:
# Find records
article = Article.find(1)
article = Article.find_by(slug: "hello-world")
# Query with conditions
@articles = Article.where(status: "published")
.order(created_at: :desc)
.limit(10)
# Eager load associations
@articles = Article.includes(:comments).all
Finders
find(id)
Finds a record by primary key. Raises an error if not found:
article = Article.find(1)
article = Article.find(params[:id])
find_by(conditions)
Finds the first record matching conditions. Returns nil if not found:
article = Article.find_by(slug: "hello-world")
article = Article.find_by(status: "published", featured: true)
find_by!(conditions)
Like find_by, but raises an error if no record is found:
article = Article.find_by!(slug: "hello-world") # Error if not found
find_or_create_by(attributes)
Finds the first record matching attributes, or creates one:
tag = Tag.find_or_create_by(name: "ruby")
all
Returns all records:
articles = Article.all
first / last
Returns the first or last record by primary key:
oldest = Article.first
newest = Article.last
count
Returns the number of records:
total = Article.count
published = Article.where(status: "published").count
exists?
Returns true if any matching records exist:
Article.exists?(1) # By ID
Article.where(status: "draft").exists? # By conditions
Query Builder
Chainable methods return a Relation that executes when iterated or terminated:
where(conditions)
Filters records by conditions:
# Hash conditions
Article.where(status: "published")
Article.where(status: "published", featured: true)
# Raw SQL conditions (SQL adapters only)
Article.where("created_at > ?", 1.week.ago)
Article.where("status = ? AND priority > ?", "active", 5)
where.not(conditions)
Excludes records matching conditions:
Article.where.not(status: "draft")
Article.where(featured: true).where.not(status: "archived")
or(relation)
Combines conditions with OR:
Article.where(status: "published").or(Article.where(featured: true))
order(columns)
Sorts results:
Article.order(:created_at) # ASC (default)
Article.order(created_at: :desc) # DESC
Article.order(status: :asc, created_at: :desc) # Multiple columns
limit(count) / offset(count)
Paginates results:
Article.limit(10) # First 10
Article.limit(10).offset(20) # Records 21-30
Article.order(created_at: :desc).limit(5) # Latest 5
select(columns)
Selects specific columns:
Article.select(:id, :title)
Article.select(:id, :title, :created_at).where(status: "published")
distinct
Returns unique records:
Article.select(:author_id).distinct
Comment.where(approved: true).distinct
includes(associations)
Eager loads associations to avoid N+1 queries:
# Single association
Article.includes(:comments).all
# Multiple associations
Article.includes(:comments, :author).all
# Then access without additional queries
articles.each do |article|
article.comments.each { |c| puts c.body } # No N+1
end
joins(associations)
Performs INNER JOINs on associations:
# Simple join
Article.joins(:comments).where(comments: { approved: true })
# Nested joins
Studio.joins(entries: [:lead, :follow])
group(columns)
Groups results for aggregate queries:
# Count by group
Article.group(:status).count
# => { "published" => 42, "draft" => 18 }
# Sum by group
Order.group(:status).sum(:amount)
# => { "completed" => 1500, "pending" => 300 }
missing(associations)
Finds records missing an associated record (LEFT JOIN … WHERE IS NULL):
Article.where.missing(:comments) # Articles with no comments
Terminal Methods
These methods execute the query and return results:
pluck(columns)
Returns an array of values for the specified columns:
Article.pluck(:id) # [1, 2, 3]
Article.pluck(:id, :title) # [[1, "First"], [2, "Second"]]
Article.where(status: "published").pluck(:title)
pick(columns)
Returns a single value (or array) from the first matching record:
Article.where(featured: true).pick(:title) # "Hello World"
Article.pick(:id, :title) # [1, "Hello World"]
sole
Returns exactly one record. Raises if zero or more than one found:
admin = User.where(role: "admin").sole # Error if 0 or 2+ admins
any?
Returns true if any matching records exist (alias for exists?):
Article.where(status: "published").any?
to_a
Executes the query and returns an array of records:
articles = Article.where(status: "published").to_a
Bulk Operations
update_all(attributes)
Updates all matching records directly with SQL (no callbacks):
Article.where(status: "draft").update_all(status: "archived")
User.update_all(active: false)
delete_all
Deletes all matching records directly with SQL (no callbacks):
Article.where(status: "archived").delete_all
destroy_all
Loads each matching record and calls destroy (runs callbacks):
Article.where(status: "archived").destroy_all
destroy_by(conditions)
Finds and destroys records matching conditions:
Article.destroy_by(status: "spam")
Transactions
Wrap multiple operations in a database transaction:
Article.transaction do
article.update!(status: "published")
Notification.create!(message: "New article!")
# If either fails, both are rolled back
end
Raise ActiveRecord::Rollback to abort silently:
Article.transaction do
article.update!(status: "published")
raise ActiveRecord::Rollback if some_condition?
# Transaction is rolled back, no error raised
end
Transactions use real BEGIN/COMMIT/ROLLBACK SQL on all SQL adapters.
Raw SQL Conditions
SQL adapters (SQLite, PostgreSQL, MySQL) support parameterized queries:
# Single parameter
Article.where("views > ?", 100)
# Multiple parameters
Article.where("created_at BETWEEN ? AND ?", start_date, end_date)
# LIKE queries
Article.where("title LIKE ?", "%Ruby%")
WHERE on Joined Tables
When using joins, reference columns on joined tables with a nested hash:
Article.joins(:comments).where(comments: { approved: true })
Card.joins(:studio).where(studios: { id: studio_id, active: true })
Note: For Dexie (IndexedDB), simple conditions (>, <, >=, <=, =) are translated to Dexie’s query API. Complex conditions fall back to JavaScript filtering after fetching.
Associations
has_many
Declares a one-to-many relationship:
class Article < ApplicationRecord
has_many :comments
has_many :comments, dependent: :destroy # Delete comments when article deleted
has_many :comments, foreign_key: :post_id # Custom foreign key
end
belongs_to
Declares the inverse of has_many or has_one:
class Comment < ApplicationRecord
belongs_to :article
belongs_to :article, optional: true # Allow nil
belongs_to :post, class_name: "Article" # Custom class
end
has_one
Declares a one-to-one relationship:
class User < ApplicationRecord
has_one :profile
has_one :profile, dependent: :destroy
end
has_many :through
Declares a many-to-many relationship through a join model:
class Studio < ApplicationRecord
has_many :studio1_pairs, class_name: "StudioPair", foreign_key: "studio2_id"
has_many :studio1s, through: :studio1_pairs, source: :studio1, class_name: "Studio"
end
The :through option names the intermediate has_many association, :source identifies the foreign key on the join model (appends _id), and :class_name specifies the target model. Without :source, the association name is singularized (e.g., tags → tag_id).
alias_attribute
Creates a getter/setter alias for a column, useful when a column name collides with a JavaScript built-in (e.g., sort vs Array.prototype.sort):
class Judge < ApplicationRecord
alias_attribute :sort_order, :sort
end
The database column remains unchanged — sort_order reads and writes the underlying sort column.
CollectionProxy
Accessing a has_many association returns a CollectionProxy with query methods:
size / length / count
article.comments.size # Synchronous when eagerly loaded
article.comments.count # Always queries database
build(attributes)
Creates a new associated record with the foreign key pre-set:
comment = article.comments.build(body: "Great post!")
comment.article_id # Already set to article.id
comment.save
create(attributes)
Builds and saves in one step:
article.comments.create(body: "Great post!")
where / order / limit
CollectionProxy supports chainable queries:
article.comments.where(approved: true)
article.comments.order(created_at: :desc).limit(5)
article.comments.where(approved: true).count
Bulk operations
article.comments.update_all(approved: true)
article.comments.delete_all
article.comments.destroy_all
article.comments.find_or_create_by(body: "Welcome!")
Instance Methods
new / create
# Build without saving
article = Article.new(title: "Draft", body: "...")
# Build and save
article = Article.create(title: "Published", body: "...")
# Create with validation check
article = Article.create!(title: "") # Raises on validation failure
save / save!
Persists a new record or saves changes to an existing one:
article = Article.new(title: "Hello")
article.save # Returns true/false
article.title = "Updated"
article.save! # Raises on failure
update / update!
Updates attributes and saves:
article.update(title: "New Title")
article.update!(title: "New Title") # Raises on failure
destroy / destroy!
Deletes the record from the database:
article.destroy
article.destroy! # Raises on failure
Respects dependent: :destroy on associations.
reload
Refreshes attributes from the database:
article.reload
new_record? / persisted?
Check record state:
article = Article.new
article.new_record? # true
article.persisted? # false
article.save
article.new_record? # false
article.persisted? # true
Validations
Validations run before save and populate the errors collection:
class Article < ApplicationRecord
validates :title, presence: true
validates :body, length: { minimum: 10 }
validates :slug, uniqueness: true
validates :status, inclusion: { in: %w[draft published archived] }
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
validates_associated :comments
end
Checking Validity
article = Article.new(title: "")
article.valid? # false
article.invalid? # true
article.errors # { title: ["can't be blank"] }
Supported Validations
| Validation | Options |
|---|---|
presence |
true |
length |
minimum, maximum, in, is |
uniqueness |
true, scope |
inclusion |
in |
exclusion |
in |
format |
with (regex) |
numericality |
only_integer, greater_than, less_than, etc. |
Callbacks
Callbacks execute at specific points in the record lifecycle:
class Article < ApplicationRecord
before_validation :normalize_title
before_save :set_published_at
after_create :notify_subscribers
after_create_commit { broadcast_append_to "articles" }
before_destroy :cleanup_attachments
end
Supported Callbacks
| Callback | Timing |
|---|---|
before_validation |
Before validation runs |
after_validation |
After validation completes |
before_save |
Before insert or update |
after_save |
After insert or update |
before_create |
Before insert (new records only) |
after_create |
After insert |
before_update |
Before update (existing records only) |
after_update |
After update |
before_destroy |
Before delete |
after_destroy |
After delete |
after_create_commit |
After transaction commits (insert) |
after_update_commit |
After transaction commits (update) |
after_destroy_commit |
After transaction commits (delete) |
Concerns
Concerns let you extract shared model behavior into reusable modules:
# app/models/concerns/trackable.rb
module Trackable
extend ActiveSupport::Concern
included do
has_many :tracks
after_update :record_change
end
def record_change
tracks.create(changed_at: Time.current)
end
end
Include concerns in your models:
class Article < ApplicationRecord
include Trackable
has_many :comments
end
Concerns transpile to subclass factory functions that compose via JavaScript class inheritance:
// concerns/trackable.js
const Trackable = (Base) => class extends Base {
static associations = { ...super.associations, tracks: { type: "has_many" } };
static callbacks = [...super.callbacks, ["after_update", "record_change"]];
record_change() {
return this.tracks.create({ changed_at: new Date() });
}
};
// article.js
class Article extends Trackable(ApplicationRecord) {
static associations = { ...super.associations, comments: { type: "has_many" } };
}
Multiple concerns compose naturally — include A; include B becomes extends B(A(ApplicationRecord)), matching Ruby’s method resolution order. Concerns can also include other concerns.
Limitations
Juntos implements the most commonly used Active Record features. The following are not yet supported:
Query Methods
having— Filter on aggregate resultseager_load/preload— Useincludesreorder/unscope— Query modificationfind_each/find_in_batches— Batch processing
Advanced Features
- Complex Arel predicates (e.g.,
arel_table,Arel.sql) - Subqueries
- CTEs (Common Table Expressions)
- Window functions
Associations
has_and_belongs_to_many— Many-to-many without join model- Polymorphic associations
For complex queries, consider using raw SQL conditions or implementing the logic in your controller.
Target-Specific Behavior
The same Ruby code works across all targets, but execution differs:
Browser Target (Dexie, sql.js)
Queries execute directly against the local database:
Article.where(status: "published")
│
└──▶ Dexie query against IndexedDB
or sql.js query against SQLite/WASM
Server Target (Node.js, Cloudflare)
Browser-side model calls become RPC requests:
Browser Server
─────── ──────
Article.where(status: "published")
│
├──▶ POST /__rpc ──▶ SQLite/PostgreSQL query
X-RPC-Action: Article.where │
Body: { args: [...] } │
▼
◀── { result: [...] } ◀── Query results
See Architecture for details on the RPC transport, or the Workflow Builder demo for a working example.
Next Steps
- Path Helpers — Server Functions-style controller access
- Architecture — How models are generated
- Testing — Test your model layer
- Hotwire — Real-time updates with
broadcast_*callbacks