Project

pluckr

0.0
The project is in a healthy, maintained state
Pluckr lets you declare the shape of the data you need and compiles it into a single SQL statement, returning lightweight immutable result objects instead of ActiveRecord models. Works on PostgreSQL, MySQL and SQLite.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

>= 0
>= 0

Runtime

 Project Readme

Pluckr

Query → one SQL statement → frozen result

A declarative read-query layer for ActiveRecord. Declare the shape, get one SQL statement and a frozen, ActiveRecord-free object.

user.id
user.email
user.subscription.name   # +1 query
user.photos.exists?      # +1 query
user.videos.count        # +1 query

becomes:

class UserSummary < Pluckr::Query
  source User

  schema do
    field :id
    field :email

    one :subscription do
      field :id
      field :name
    end

    exists :photos
    count :videos
  end
end

user = UserSummary.find(1)

user.id             # => 1
user.email          # => "user@example.com"
user.subscription   # => #<UserSummary.subscription id=5, name="Pro"> (nil if none)
user.photos_exists  # => true
user.videos_count   # => 2
user.to_h           # => {id: 1, email: "...", subscription: {...}, photos_exists: true, videos_count: 2}
SELECT "users"."id"                    AS "id",
       "users"."email"                 AS "email",
       "pluckr_subscription"."id"      AS "__pluckr.subscription.present",
       "pluckr_subscription"."id"      AS "subscription.id",
       "pluckr_subscription"."name"    AS "subscription.name",
       EXISTS (SELECT 1 FROM "photos" "pluckr_sub_1"
                WHERE "pluckr_sub_1"."user_id" = "users"."id"
                LIMIT 1 OFFSET 0)      AS "photos_exists",
       (SELECT COUNT(*) FROM "videos" "pluckr_sub_2"
         WHERE "pluckr_sub_2"."user_id" = "users"."id") AS "videos_count"
  FROM "users"
  LEFT OUTER JOIN "subscriptions" "pluckr_subscription"
    ON "pluckr_subscription"."user_id" = "users"."id"

Is it worth it?

PostgreSQL, 50k users, one page of this read model (full results):

rows ActiveRecord, N+1 ActiveRecord, preloaded + grouped Pluckr
10 49 SQL / 5.00 ms 5 SQL / 0.85 ms 1 SQL / 0.36 ms
100 481 SQL / 49.1 ms 5 SQL / 2.89 ms 1 SQL / 0.96 ms
1,000 4,801 SQL / 493 ms 5 SQL / 23.6 ms 1 SQL / 12.3 ms
10,000 48,001 SQL / 4,882 ms 5 SQL / 290 ms 1 SQL / 98.3 ms

2–3× faster than careful ActiveRecord, 10–51× faster than a forgotten preload. One statement, always.

Install

gem "pluckr"

Ruby 3.2+, ActiveRecord 7.1+. PostgreSQL, MySQL, SQLite.

Querying

UserSummary.find(1)                       # raises ActiveRecord::RecordNotFound
UserSummary.fetch                         # array
UserSummary.where(active: true)
           .order(created_at: :desc)
           .limit(50)
           .offset(100)
           .fetch
UserSummary.where(active: true).to_sql

UserSummary.for(user)                     # one record you already have
UserSummary.for(users)                    # those records, one statement, same order
UserSummary.for(User.active)              # a relation, still one statement

UserSummary.find_by(email: "a@b.c")       # first match, or nil (find_by! raises)
UserSummary.last                          # first(n) / last(n) / take(n) too
UserSummary.where(active: true).count     # SELECT COUNT(*)
UserSummary.where(active: true).exists?   # SELECT 1
UserSummary.find_each { |row| ... }       # keyset pages, never OFFSET
UserSummary.where(active: true).explain

where / where.not / order / limit / offset pass through to the root relation. Chains are immutable. A read is one statement.

Exceptions: paginated .for is two (keys, then the read model). A preloaded page (includes / eager_load) is loaded the way ActiveRecord loads it. find_each / in_batches are one per page. count / exists? / any? / none? / empty? / one? / many? compile no nodes.

.for a collection or multi-id find needs field :id. A record you handed over that is gone raises RecordNotFound; a relation just omits the row. group / having raises — load it (for(relation.to_a)).

Do not loop .for. Lists you do not already have as AR objects stay UserSummary.where(...).fetch.

find_each / in_batches need field :id, ignore your order, and refuse a chain that already has limit / offset.

Dashboards

No source — one object of independent aggregates:

