|
| 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`. |
0 commit comments