pluckr 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 490833f6b37ba16885bf0e67d69cea3aebaa12e932e88460815bab02c0db42da
4
+ data.tar.gz: c152bdeb70d2c0c99546e24c1c5055d8d6db2701bee263cfda9d34806b6cdc4f
5
+ SHA512:
6
+ metadata.gz: a4e3e9ebce285dd7399aebc26958f9deff15cc6cd786876c82698b491f42759cf083e1ccd005311e1f610cb11fc40c608fbcc78b4710e09e4f0dcc62faa778a4
7
+ data.tar.gz: 46f29188b91f8942ba06ff1ca353d03e1f02be3bd80d8772a3ade972e757bd880a3446601fe6ed79273b95b5451b0e3cb06089ae9f845fc0248f99cef7f64439
data/CHANGELOG.md ADDED
@@ -0,0 +1,66 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ - Initial MVP: `source`, `field` (+ `as:`), `one` (+ `via:`, nested), `exists`,
6
+ `count`/`sum`/`min`/`max` (correlated and independent), `where`/`order`/`limit`/`offset`,
7
+ `find`, `fetch`, `to_sql`, immutable result objects.
8
+ - Compiles on PostgreSQL, MySQL and SQLite; values cast with the model's column types.
9
+ - Reflection support: custom foreign/primary keys, custom table names,
10
+ self-referential associations, STI type conditions.
11
+ - `where:` and `scope:` on `exists`/`count`/`sum`/`min`/`max`, correlated or independent.
12
+ - `where.not` on root queries.
13
+ - Raises `Pluckr::UnsupportedAssociation` for :through, polymorphic `belongs_to`,
14
+ scoped and `default_scope`d associations instead of generating wrong SQL;
15
+ polymorphic `has_many ..., as:` and STI children are supported.
16
+ - `exists` compiles to `EXISTS (... LIMIT 1 OFFSET 0)`; the fence keeps PostgreSQL
17
+ from hashing the subquery into a full scan of the child table.
18
+ - Scoped correlated aggregates read through a derived table, so a scope may join
19
+ (or be) the table it correlates to.
20
+ - `Pluckr.batch { |b| b.count relation, as: :name }` - ad-hoc aggregates over
21
+ relations the caller already built, in one statement. Respects everything
22
+ ActiveRecord applied, including `:through`, polymorphic, scoped and
23
+ `default_scope`d associations.
24
+ - Result objects serialise as their data: `as_json`, `to_json` and `to_hash`, so
25
+ `render json: result` no longer emits `{"attributes": {...}}`.
26
+ - `Query.for(record)`, `.for(records)` and `.for(relation)` fetch a read model
27
+ for records already in memory, in one statement. Collections keep input order;
28
+ an unloaded relation becomes a subquery of its conditions alone, a paginated
29
+ one is resolved to primary keys first (two statements, its own page and order),
30
+ and `group`/`having` raises instead of generating invalid SQL.
31
+ - `last`/`last!`/`take`/`take!` and a count for `first`/`last`/`take`
32
+ (`first(10)`), read with a `LIMIT` rather than by hydrating the chain and
33
+ throwing most of it away.
34
+ - `count`/`size`/`exists?`/`any?`/`none?`/`empty?`/`one?`/`many?` ask the database (`COUNT(*)`,
35
+ `SELECT 1`) instead of letting Enumerable build every result object first; a
36
+ block still sends `any?`/`none?` back to Enumerable.
37
+ - `find_by`/`find_by!` (ActiveRecord's arity: a condition is required) and
38
+ `explain`, which explains the statement Pluckr runs.
39
+ - `find` takes several ids (or an array) and returns them in the order asked,
40
+ sends a block to Enumerable, and treats `find(nil)` as `RecordNotFound` -
41
+ ActiveRecord's behaviour in each case.
42
+ - `find_each`/`in_batches` read a whole chain in pages, seeking by primary key
43
+ rather than paging with `OFFSET`. Needs `field` on the primary key; refuses a
44
+ chain that already has a `limit`/`offset`.
45
+ - Every statement publishes an `ActiveSupport::Notifications` `fetch.pluckr`
46
+ event with `:name`, `:sql` and `:rows`, covering `fetch`, `find`, `first`,
47
+ `for` and `Pluckr.batch`.
48
+ - Composite primary keys no longer break `first`; `find`/`for` raise a descriptive
49
+ error for them.
50
+ - Query subclasses inherit `source` and `schema` from the query they subclass.
51
+ - `first` orders by primary key when the relation has no order, like ActiveRecord.
52
+ - Reading two connection pools in one statement raises instead of producing a
53
+ confusing SQL error.
54
+ - `order:` directions are case-insensitive; `:ASC` used to reach Arel unchanged.
55
+ - `first`/`last` select one row of a collection (`last :comment, via: :comments,
56
+ order: :created_at do ... end`), compiled as one correlated `LIMIT 1` subquery
57
+ per column, with the primary key as a tiebreaker so every column comes from the
58
+ same row.
59
+ - `avg` (alias `average`) alongside `count`/`sum`/`min`/`max`, correlated,
60
+ independent and in `Pluckr.batch`. Returns a BigDecimal, or nil over no rows,
61
+ matching `relation.average`.
62
+ - `sum` returns 0 rather than nil when nothing matched, matching
63
+ `relation.sum(:column)`; `min`/`max` still return nil.
64
+ - `where:` accepts a callable returning a Hash, evaluated on every compilation,
65
+ so `-> { { created_at: 1.week.ago.. } }` is not frozen at class-load time.
66
+ - Benchmarks in `benchmarks/read_models.rb`, results in `BENCHMARKS.md`.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Igor Kasyanchuk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/PROMPT.md ADDED
@@ -0,0 +1,112 @@
1
+ # Pluckr — prompt for AI assistants
2
+
3
+ Copy everything below the line into a system prompt or project instructions when generating or reviewing Rails code that uses Pluckr.
4
+
5
+ ---
6
+
7
+ You are working in a Rails app that uses **Pluckr** (`gem "pluckr"`): a declarative read-query layer for ActiveRecord. A `Pluckr::Query` subclass declares the shape of the data; Pluckr compiles it into **exactly one SQL statement** and returns frozen, ActiveRecord-free result objects.
8
+
9
+ ## When to use it
10
+
11
+ Use Pluckr for read models: index/show payloads, admin tables, dashboards, job payloads. Do not use it for writes, nested collections (`has_many` arrays), or as a serializer framework.
12
+
13
+ Do **not** load ActiveRecord models and then project them. Do **not** add `has_pluckr` / `user.user_card` on models — that is N+1.
14
+
15
+ ## Define a query
16
+
17
+ ```ruby
18
+ class UserCard < Pluckr::Query
19
+ source User
20
+
21
+ schema do
22
+ field :id
23
+ field :email
24
+
25
+ one :subscription do
26
+ field :id
27
+ field :name
28
+ end
29
+
30
+ exists :photos
31
+ count :videos
32
+ last :comment, via: :comments, order: :created_at do
33
+ field :body
34
+ end
35
+ end
36
+ end
37
+ ```
38
+
39
+ Lists and filters live on the query, not on `User`:
40
+
41
+ ```ruby
42
+ UserCard.where(active: true).order(created_at: :desc).limit(50).fetch
43
+ UserCard.find(id)
44
+ UserCard.find_by(email: "a@b.c") # nil if absent; find_by! raises
45
+ UserCard.first # also first(n) / last / last(n) / take(n)
46
+ UserCard.where(active: true).count # COUNT(*) - also exists?/any?/none?/empty?/one?/many?
47
+ UserCard.find_each { |row| ... } # the whole table, a page at a time
48
+ ```
49
+
50
+ ## Records you already have (`.for`)
51
+
52
+ When Ruby already holds AR records (or a relation of them), use `.for`. It is **one statement** for a collection (two for a paginated relation), and keeps array order.
53
+
54
+ ```ruby
55
+ UserCard.for(user) # one object (RecordNotFound if gone)
56
+ UserCard.for(users) # array, same order as `users`
57
+ UserCard.for(User.active) # relation filtered in SQL, not loaded into Ruby
58
+ ```
59
+
60
+ Rules for `.for`:
61
+
62
+ - a collection requires `field :id` (or `field :id, as: ...`) on the schema, so rows can be aligned to the input order; empty collection gives `[]`, a missing row raises `ActiveRecord::RecordNotFound`
63
+ - the query's own `where` still applies, so `Query.where(active: true).for(inactive_record)` raises `RecordNotFound`
64
+ - an unloaded relation is passed as a subquery, so only its conditions count: order and row count are the query's, not the relation's, and `lock` is dropped
65
+ - a paginated relation (`limit`/`offset`) instead reads its primary keys first and the read model second - two statements, and the page and its order are the relation's own. No records are instantiated unless the relation joins to preload (`includes`/`eager_load`), whose `LIMIT` counts parent rows, so ActiveRecord pages it. Rows the query's own `where` excludes are absent rather than `RecordNotFound`; that is reserved for records you hand over yourself
66
+ - `group`/`having` on a relation raises `Pluckr::ConfigurationError` - load it instead (`for(relation.to_a)`)
67
+ - do not pass a Pluckr query to `.for`; it takes AR records. Use `fetch`
68
+ - wrong model, or a composite primary key: `Pluckr::ConfigurationError`. Composite keys use `where(...).first`
69
+
70
+ Never do this:
71
+
72
+ ```ruby
73
+ users.each { |u| UserCard.for(u) } # N queries
74
+ users.map { |u| UserCard.find(u.id) }
75
+ ```
76
+
77
+ ## Dashboards and ad-hoc aggregates
78
+
79
+ No `source` → one object of independent subqueries:
80
+
81
+ ```ruby
82
+ class DashboardStats < Pluckr::Query
83
+ schema do
84
+ count :users, from: User
85
+ sum :paid, from: Order, column: :amount, where: { status: "paid" }
86
+ end
87
+ end
88
+ DashboardStats.fetch
89
+ ```
90
+
91
+ Runtime / association-scoped aggregates (including `:through`, scoped associations): `Pluckr.batch`.
92
+
93
+ ```ruby
94
+ Pluckr.batch do |b|
95
+ b.count user.videos, as: :video_count
96
+ b.exists user.photos, as: :has_photos
97
+ end
98
+ ```
99
+
100
+ ## DSL rules
101
+
102
+ - `field` / `one` / `exists` / `count`/`sum`/`avg`/`min`/`max` / `first`/`last`
103
+ - `one` is singular only (`belongs_to` / `has_one`). `has_one` needs a unique index on the FK or `fetch` duplicates parent rows
104
+ - `has_many` is never joined; use `exists`, `count`, or `first`/`last` (fields only; no `where:`/`scope:` on pick nodes yet)
105
+ - `where:` on aggregates is a Hash or a **zero-argument** lambda `-> { { created_at: 1.week.ago.. } }`. Use `scope: ->(rel) { ... }` to receive the relation
106
+ - `:through`, polymorphic `belongs_to`, association scopes, and `default_scope` on the target raise `Pluckr::UnsupportedAssociation` — use `Pluckr.batch` instead
107
+ - Do not write SQL against joined table names (`subscriptions.name`); Pluckr aliases them (`pluckr_subscription`)
108
+ - Results: frozen objects, `to_h` / `as_json` / `to_json`. Not ActiveRecord. Missing `one` is `nil`
109
+
110
+ ## SQL budget
111
+
112
+ Every `fetch` / `find` / `first` / `last` / `take` / `for` is one statement, no matter how many nodes the schema has. Two only for `.for(paginated_relation)` (keys, then the read) and one per page for `find_each` / `in_batches`. `count` / `exists?` / `any?` / `none?` / `empty?` / `one?` / `many?` are one statement each and build no result objects. If you emit more than one Pluckr query per request for the same screen, you are using it wrong — widen the schema or use `Pluckr.batch`.
data/README.md ADDED
@@ -0,0 +1,436 @@
1
+ # Pluckr
2
+
3
+ ![Query → one SQL statement → frozen result](docs/demo.gif)
4
+
5
+ A declarative read-query layer for ActiveRecord. Declare the shape, get one SQL
6
+ statement and a frozen, ActiveRecord-free object.
7
+
8
+ ```ruby
9
+ user.id
10
+ user.email
11
+ user.subscription.name # +1 query
12
+ user.photos.exists? # +1 query
13
+ user.videos.count # +1 query
14
+ ```
15
+
16
+ becomes:
17
+
18
+ ```ruby
19
+ class UserSummary < Pluckr::Query
20
+ source User
21
+
22
+ schema do
23
+ field :id
24
+ field :email
25
+
26
+ one :subscription do
27
+ field :id
28
+ field :name
29
+ end
30
+
31
+ exists :photos
32
+ count :videos
33
+ end
34
+ end
35
+
36
+ user = UserSummary.find(1)
37
+
38
+ user.id # => 1
39
+ user.email # => "user@example.com"
40
+ user.subscription # => #<UserSummary.subscription id=5, name="Pro"> (nil if none)
41
+ user.photos_exists # => true
42
+ user.videos_count # => 2
43
+ user.to_h # => {id: 1, email: "...", subscription: {...}, photos_exists: true, videos_count: 2}
44
+ ```
45
+
46
+ ```sql
47
+ SELECT "users"."id" AS "id",
48
+ "users"."email" AS "email",
49
+ "pluckr_subscription"."id" AS "__pluckr.subscription.present",
50
+ "pluckr_subscription"."id" AS "subscription.id",
51
+ "pluckr_subscription"."name" AS "subscription.name",
52
+ EXISTS (SELECT 1 FROM "photos" "pluckr_sub_1"
53
+ WHERE "pluckr_sub_1"."user_id" = "users"."id"
54
+ LIMIT 1 OFFSET 0) AS "photos_exists",
55
+ (SELECT COUNT(*) FROM "videos" "pluckr_sub_2"
56
+ WHERE "pluckr_sub_2"."user_id" = "users"."id") AS "videos_count"
57
+ FROM "users"
58
+ LEFT OUTER JOIN "subscriptions" "pluckr_subscription"
59
+ ON "pluckr_subscription"."user_id" = "users"."id"
60
+ ```
61
+
62
+ ## Is it worth it?
63
+
64
+ PostgreSQL, 50k users, one page of this read model ([full results](BENCHMARKS.md)):
65
+
66
+ | rows | ActiveRecord, N+1 | ActiveRecord, preloaded + grouped | Pluckr |
67
+ | ---: | ---: | ---: | ---: |
68
+ | 10 | 49 SQL / 5.00 ms | 5 SQL / 0.85 ms | **1 SQL / 0.36 ms** |
69
+ | 100 | 481 SQL / 49.1 ms | 5 SQL / 2.89 ms | **1 SQL / 0.96 ms** |
70
+ | 1,000 | 4,801 SQL / 493 ms | 5 SQL / 23.6 ms | **1 SQL / 12.3 ms** |
71
+ | 10,000 | 48,001 SQL / 4,882 ms | 5 SQL / 290 ms | **1 SQL / 98.3 ms** |
72
+
73
+ 2–3× faster than careful ActiveRecord, 10–51× faster than a forgotten preload.
74
+ One statement, always.
75
+
76
+ ## Install
77
+
78
+ ```ruby
79
+ gem "pluckr"
80
+ ```
81
+
82
+ Ruby 3.2+, ActiveRecord 7.1+. PostgreSQL, MySQL, SQLite.
83
+
84
+ ## Querying
85
+
86
+ ```ruby
87
+ UserSummary.find(1) # raises ActiveRecord::RecordNotFound
88
+ UserSummary.fetch # array
89
+ UserSummary.where(active: true)
90
+ .order(created_at: :desc)
91
+ .limit(50)
92
+ .offset(100)
93
+ .fetch
94
+ UserSummary.where(active: true).to_sql
95
+
96
+ UserSummary.for(user) # one record you already have
97
+ UserSummary.for(users) # those records, one statement, same order
98
+ UserSummary.for(User.active) # a relation, still one statement
99
+
100
+ UserSummary.find_by(email: "a@b.c") # first match, or nil (find_by! raises)
101
+ UserSummary.last # first(n) / last(n) / take(n) too
102
+ UserSummary.where(active: true).count # SELECT COUNT(*)
103
+ UserSummary.where(active: true).exists? # SELECT 1
104
+ UserSummary.find_each { |row| ... } # keyset pages, never OFFSET
105
+ UserSummary.where(active: true).explain
106
+ ```
107
+
108
+ `where` / `where.not` / `order` / `limit` / `offset` pass through to the root
109
+ relation. Chains are immutable. A read is one statement.
110
+
111
+ Exceptions: paginated `.for` is two (keys, then the read model). A preloaded
112
+ page (`includes` / `eager_load`) is loaded the way ActiveRecord loads it.
113
+ `find_each` / `in_batches` are one per page. `count` / `exists?` / `any?` /
114
+ `none?` / `empty?` / `one?` / `many?` compile no nodes.
115
+
116
+ `.for` a collection or multi-id `find` needs `field :id`. A record you handed
117
+ over that is gone raises `RecordNotFound`; a relation just omits the row.
118
+ `group` / `having` raises — load it (`for(relation.to_a)`).
119
+
120
+ Do not loop `.for`. Lists you do not already have as AR objects stay
121
+ `UserSummary.where(...).fetch`.
122
+
123
+ `find_each` / `in_batches` need `field :id`, ignore your `order`, and refuse a
124
+ chain that already has `limit` / `offset`.
125
+
126
+ ## Dashboards
127
+
128
+ No `source` — one object of independent aggregates:
129
+
130
+ ```ruby
131
+ class DashboardStats < Pluckr::Query
132
+ schema do
133
+ count :users, from: User
134
+ count :comments, from: Comment
135
+ count :active_accounts, from: Account, where: { active: true }
136
+
137
+ sum :paid_revenue, from: Order, column: :amount, where: { status: "paid" }
138
+ max :last_signup, from: User, column: :created_at
139
+ end
140
+ end
141
+
142
+ stats = DashboardStats.fetch
143
+ stats.users # => 1520
144
+ stats.active_accounts # => 904
145
+ stats.to_h # => {users: 1520, comments: 48203, ...}
146
+ ```
147
+
148
+ ```sql
149
+ SELECT (SELECT COUNT(*) FROM "users") AS "users",
150
+ (SELECT COUNT(*) FROM "comments") AS "comments",
151
+ (SELECT COUNT(*) FROM "accounts" WHERE "accounts"."active" = TRUE) AS "active_accounts"
152
+ ```
153
+
154
+ ## Ad-hoc aggregates
155
+
156
+ Runtime aggregates over relations you already have — one statement:
157
+
158
+ ```ruby
159
+ stats = Pluckr.batch do |b|
160
+ b.count user.videos, as: :video_count
161
+ b.sum user.videos.where(size: 100..), :size, as: :big_video_bytes
162
+ b.avg user.videos, :size, as: :average_size
163
+ b.exists user.photos, as: :has_photos
164
+ b.count Account.active, as: :active_accounts
165
+ end
166
+
167
+ stats.video_count # => 3
168
+ stats.has_photos # => true
169
+ ```
170
+
171
+ Each entry wraps the relation's own SQL, so `default_scope`, `:through`, STI,
172
+ `joins`, `merge`, `distinct`, `group`, `limit` all apply. `as:` is required.
173
+ `count` / `sum` / `avg` (`average`) / `min` / `max` / `exists`. A model class
174
+ works (`b.count User, as: :users`). `Pluckr::Batch.build { ... }` is the
175
+ unexecuted form (`#to_sql`, `#fetch`).
176
+
177
+ A batch answers one subject. For a column on every row of an index page, define
178
+ a query.
179
+
180
+ ## DSL
181
+
182
+ | Node | Result | SQL |
183
+ | --- | --- | --- |
184
+ | `field :email` | `email` | selected column |
185
+ | `field :email, as: :contact` | `contact` | selected column |
186
+ | `one :subscription do ... end` | `subscription` (or `nil`) | `LEFT OUTER JOIN` |
187
+ | `one :plan, via: :subscription do ... end` | `plan` | `LEFT OUTER JOIN` |
188
+ | `first :comment, via: :comments do ... end` | `comment` (or `nil`) | correlated `LIMIT 1` |
189
+ | `last :comment, via: :comments, order: :created_at do ... end` | `comment` | correlated `LIMIT 1` |
190
+ | `exists :photos` | `photos_exists` | correlated `EXISTS` |
191
+ | `exists :photos, as: :has_photos` | `has_photos` | correlated `EXISTS` |
192
+ | `count :videos` | `videos_count` | scalar subquery |
193
+ | `count :videos, as: :n_videos` | `n_videos` | scalar subquery |
194
+ | `count :users, from: User` | `users` | scalar subquery |
195
+ | `sum/avg/min/max ..., column: :amount` | `<name>_sum`, `<name>_avg`, ... | scalar subquery |
196
+
197
+ `exists` / `count` / `sum` / `avg` / `min` / `max` take `where:` and `scope:`
198
+ (`average` aliases `avg`, output is still `<name>_avg`):
199
+
200
+ ```ruby
201
+ count :active_accounts, from: Account, where: { active: true }
202
+ count :active_accounts, from: Account, scope: ->(rel) { rel.active }
203
+
204
+ count :big_videos, via: :videos, scope: ->(rel) { rel.where(size: 100..) }
205
+ exists :recent_photos, via: :photos, scope: -> { Photo.where(created_at: 1.week.ago..) }
206
+ ```
207
+
208
+ `where:` is a Hash **or** a zero-argument callable returning one. A Hash is
209
+ frozen at class load:
210
+
211
+ ```ruby
212
+ count :recent_orders, from: Order, where: { created_at: 1.week.ago.. } # week before boot, forever
213
+ count :recent_orders, from: Order, where: -> { { created_at: 1.week.ago.. } } # what you meant
214
+ ```
215
+
216
+ `scope:` receives the relation (or nothing) and must return one for the same
217
+ model. `limit` / `offset` / `group` inside a scope raise.
218
+
219
+ `one` nests:
220
+
221
+ ```ruby
222
+ one :subscription do
223
+ field :name
224
+ one :plan do
225
+ field :name
226
+ end
227
+ end
228
+
229
+ user.subscription.plan.name
230
+ ```
231
+
232
+ Joins come from ActiveRecord reflection. `has_many` is never joined — use
233
+ `exists` / `count` / `first` / `last`. Leave the `OFFSET 0` on `EXISTS` alone
234
+ ([why](BENCHMARKS.md#why-exists-carries-limit-1-offset-0)).
235
+
236
+ ### One row out of many
237
+
238
+ ```ruby
239
+ class UserSummary < Pluckr::Query
240
+ source User
241
+
242
+ schema do
243
+ field :email
244
+
245
+ last :comment, via: :comments do # newest by primary key
246
+ field :body
247
+ field :created_at
248
+ end
249
+
250
+ last :signup_order, via: :orders, order: :created_at do
251
+ field :amount
252
+ end
253
+ end
254
+ end
255
+
256
+ user.comment.body # => "the latest one" (nil if none)
257
+ user.signup_order.amount
258
+ ```
259
+
260
+ `last` matches `relation.order(...).last`. No `order:` → primary key. Blocks
261
+ are fields only in v0.1; no `where:` / `scope:` yet. Index `[foreign_key, order_column]`.
262
+
263
+ ### `has_one` needs a unique index
264
+
265
+ `one` is a `LEFT OUTER JOIN`. Duplicate child rows duplicate the parent — unique
266
+ the FK:
267
+
268
+ ```ruby
269
+ add_index :subscriptions, :user_id, unique: true
270
+ add_index :profiles, :owner_id, unique: true # has_one :profile, foreign_key: :owner_id
271
+ ```
272
+
273
+ ### Missing vs NULL
274
+
275
+ ```ruby
276
+ user.subscription # => nil no row
277
+ user.subscription.name # => nil row exists, name is NULL
278
+ ```
279
+
280
+ ## Result objects
281
+
282
+ Frozen, ActiveRecord-free, one reader per declared output:
283
+
284
+ ```ruby
285
+ user.class.ancestors.include?(ActiveRecord::Base) # => false
286
+ user.frozen? # => true
287
+ user[:email]
288
+ user.to_h # nested, internal aliases stripped
289
+ user.created_at # NoMethodError - not selected
290
+ ```
291
+
292
+ Cast with the model's column types. `count` / `sum` over no rows are `0`;
293
+ `min` / `max` / `avg` are `nil`. `avg` is a `BigDecimal`.
294
+
295
+ ```ruby
296
+ result.to_json # => {"id":1,"email":"...","subscription":{"name":"Pro"},"videos_count":2}
297
+ result.as_json # => {"id" => 1, ...}
298
+ result.to_hash # => {id: 1, ...}, so **result works too
299
+ ```
300
+
301
+ ## Errors, early
302
+
303
+ Schema mistakes raise at class-definition time:
304
+
305
+ ```ruby
306
+ field :does_not_exist # Pluckr::UnknownField: User does not have column `does_not_exist`
307
+ one :unknown # Pluckr::UnknownAssociation: User does not have association `unknown`
308
+ one :photos # Pluckr::InvalidAssociation: `User#photos` is a has_many association
309
+ # and cannot be used with `one`
310
+ field :id # Pluckr::MissingSource (no `source` declared)
311
+ schema { } # Pluckr::ConfigurationError: `schema` block is empty
312
+ count :x, scope: {} # Pluckr::ConfigurationError: `scope:` expects a callable
313
+ ```
314
+
315
+ Compile-time checks (`scope:` return value, alias length, one connection) raise
316
+ on first `to_sql` / `fetch`.
317
+
318
+ ## Instrumentation
319
+
320
+ ```ruby
321
+ ActiveSupport::Notifications.subscribe("fetch.pluckr") do |*args|
322
+ event = ActiveSupport::Notifications::Event.new(*args)
323
+
324
+ event.payload[:name] # => "UserSummary" (or "Pluckr::Batch")
325
+ event.payload[:sql] # => "SELECT ..."
326
+ event.payload[:rows] # => 100
327
+ event.duration # => 0.96 (ms)
328
+ end
329
+ ```
330
+
331
+ Covers every statement Pluckr compiles. `count` / `exists?` and the key `pluck`
332
+ for a paginated `.for` are ActiveRecord's (`sql.active_record`).
333
+
334
+ ## How it works
335
+
336
+ ```
337
+ DSL -> schema AST -> reflection -> SQL compiler -> flat row -> result object
338
+ ```
339
+
340
+ [AGENTS.md](AGENTS.md) is the internals.
341
+
342
+ ## Not in v0.1
343
+
344
+ - `many` (nested collections)
345
+ - raw SQL fields, manual joins
346
+ - `has_many :through`, polymorphic `belongs_to`, scoped associations, and
347
+ associations whose model has a `default_scope` (raise
348
+ `Pluckr::UnsupportedAssociation`). Polymorphic `has_many ..., as:` and STI
349
+ children work; so does `Pluckr.batch` for everything ActiveRecord can SQL
350
+ - `where:` / `scope:` on `first` / `last`
351
+ - composite primary keys are readable, and `first` orders by every key column,
352
+ but `find` / `for` need a single-column key
353
+ - writes, serializers, pagination beyond `limit` / `offset`, caching
354
+
355
+ ## What Pluckr is not
356
+
357
+ Not an ORM, not a serializer, not GraphQL, not a replacement for ActiveRecord.
358
+ Use it for API read models, index tables, dashboards, reports, job payloads.
359
+
360
+ ## Benchmarks
361
+
362
+ ```bash
363
+ bundle exec ruby benchmarks/read_models.rb # PostgreSQL
364
+ DB=sqlite bundle exec ruby benchmarks/read_models.rb # SQLite
365
+ PAGES=25,250 USERS=200000 bundle exec ruby benchmarks/read_models.rb
366
+ ```
367
+
368
+ [BENCHMARKS.md](BENCHMARKS.md) has plans, scaling, and what a missing FK index
369
+ costs (123×).
370
+
371
+ ## Try it locally
372
+
373
+ ```bash
374
+ git clone https://github.com/igorkasyanchuk/pluckr && cd pluckr
375
+ bundle install
376
+ bin/setup # database, schema, seeds
377
+ bin/console # IRB with models, seeds, example queries
378
+ ```
379
+
380
+ ```
381
+ >> UserSummary.find(2).to_h
382
+ => {id: 2, email: "user1@example.com",
383
+ subscription: {id: 1, name: "sub-1", plan: {name: "Pro", price_cents: 2900}},
384
+ photos_exists: true, videos_count: 3}
385
+
386
+ >> statements { UserSummary.where(active: true).limit(5).fetch }
387
+ 1. SELECT "users"."id" AS "id", ...
388
+ => 1
389
+
390
+ >> DashboardStats.fetch.to_h
391
+ => {users: 21, active_users: 16, comments: 49, active_accounts: 5,
392
+ paid_revenue: 1641, last_signup: 2026-08-12 21:14:14 UTC}
393
+ ```
394
+
395
+ | | |
396
+ | --- | --- |
397
+ | `sql(query)` | print the SQL |
398
+ | `explain(query)` | `EXPLAIN` (`analyze: true` on PostgreSQL) |
399
+ | `statements { ... }` | print and count statements |
400
+ | `reload!` | pick up `lib/` and `dev/` edits |
401
+ | `reseed!(users: 500)` | wipe and re-seed |
402
+ | `reset!` | rebuild schema, then re-seed |
403
+ | `Dev.log!(false)` | stop echoing SQL |
404
+ | `Dev.counts` | row counts |
405
+
406
+ ```bash
407
+ DB=postgres bin/setup && DB=postgres bin/console
408
+ DB=mysql bin/setup && DB=mysql bin/console
409
+ SEED_USERS=5000 DB=postgres bin/setup
410
+ ```
411
+
412
+ | Variable | Default | Meaning |
413
+ | --- | --- | --- |
414
+ | `DB` | `sqlite` | `sqlite`, `postgres` or `mysql` |
415
+ | `SEED_USERS` | `20` | users to seed |
416
+ | `PLUCKR_DEV_DATABASE` | `pluckr_dev` | database name (file name on SQLite) |
417
+ | `PGHOST` / `PGPORT` / `PGUSER` / `PGPASSWORD` | `postgres` user | PostgreSQL |
418
+ | `MYSQL_HOST` / `MYSQL_PORT` / `MYSQL_USER` / `MYSQL_PASSWORD` | `root@127.0.0.1` | MySQL |
419
+
420
+ ## Development
421
+
422
+ ```bash
423
+ bundle exec rspec # SQLite (default)
424
+ DB=postgres bundle exec rspec
425
+ DB=mysql bundle exec rspec
426
+ ```
427
+
428
+ CI: Ruby 3.2 and 3.4 × SQLite, PostgreSQL, MySQL. Pass SQLite and PostgreSQL
429
+ locally before pushing.
430
+
431
+ [AGENTS.md](AGENTS.md) is the internals. [PROMPT.md](PROMPT.md) is a system
432
+ prompt for generating Pluckr queries.
433
+
434
+ ## License
435
+
436
+ MIT.
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pluckr
4
+ # Single source of truth for the flat column aliases used in generated SQL.
5
+ #
6
+ # The SQL stays flat; nesting lives in the alias path:
7
+ #
8
+ # users.id AS "id"
9
+ # subscriptions.name AS "subscription.name"
10
+ # subscriptions.id AS "__pluckr.subscription.present"
11
+ #
12
+ # The result builder reads the same helpers back, so the encoding is never
13
+ # duplicated between the compiler and the hydrator.
14
+ module Aliases
15
+ SEPARATOR = "."
16
+ PRESENCE_PREFIX = "__pluckr"
17
+ PRESENCE_SUFFIX = "present"
18
+ TABLE_PREFIX = "pluckr"
19
+
20
+ module_function
21
+
22
+ # Output alias for a value node, e.g. ["subscription"], :name => "subscription.name"
23
+ def output(path, name)
24
+ (path + [name]).join(SEPARATOR)
25
+ end
26
+
27
+ # Hidden marker telling the hydrator whether a nested `one` row exists at all.
28
+ def presence(path)
29
+ ([PRESENCE_PREFIX] + path + [PRESENCE_SUFFIX]).join(SEPARATOR)
30
+ end
31
+
32
+ # Table alias for a joined singular association.
33
+ def table(path)
34
+ ([TABLE_PREFIX] + path).join("_")
35
+ end
36
+ end
37
+ end