Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
98cdc09
Wrap optional readers in a per-class module
nz Sep 8, 2026
7b1e1e8
Cover the rest of the override surface
nz Sep 8, 2026
f866bbf
Document where wrapped readers live
nz Sep 8, 2026
1bd2ff6
Lift a wrapped reader's value exactly one layer
nz Sep 8, 2026
60cad4b
Assert on the Option in the override tests
nz Sep 8, 2026
3a5abf9
State the design purpose and its deviations
nz Sep 8, 2026
647ae36
Warn that an Option is always truthy
nz Sep 8, 2026
efebe89
Document the override convention and the reader boundary
nz Sep 8, 2026
e673919
Fix the compromises register
nz Sep 8, 2026
9e35056
Load Book's schema before asking which rung owns its reader
nz Sep 8, 2026
1c45545
Run the doctests against a live ActiveRecord connection
nz Sep 8, 2026
d1de87c
Unwrap an Option assigned to an attribute writer
nz Sep 8, 2026
9257d39
Record the attribute unwrap in the compromises register
nz Sep 8, 2026
718fc13
Unwrap an Option assigned to a singular association writer
nz Sep 8, 2026
ca1dc6a
Reach the type helpers that read a value before Type::Value
nz Sep 8, 2026
8e7f531
Record the writer unwrap in the compromises register
nz Sep 8, 2026
3a1099e
Unwrap only a top-level Option on the attribute path
nz Sep 8, 2026
2976244
Cover the polymorphic and through singular associations
nz Sep 8, 2026
0d45565
State what the attribute tests assert, not what came before
nz Sep 8, 2026
0257d8b
Read a validated attribute through the wrapper, not around it
nz Sep 8, 2026
6c6e751
Record the validation seam in the compromises register
nz Sep 8, 2026
287cd8c
Unwrap an attribute where a model serializes it
nz Sep 8, 2026
b272a56
Describe what a validator sees on a converted model
nz Sep 8, 2026
16f8acb
Unwrap an association and a method a payload names
nz Sep 8, 2026
f9d27d5
Cover the validators that weigh, match and order a value
nz Sep 8, 2026
45ee7a9
Let a model omit the keys it has no value for
nz Sep 8, 2026
a357814
Wrap an encrypted attribute like any other nullable column
nz Sep 8, 2026
ebf60eb
Guard the bare to_json refusal beside the nested one
nz Sep 8, 2026
c8da09f
Keep only the delegations ActiveRecord still asks a Some for
nz Sep 8, 2026
fdc5a7f
Take the serialization declaration on either side of the include
nz Sep 8, 2026
0acc071
Assert the deterministic encrypted attribute is wrapped as well as read
nz Sep 8, 2026
cab16cb
Refuse a serialization declaration that cannot change a payload
nz Sep 8, 2026
d43c9b5
Unwrap an Option where a column type serializes it
nz Sep 8, 2026
235d168
Say what omission governs, and show one declaration per model
nz Sep 8, 2026
3a3146a
Read the serialization example back out of the database
nz Sep 8, 2026
b8f9a84
Merge pull request #65 from omc/nz/association-writers-unwrap
nz Sep 8, 2026
00ec31b
Merge pull request #68 from omc/nz/serialization-none
nz Sep 8, 2026
d04fa9b
Merge remote-tracking branch 'origin/nz/validation-reads-raw' into nz…
nz Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ plugins: rubocop-yard
# the assertions. Long lines carrying a `#=>` expectation are allowed.
Layout/LineLength:
AllowedPatterns:
- '#=>'
- "#=>"

# Some(), None(), Ok() and Err() are the library's Rust-style value
# constructors; their capitalized names are the point.
Expand Down Expand Up @@ -38,12 +38,18 @@ Metrics:
# The ActiveRecord hooks in the optional concern are one subject — where a
# reader may come from and what leaves it alone — and reading them together is
# the point. The generated reader is a heredoc, which the length cops count as
# if it were code.
# if it were code. What a model declares about errgonomic before the concern
# acts is one subject in the same way.
Metrics/BlockLength:
Exclude:
- lib/errgonomic/rails/active_record_optional.rb
- lib/errgonomic/rails/active_record_delegate_optional.rb
- test/**/*
Metrics/MethodLength:
Exclude:
- lib/errgonomic/rails/active_record_optional.rb
- test/**/*
Metrics/ModuleLength:
Exclude:
- lib/errgonomic/rails/active_record_optional.rb
- test/**/*
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
## [Unreleased]

