Skip to content

Commit c5ac496

Browse files
authored
Merge pull request #10 from stevegeek/2.x-spike
V2.0
2 parents aec5115 + 2afef49 commit c5ac496

75 files changed

Lines changed: 2042 additions & 692 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/main.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ jobs:
99
strategy:
1010
fail-fast: false
1111
matrix:
12-
ruby: [ "3.2", "3.3", "3.4" ]
13-
rails: [ "7.2", "8.0" ]
12+
ruby: [ "3.2", "3.3", "3.4", "4.0" ]
13+
rails: [ "8.0", "8.1" ]
1414
steps:
1515
- uses: actions/checkout@v3
1616
- name: Set up Ruby

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
/test/dummy/log/*.log
1212
/test/dummy/storage/
1313
/test/dummy/tmp/
14+
/test/tmp/
1415
/log/*.log
1516
Gemfile.lock
1617
/.idea/

‎Appraisals‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
appraise "rails-7.2" do
2-
gem "rails", "~> 7.2"
1+
appraise "rails-8.0" do
2+
gem "rails", "~> 8.0.0"
33
end
44

5-
appraise "rails-8.0" do
6-
gem "rails", "~> 8.0"
5+
appraise "rails-8.1" do
6+
gem "rails", "~> 8.1.0"
77
end

‎CHANGELOG.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,32 @@
11
## [Unreleased]
22

3+
## [2.0.0] - unreleased
4+
5+
### Breaking
6+
7+
- Minimum Rails 8.0; minimum Literal 1.9.
8+
- Instance composition (`q1 + q2`) returns a value (`Quo::ComposedRelationBackedQuery` / `Quo::ComposedCollectionBackedQuery`), not an anonymous class. No more per-call class allocation.
9+
- Each operand keeps its own constructor-time props; merge is the AND of each side's filter (1.x's prop fan-out is gone).
10+
- Pagination inherits as a coupled `(page, page_size)` pair from whichever operand is paginated (right wins).
11+
- `wrap` is type-strict at definition time. `Quo::RelationBackedQuery.wrap` requires an `ActiveRecord::Relation` or `Quo::RelationBackedQuery`; `Quo::CollectionBackedQuery.wrap` requires `Enumerable` or `Quo::CollectionBackedQuery`. Block forms still defer.
12+
13+
### Added
14+
15+
- `Quo::RelationBackedQuery.from(relation)` and `Quo::CollectionBackedQuery.from(enumerable)` value-form constructors. Zero class allocation per call.
16+
- `copy(prop: value)` on a composed instance fans the override across every operand declaring the prop, recursing into composed operands.
17+
- Composed and wrapped value-form classes inherit from `Quo.relation_backed_query_base_class` / `Quo.collection_backed_query_base_class`.
18+
19+
### Fixed
20+
21+
- #5 — `Quo::RelationBackedQuerySpecification#joins` / `#left_outer_joins` accept multiple tables.
22+
- #6 — `_specification` survives `Query#copy` and merge paths.
23+
- #7 — `Quo::Results` Enumerable delegation returns transformed items for filter-style methods (`select`, `reject`, `find`, `sort_by`, `partition`, …) where the block already saw transformed items.
24+
- #8 — `wrap` rejects the wrong type at definition time with a message pointing at the matching constructor.
25+
26+
### Removed
27+
28+
- Unused per-call `Quo::Composing::InstanceStrategyRegistry` + the four instance-strategy classes.
29+
330
## [1.0.1] - 2026-05-12
431

532
### Fixed

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
<p align="center">
2+
<img src="quo.png" alt="Quo" width="160" height="160" />
3+
</p>
4+
15
# Quo: Query Objects for ActiveRecord & Collections
26

37
![Coverage](badges/coverage_badge_total.svg)

‎UPGRADING.md‎

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
# Upgrading Quo
2+
3+
## Upgrading from 1.x to 2.0
4+
5+
Quo 2.0 is a structural rework of query composition. Most code continues
6+
to work unchanged. There are two intentional behavioural changes worth
7+
knowing about before you bump, plus a new value-form API you'll want to
8+
adopt for hot paths.
9+
10+
### TL;DR
11+
12+
- Composition still uses `+` / `compose` / `merge`. Same API.
13+
- **Class composition** (`SomeClass + OtherClass`) — unchanged. Returns a Class.
14+
- **Instance composition** (`some_instance + other_instance`) — now returns a
15+
concrete value (`Quo::ComposedRelationBackedQuery` or
16+
`Quo::ComposedCollectionBackedQuery`), not an anonymous class. Allocates
17+
no new classes per call.
18+
- New: `Quo::RelationBackedQuery.from(relation)` and
19+
`Quo::CollectionBackedQuery.from(enumerable)` — value-form constructors,
20+
for use instead of `wrap(rel).new` on hot paths.
21+
- 1.x's "prop fan-out from a synthesised parent class" is gone. Each
22+
operand keeps its own constructor-time props. Two subtle consequences
23+
documented below.
24+
- ~2× faster instance composition, with **zero anonymous classes per call**.
25+
26+
### Why
27+
28+
1.x had a single composition path. Both `Q1 + Q2` (between classes) and
29+
`q1 + q2` (between instances) went through the same machinery, which
30+
allocated a fresh anonymous class on every call. For instances, the
31+
class was then immediately instantiated once and discarded — pure
32+
waste, paid per request.
33+
34+
2.0 splits the two cases. Class composition keeps the class-allocation
35+
machinery (it's the right shape for *defining a new type* once at
36+
code-load time). Instance composition produces a value instead — a
37+
small struct that holds the two operands and walks them at unwrap
38+
time. No anonymous classes, no `prop` re-registration.
39+
40+
### What's the same
41+
42+
- The `+` / `compose` / `merge` API at both class and instance levels.
43+
- The leaf query class API: `prop`, `query`, `collection`, `transform`,
44+
`wrap`, `with_specification`, etc.
45+
- Pagination and transformer surfaces on every Quo::Query.
46+
- Specifications (`order(...)`, `joins(...)`, `where(...)`, `distinct`,
47+
etc.) on relation-backed queries, including when applied to a composed
48+
instance.
49+
- `Quo::ComposedQuery` is still the marker module used by class
50+
composition. Existing `kind_of?(Quo::ComposedQuery)` checks on
51+
class-composed results keep working.
52+
- Composed and wrapped value-form instances are subclasses of the
53+
configured base classes (`Quo.relation_backed_query_base_class`,
54+
`Quo.collection_backed_query_base_class` — typically
55+
`ApplicationRelationQuery` / `ApplicationCollectionQuery`). Anything
56+
defined on those base classes is available on `.from`-constructed and
57+
instance-composed values. The base classes are resolved at autoload
58+
time, so configure `Quo.relation_backed_query_base_class = ...`
59+
in an initializer (it runs before eager load / first reference in
60+
Rails apps).
61+
62+
### What's intentionally different
63+
64+
#### 1. Prop fan-out at instance composition is gone
65+
66+
In 1.x, `(Q1.new(score: 0.5) + Q2.new(score: 0.7))` produced a merged
67+
class whose effective `score` was 0.7 (right won) and that value was
68+
fanned to every child at unwrap time.
69+
70+
In 2.x, each operand keeps its own constructor-time props. The merged
71+
relation is the AND of both operands' filters, evaluated independently:
72+
73+
```ruby
74+
# 1.x: filter = (score < 0.7 AND score < 0.7) -> score < 0.7
75+
# 2.x: filter = (score < 0.5 AND score < 0.7) -> score < 0.5
76+
```
77+
78+
If you relied on 1.x's "right wins everywhere" behaviour, you now need
79+
to construct the leaf with the value you actually want:
80+
81+
```ruby
82+
# Equivalent to 1.x's behaviour
83+
Q1.new(score: 0.7) + Q2.new(score: 0.7)
84+
```
85+
86+
#### 2. Pagination inherits as a coupled (page, page_size) pair
87+
88+
In 1.x, `page` and `page_size` were fanned independently, so a composed
89+
query could end up with `page: 2` from the left operand and
90+
`page_size: 50` from the right, even though neither operand was
91+
constructed with that pair.
92+
93+
In 2.x, pagination inherits *as a unit* from whichever operand is
94+
paginated (i.e. has a non-nil `page`). Right wins if both are.
95+
`page_size` alone doesn't make a query paginated, because every
96+
Quo::Query has a default `page_size`.
97+
98+
If you previously relied on the cross-pollination behaviour, set
99+
pagination explicitly on the composed instance:
100+
101+
```ruby
102+
(q1 + q2).copy(page: 1, page_size: 50).results
103+
```
104+
105+
#### 3. `wrap` is type-strict at definition time
106+
107+
`Quo::RelationBackedQuery.wrap(x)` now raises `ArgumentError` at call
108+
time if `x` is not an `ActiveRecord::Relation` or a
109+
`Quo::RelationBackedQuery` instance. Same for
110+
`Quo::CollectionBackedQuery.wrap(x)` — `x` must be an `Enumerable` or a
111+
`Quo::CollectionBackedQuery` instance. Previously these would silently
112+
accept the wrong type and fail later at `.unwrap` / `.results` time,
113+
often with a confusing error.
114+
115+
Block forms still defer the check to first call (the block body can
116+
return anything until it's evaluated).
117+
118+
If you were relying on cross-type wrapping (e.g. handing an array to
119+
`RelationBackedQuery.wrap`), use the matching constructor:
120+
121+
- in-memory collection → `Quo::CollectionBackedQuery.wrap(arr)` or
122+
`Quo::CollectionBackedQuery.from(arr)`
123+
- AR relation → `Quo::RelationBackedQuery.wrap(rel)` or
124+
`Quo::RelationBackedQuery.from(rel)`
125+
126+
#### 4. Minimum Rails 8
127+
128+
Quo 2.x requires `activerecord >= 8.0` and `activesupport >= 8.0`. For
129+
Rails 7.x, stay on Quo 1.x.
130+
131+
### What's new
132+
133+
#### `.from` — value-form constructors
134+
135+
For wrapping a bare relation or enumerable as a Quo::Query at a call
136+
site, use `.from` instead of `wrap(...).new`:
137+
138+
```ruby
139+
# 1.x — still works, but allocates a new class per call.
140+
Quo::RelationBackedQuery.wrap(Comment.where(read: false)).new.results
141+
142+
# 2.x — value form, no Class.new per call.
143+
Quo::RelationBackedQuery.from(Comment.where(read: false)).results
144+
145+
# Same for collections.
146+
Quo::CollectionBackedQuery.from([1, 2, 3]).results
147+
```
148+
149+
`.from` returns an instance of `Quo::WrappedRelationBackedQuery` /
150+
`Quo::WrappedCollectionBackedQuery`. They behave like any other
151+
Quo::Query — composable, paginatable, transformable.
152+
153+
Keep using `wrap` when you want a *class* (constant assignment, block
154+
form with typed props):
155+
156+
```ruby
157+
# Still right in 2.x — type-defining use.
158+
RecentComments = Quo::RelationBackedQuery.wrap(props: {since: Time}) do
159+
Comment.where("created_at > ?", since)
160+
end
161+
RecentComments.new(since: 1.day.ago).results
162+
```
163+
164+
The skill bundled with Quo (`bin/rails generate quo:install`) covers
165+
the class-vs-instance composition cut and the new `.from` API.
166+
167+
### Performance
168+
169+
Measured on a representative 3-leaf instance composition (5000
170+
iterations of `(L.new + R.new + T.new).unwrap.to_sql`):
171+
172+
| metric | 1.0.0 | 2.0.0 | Δ |
173+
|---------------------|-------------|-------------|-------------|
174+
| wall | 3805 ms | 1740 ms | 2.2× faster |
175+
| GC time | 238 ms | 61 ms | 4× less |
176+
| total allocations | 8,000,091 | 4,755,036 | -41% |
177+
| T_CLASS allocations | 753 | **0** | -100% |
178+
179+
The "zero T_CLASS per call" is the structural win — instance
180+
composition no longer goes through `Class.new`.
181+
182+
### Migrating
183+
184+
For most apps, just bumping the gem version and running tests is
185+
sufficient. If tests fail, they'll fall into one of these categories:
186+
187+
1. **Test asserted 1.x's prop-fan-out "right wins at unwrap time".**
188+
Update the test to reflect the new behaviour (filter is the AND of
189+
each operand's own filter) or restructure your call site to
190+
construct each leaf with the values you want.
191+
192+
2. **Test asserted the cross-pollinated pagination behaviour.** Set
193+
pagination explicitly on the composed instance, not at the leaf
194+
level.
195+
196+
3. **Test calls `composed.copy(some_user_prop:)` and expects a single
197+
prop value to be observed.** v2's copy applies the new value to
198+
every operand that declares the prop (right-wins precedence on
199+
reads when both have it). Behaviour matches if you wanted "this
200+
value, everywhere". If you wanted "this value on one side only",
201+
construct the new operand explicitly: `q.copy(left: q.left.copy(prop: x))`.
202+
203+
### Performance opportunities
204+
205+
After upgrading, scan for these patterns and consider rewriting:
206+
207+
- `Quo::RelationBackedQuery.wrap(some_relation).new` → use
208+
`Quo::RelationBackedQuery.from(some_relation)`.
209+
- `(Q1 + Q2).new(prop: value)` at a call site → if `Q1` and `Q2` are
210+
classes, this still allocates a new class per request. Either hoist
211+
the composition to a constant (`MyType = Q1 + Q2`) and instantiate
212+
`MyType.new(...)` per request, or switch to instance composition
213+
(`Q1.new(prop: value) + Q2.new(...)` ).
214+
215+
Both patterns are clearly explained in the bundled skill — see
216+
`.claude/skills/quo/SKILL.md` after running `bin/rails generate quo:install`.

‎claude-skill/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ composable, type-safe query objects in a Rails application.
2929
Each markdown file in this skill carries a banner near the top declaring
3030
which Quo version it targets, e.g.:
3131

32-
> **Targets Quo `~> 1.0`.**
32+
> **Targets Quo `~> 2.0`.**
3333
3434
When you upgrade the gem, re-run the install generator with `--force` to
3535
refresh the skill content:
@@ -40,7 +40,7 @@ bin/rails generate quo:install --force
4040

4141
## Installation
4242

43-
The intended path is the bundled Rails generator (ships with Quo `~> 1.0`):
43+
The intended path is the bundled Rails generator (ships with Quo `~> 2.0`):
4444

4545
```bash
4646
bin/rails generate quo:install

0 commit comments

Comments
 (0)