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 +7 -0
- data/CHANGELOG.md +66 -0
- data/LICENSE.txt +21 -0
- data/PROMPT.md +112 -0
- data/README.md +436 -0
- data/lib/pluckr/aliases.rb +37 -0
- data/lib/pluckr/batch.rb +216 -0
- data/lib/pluckr/compiler/sql.rb +363 -0
- data/lib/pluckr/errors.rb +24 -0
- data/lib/pluckr/query.rb +207 -0
- data/lib/pluckr/reflection/association.rb +109 -0
- data/lib/pluckr/relation.rb +414 -0
- data/lib/pluckr/result/builder.rb +100 -0
- data/lib/pluckr/result/object.rb +78 -0
- data/lib/pluckr/schema/aggregate.rb +58 -0
- data/lib/pluckr/schema/conditions.rb +45 -0
- data/lib/pluckr/schema/definition.rb +289 -0
- data/lib/pluckr/schema/exists.rb +28 -0
- data/lib/pluckr/schema/field.rb +16 -0
- data/lib/pluckr/schema/node.rb +21 -0
- data/lib/pluckr/schema/one.rb +37 -0
- data/lib/pluckr/schema/scope.rb +34 -0
- data/lib/pluckr/version.rb +5 -0
- data/lib/pluckr.rb +45 -0
- metadata +125 -0
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
|
+

|
|
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
|