- `ActiveRecordOptional` installs its wrapped readers into a per-class module, so a model's own reader of the same name composes with the wrapper through `super` instead of one silently replacing the other
- A wrapped reader lifts a value exactly one layer: an Option arriving from beneath the wrapper passes through instead of being wrapped a second time
- `belongs_to` and `has_one` writers accept an Option: `Some(record)` assigns the record it wraps and `None()` clears the association, so a wrapped reader can be assigned straight onto another record
- An attribute writer accepts an Option and stores the value inside it, `None()` storing `nil`, for every column type: `record.pinned = Some(false)` stores `false` where it used to store `true`, string, text, json, date and datetime writers no longer raise, and the numeric writers no longer route through the soft-deprecated `Option#presence`. Dirty tracking, `attributes` and the before-type-cast reader see the raw value
- A value that reaches the database without passing an attribute writer unwraps at the type cast instead, so `update_all`, `insert_all`, `upsert` and a default declared with `attribute :col, :string, default: Some(v)` take Options for every column type
- Validation on a converted model reads the value inside a wrapped attribute rather than the wrapper: `inclusion` and `exclusion` compare against the value, `presence` rejects `Some('')` as it rejects `''`, `length` and `format` no longer raise, and a `None` validates like `nil`. `validates :x, some: true` stays the Option-aware presence check, and now answers for a plain value on any model. An application's own `EachValidator` or `validates_each` block on a converted model is handed the inner value where 0.8.x handed it the Option, so one written against the wrapper as `value.some?` needs the adaptation `SomeValidator` took: `value.to_option.some?`
- An attribute declared with `encrypts` is wrapped like any other nullable column, now that the encryption length validator reads it through the validation seam. It round-trips as an Option, a `deterministic: true` attribute stays queryable, and `downcase:` still normalizes on write
- `Some` no longer delegates `marked_for_destruction?` to its record. The presence, absence and associated validators were what asked it, and they now receive the record itself
- A column type unwraps an Option when it serializes a value as well as when it casts one, so `find_by(col: Some(v))` works where it previously reached the type with the wrapper still on: `find_by` binds into a cached statement rather than through the predicate builder. This is what makes `find_by` usable against an encrypted attribute, whose type serializes through the underlying type and then calls `to_s`
- A converted model serializes as the unconverted one does: `as_json`, `to_json` and `serializable_hash` fetch every attribute through `read_attribute_for_serialization`, which unwraps, so `Some(v)` writes `v` and `None()` writes `null` where 0.8.x raised `Errgonomic::SerializeError`. An association under `include:` serializes as its record's hash, or leaves the key out where a `nil` association already does, and a wrapped reader named in `methods:` unwraps one layer. An Option handed to an arbitrary payload still raises
- `errgonomic_serialize_none :omit` drops the keys a record has no value for instead of writing them as `null`, declared on either side of the include, on a model or on a base class above it, and scoped to named readers with `only:` or `except:`. The nearest declaration wins and replaces whatever it inherits; `:null` is the default and needs no declaration. A declaration that cannot change a payload raises `ArgumentError` where it is written: an unknown mode, `only:` together with `except:`, or a scoped `:null`
- [Dev, Test] - Doctests run against an in-memory ActiveRecord connection, so an `@example` under `lib/errgonomic/rails` specifies the integration the same way every other example specifies the core

## [0.4.1] - 2025-02-20

- Bugfix: `unwrap_or_else` yields the inner error
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Comments explain intent and rationale — the *why* behind a non-obvious choice.

## What we value, in order

When two designs compete, prefer them in this order.
When two designs compete, prefer them in this order. Before that ordering applies, a design choice has to fit the gem's purpose, which the README's Design section states: the intersection of Rust and Rails idioms, with every deviation from either documented alongside its reason.

1. **Correct.** The code states its behavior and is tested against that statement. Invalid states are made unrepresentable rather than guarded against after the fact. Runtime failures produce diagnostics that tell an operator what went wrong and what to do.
2. **Simple.** The code reads clearly at the right level of abstraction, and is idiomatic and approachable to another developer. Fewer moving parts, fewer sources of truth.
Expand Down
Loading
Loading