class DashboardStats < Pluckr::Query
  schema do
    count :users,    from: User
    count :comments, from: Comment
    count :active_accounts, from: Account, where: { active: true }

    sum :paid_revenue, from: Order, column: :amount, where: { status: "paid" }
    max :last_signup,  from: User,  column: :created_at
  end
end

stats = DashboardStats.fetch
stats.users            # => 1520
stats.active_accounts  # => 904
stats.to_h             # => {users: 1520, comments: 48203, ...}
SELECT (SELECT COUNT(*) FROM "users") AS "users",
       (SELECT COUNT(*) FROM "comments") AS "comments",
       (SELECT COUNT(*) FROM "accounts" WHERE "accounts"."active" = TRUE) AS "active_accounts"

Ad-hoc aggregates

Runtime aggregates over relations you already have — one statement:

stats = Pluckr.batch do |b|
  b.count  user.videos,                           as: :video_count
  b.sum    user.videos.where(size: 100..), :size, as: :big_video_bytes
  b.avg    user.videos, :size,                    as: :average_size
  b.exists user.photos,                           as: :has_photos
  b.count  Account.active,                        as: :active_accounts
end

stats.video_count      # => 3
stats.has_photos       # => true

Each entry wraps the relation's own SQL, so default_scope, :through, STI, joins, merge, distinct, group, limit all apply. as: is required. count / sum / avg (average) / min / max / exists. A model class works (b.count User, as: :users). Pluckr::Batch.build { ... } is the unexecuted form (#to_sql, #fetch).

A batch answers one subject. For a column on every row of an index page, define a query.

DSL

Node Result SQL
field :email email selected column
field :email, as: :contact contact selected column
one :subscription do ... end subscription (or nil) LEFT OUTER JOIN
one :plan, via: :subscription do ... end plan LEFT OUTER JOIN
first :comment, via: :comments do ... end comment (or nil) correlated LIMIT 1
last :comment, via: :comments, order: :created_at do ... end comment correlated LIMIT 1
exists :photos photos_exists correlated EXISTS
exists :photos, as: :has_photos has_photos correlated EXISTS
count :videos videos_count scalar subquery
count :videos, as: :n_videos n_videos scalar subquery
count :users, from: User users scalar subquery
sum/avg/min/max ..., column: :amount <name>_sum, <name>_avg, ... scalar subquery

exists / count / sum / avg / min / max take where: and scope: (average aliases avg, output is still <name>_avg):

count :active_accounts, from: Account, where: { active: true }
count :active_accounts, from: Account, scope: ->(rel) { rel.active }

count  :big_videos, via: :videos, scope: ->(rel) { rel.where(size: 100..) }
exists :recent_photos, via: :photos, scope: -> { Photo.where(created_at: 1.week.ago..) }

where: is a Hash or a zero-argument callable returning one. A Hash is frozen at class load:

count :recent_orders, from: Order, where: { created_at: 1.week.ago.. }      # week before boot, forever
count :recent_orders, from: Order, where: -> { { created_at: 1.week.ago.. } }  # what you meant

scope: receives the relation (or nothing) and must return one for the same model. limit / offset / group inside a scope raise.

one nests:

one :subscription do
  field :name
  one :plan do
    field :name
  end
end

user.subscription.plan.name

Joins come from ActiveRecord reflection. has_many is never joined — use exists / count / first / last. Leave the OFFSET 0 on EXISTS alone (why).

One row out of many

class UserSummary < Pluckr::Query
  source User

  schema do
    field :email

    last :comment, via: :comments do          # newest by primary key
      field :body
      field :created_at
    end

    last :signup_order, via: :orders, order: :created_at do
      field :amount
    end
  end
end

user.comment.body        # => "the latest one"   (nil if none)
user.signup_order.amount

last matches relation.order(...).last. No order: → primary key. Blocks are fields only in v0.1; no where: / scope: yet. Index [foreign_key, order_column].

has_one needs a unique index

one is a LEFT OUTER JOIN. Duplicate child rows duplicate the parent — unique the FK:

add_index :subscriptions, :user_id, unique: true
add_index :profiles, :owner_id, unique: true       # has_one :profile, foreign_key: :owner_id

Missing vs NULL

user.subscription        # => nil     no row
user.subscription.name   # => nil     row exists, name is NULL

Result objects

Frozen, ActiveRecord-free, one reader per declared output:

user.class.ancestors.include?(ActiveRecord::Base) # => false
user.frozen?                                      # => true
user[:email]
user.to_h          # nested, internal aliases stripped
user.created_at    # NoMethodError - not selected

Cast with the model's column types. count / sum over no rows are 0; min / max / avg are nil. avg is a BigDecimal.

