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)
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
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)
to_a
Executes the query and returns an array of records:
articles = Article.where(status: "published").to_a
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%")
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
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
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 }
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) |
Limitations
Juntos implements the most commonly used Active Record features. The following are not yet supported:
Query Methods
joins— Useincludesfor eager loading insteadgroup/having— Aggregate querieseager_load/preload— Useincludesreorder/unscope— Query modificationlock— Row locking
Advanced Features
- Complex Arel predicates (e.g.,
arel_table,Arel.sql) - Subqueries
- CTEs (Common Table Expressions)
- Window functions
find_each/find_in_batches— Batch processing
Associations
has_many :through— Join modelshas_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.
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