result.to_json   # => {"id":1,"email":"...","subscription":{"name":"Pro"},"videos_count":2}
result.as_json   # => {"id" => 1, ...}
result.to_hash   # => {id: 1, ...}, so **result works too

Errors, early

Schema mistakes raise at class-definition time:

field :does_not_exist  # Pluckr::UnknownField: User does not have column `does_not_exist`
one :unknown           # Pluckr::UnknownAssociation: User does not have association `unknown`
one :photos            # Pluckr::InvalidAssociation: `User#photos` is a has_many association
                       #   and cannot be used with `one`
field :id              # Pluckr::MissingSource (no `source` declared)
schema { }             # Pluckr::ConfigurationError: `schema` block is empty
count :x, scope: {}    # Pluckr::ConfigurationError: `scope:` expects a callable

Compile-time checks (scope: return value, alias length, one connection) raise on first to_sql / fetch.

Instrumentation

ActiveSupport::Notifications.subscribe("fetch.pluckr") do |*args|
  event = ActiveSupport::Notifications::Event.new(*args)

  event.payload[:name]  # => "UserSummary" (or "Pluckr::Batch")
  event.payload[:sql]   # => "SELECT ..."
  event.payload[:rows]  # => 100
  event.duration        # => 0.96 (ms)
end

Covers every statement Pluckr compiles. count / exists? and the key pluck for a paginated .for are ActiveRecord's (sql.active_record).

How it works

DSL -> schema AST -> reflection -> SQL compiler -> flat row -> result object

AGENTS.md is the internals.

Not in v0.1

  • many (nested collections)
  • raw SQL fields, manual joins
  • has_many :through, polymorphic belongs_to, scoped associations, and associations whose model has a default_scope (raise Pluckr::UnsupportedAssociation). Polymorphic has_many ..., as: and STI children work; so does Pluckr.batch for everything ActiveRecord can SQL
  • where: / scope: on first / last
  • composite primary keys are readable, and first orders by every key column, but find / for need a single-column key
  • writes, serializers, pagination beyond limit / offset, caching

What Pluckr is not

Not an ORM, not a serializer, not GraphQL, not a replacement for ActiveRecord. Use it for API read models, index tables, dashboards, reports, job payloads.

Benchmarks

bundle exec ruby benchmarks/read_models.rb            # PostgreSQL
DB=sqlite bundle exec ruby benchmarks/read_models.rb  # SQLite
PAGES=25,250 USERS=200000 bundle exec ruby benchmarks/read_models.rb

BENCHMARKS.md has plans, scaling, and what a missing FK index costs (123×).

Try it locally

git clone https://github.com/igorkasyanchuk/pluckr && cd pluckr
bundle install
bin/setup      # database, schema, seeds
bin/console    # IRB with models, seeds, example queries
>> UserSummary.find(2).to_h
=> {id: 2, email: "user1@example.com",
    subscription: {id: 1, name: "sub-1", plan: {name: "Pro", price_cents: 2900}},
    photos_exists: true, videos_count: 3}

>> statements { UserSummary.where(active: true).limit(5).fetch }
1. SELECT "users"."id" AS "id", ...
=> 1

>> DashboardStats.fetch.to_h
=> {users: 21, active_users: 16, comments: 49, active_accounts: 5,
    paid_revenue: 1641, last_signup: 2026-08-12 21:14:14 UTC}
sql(query) print the SQL
explain(query) EXPLAIN (analyze: true on PostgreSQL)
statements { ... } print and count statements
reload! pick up lib/ and dev/ edits
reseed!(users: 500) wipe and re-seed
reset! rebuild schema, then re-seed
Dev.log!(false) stop echoing SQL
Dev.counts row counts
DB=postgres bin/setup && DB=postgres bin/console
DB=mysql    bin/setup && DB=mysql    bin/console
SEED_USERS=5000 DB=postgres bin/setup
Variable Default Meaning
DB sqlite sqlite, postgres or mysql
SEED_USERS 20 users to seed
PLUCKR_DEV_DATABASE pluckr_dev database name (file name on SQLite)
PGHOST / PGPORT / PGUSER / PGPASSWORD postgres user PostgreSQL
MYSQL_HOST / MYSQL_PORT / MYSQL_USER / MYSQL_PASSWORD root@127.0.0.1 MySQL

Development

bundle exec rspec              # SQLite (default)
DB=postgres bundle exec rspec
DB=mysql bundle exec rspec

CI: Ruby 3.2 and 3.4 × SQLite, PostgreSQL, MySQL. Pass SQLite and PostgreSQL locally before pushing.

AGENTS.md is the internals. PROMPT.md is a system prompt for generating Pluckr queries.

License

MIT.