errgonomic 0.8.3 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6cfa38b0fc666fade921ea48c1301cc51643175a59e82b4f2c50ac3a471e6696
4
- data.tar.gz: 8294913794e216f90443e3396753a537d1fbbede4bf6e5eab459bdea51691e6e
3
+ metadata.gz: a1d2acdb3fb3ef7682b897113199fa17107edd3bb4a00aec5eea57afdcc899f0
4
+ data.tar.gz: 9f1e08884a73e3735c612486dc84308fce3ab5a274f85cf1a67191b34da4d30a
5
5
  SHA512:
6
- metadata.gz: 395971235a600af1c734db4dddb9aa8363e63d76ce65175ccb40c358e67aeb48e16379b0682177c42ec4f5d90a50fa14a55f7e7b994b0cd8ffe88dda7f89e688
7
- data.tar.gz: d02c232ea2f79a5a3a04ce3913c1e9c07909655da06b11a5654699be7e371f5a5f8345b5efb9a3be8b40e5de667280b59c49dd2ba9541555faef7ccce8b1ca19
6
+ metadata.gz: 676ef637409475913d89cb971c645e38ead97fbcd5e5cd56096b11a07175310c6e1a90b296d49db33b8ef04fcdf3085468e6e52fb4b902f512408f144fc1e1cc
7
+ data.tar.gz: 19cde3c8a522a57fdfa5ac367751f48657c9319e74ff5869d3ba4d965abe847ee63c168c1419fbe491bbbb871f05cbc83298ee995198ee486949a43d04c70ac6
data/.rubocop.yml CHANGED
@@ -1,10 +1,11 @@
1
1
  plugins: rubocop-yard
2
2
 
3
3
  # Doctest expectation lines are executable spec; wrapping them would change
4
- # the assertions. Long lines carrying a `#=>` expectation are allowed.
4
+ # the assertions. Long lines carrying a `# =>` expectation are allowed, in
5
+ # either of the two spacings the examples use.
5
6
  Layout/LineLength:
6
7
  AllowedPatterns:
7
- - '#=>'
8
+ - '#\s*=>'
8
9
 
9
10
  # Some(), None(), Ok() and Err() are the library's Rust-style value
10
11
  # constructors; their capitalized names are the point.
@@ -12,6 +13,14 @@ Naming/MethodName:
12
13
  AllowedPatterns:
13
14
  - '\A(Some|None|Ok|Err)\z'
14
15
 
16
+ # The optional concern overrides Rails macros so a reader is wrapped or taken
17
+ # back as a model declares one, and an override has to keep the name Rails
18
+ # gave it. These are declarations, not predicates.
19
+ Naming/PredicatePrefix:
20
+ AllowedMethods:
21
+ - has_one
22
+ - has_secure_password
23
+
15
24
  # Option::Any and Result::Any deliberately carry the whole combinator API in
16
25
  # one class each, mirroring Rust's Option and Result surface.
17
26
  Metrics/ClassLength:
@@ -38,12 +47,18 @@ Metrics:
38
47
  # The ActiveRecord hooks in the optional concern are one subject — where a
39
48
  # reader may come from and what leaves it alone — and reading them together is
40
49
  # the point. The generated reader is a heredoc, which the length cops count as
41
- # if it were code.
50
+ # if it were code. What a model declares about errgonomic before the concern
51
+ # acts is one subject in the same way.
42
52
  Metrics/BlockLength:
43
53
  Exclude:
44
54
  - lib/errgonomic/rails/active_record_optional.rb
55
+ - lib/errgonomic/rails/active_record_delegate_optional.rb
45
56
  - test/**/*
46
57
  Metrics/MethodLength:
47
58
  Exclude:
48
59
  - lib/errgonomic/rails/active_record_optional.rb
49
60
  - test/**/*
61
+ Metrics/ModuleLength:
62
+ Exclude:
63
+ - lib/errgonomic/rails/active_record_optional.rb
64
+ - test/**/*
data/CHANGELOG.md CHANGED
@@ -1,10 +1,140 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [0.4.1] - 2025-02-20
3
+ ## [0.9.0] - 2026-09-08
4
+
5
+ This release turns the ActiveRecord integration from a set of wrapped readers into a full set of boundaries, covering readers, writers, query binds, validation and serialization, with the behavior changes named in the bullets below.
6
+
7
+ ### Upgrading from 0.8.x
8
+
9
+ A model's own `def` of a wrapped column or association reader now composes with the wrapper rather than replacing it or being replaced by it, and `super` inside that override returns the Option. An override written as `super || fallback` no longer falls back, because a `None` is truthy. Keep the Option and write `super.or_else { Some(fallback) }`, or hand the bare value back from a differently named accessor as `super.unwrap_or(fallback)`, which is the override convention the README states. Sweep the models for every `def` that names a wrapped reader and calls `super` in its body.
10
+
11
+ An attribute declared with `encrypts` is wrapped like any other nullable column, where 0.8.x excluded it from the conversion. Every read of one now answers an Option, so a truthiness idiom such as `secret ||= SecureRandom.hex` is a no-op against a `None` rather than the assignment it looks like. Sweep the reads of encrypted attributes for `||`, `||=` and `if attr`.
12
+
13
+ Several behaviors that 0.8.x code may lean on have changed. `to_s` on an Option or a Result renders instead of raising, `map_or` and `map_or_else` answer the bare value their default or block gives rather than wrapping it, and `Some` no longer delegates `marked_for_destruction?` to its record. An adapter's `type_cast` and a column type's `cast` and `serialize` no longer unwrap, because a value is unwrapped where it enters ActiveRecord instead, so code that reached `Type::Value#cast(Some(x))` directly has to unwrap the value first. An application's own `EachValidator` on a converted model is handed the inner value where 0.8.x handed it the Option.
14
+
15
+ `delegate_optional` calls the target method directly rather than sending to it, so a private or protected method on the target no longer delegates. A declaration whose `to:` is missing or `nil` raises where it is written, and a writer such as `delegate_optional :name=, to: :author` is refused.
16
+
17
+ ### Changes
18
+
19
+
20
+ - `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
21
+ - 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
22
+ - 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
23
+ - A reader a framework macro declares and then reads for itself is excluded from the wrapping automatically, wherever the macro is written: the `has_one` associations behind `has_rich_text` and `has_one_attached`, and the digest column `has_secure_password` hands to BCrypt. Wrapping them broke `body=`, `to_plain_text`, an attachment's own readers and `authenticate`, none of which passes through a seam that has heard of an Option. The associations are recognized by the class name their reflection carries, so neither engine has to be loaded to answer, and the digest by the module `has_secure_password` includes for the attribute, so a second `has_secure_password :recovery_password` is covered too
24
+ - `Model.errgonomic_optionals` on a subclass reports the readers it inherited along with anything it wrapped itself, where an STI subclass used to report nothing at all while responding to every wrapped reader its parent declared. `Model.errgonomic_optional_names` stays the set that class wrapped on its own
25
+ - `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
26
+ - 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
27
+ - An `attribute :col, type, default: Some(v)` declaration unwraps its default where it is written, so the stored default is a plain value whatever the type is, as an assigned one is. A `Proc` default is wrapped rather than unwrapped, so `default: -> { Some(v) }` hands the type `v` each time a record is built
28
+ - A bulk write unwraps each value of each row before the column type sees it, so `update_all`, `insert_all`, `insert_all!`, `upsert_all` and the singular `insert`, `insert!` and `upsert` take an Option on any column, an application's own type included
29
+ - `find` and `find_by` unwrap an Option where they are given their ids and conditions, so they take one on any column: a `json` column, whose type encodes the value it is handed without calling `super`, and an application's own `ActiveModel::Type::Value` subclass, which is written the same way. A list of ids unwraps one level in, on a class, a relation and an association alike. This is also what makes `find_by` usable against an encrypted attribute, whose type serializes through the underlying type and then calls `to_s`
30
+ - `find_by(col: None())` finds the row whose column is NULL, as `find_by(col: nil)` does, where it used to bind an equality against NULL and quietly find nothing. `find(None())` reports a missing id rather than naming the wrapper
31
+ - An adapter's `type_cast` no longer unwraps an Option. Every value is unwrapped before it can reach an adapter, so nothing entered the seam; `quote` still unwraps, and that is what a value reaching the SQL boundary passes through
32
+ - A form helper on a converted model renders what one on an unconverted model renders: ActionView reads a field's value off the record through the public reader whenever it did not come from user input, which is every record an edit form loads from the database, and that seam now unwraps. `check_box` no longer raises on `to_i`, `datetime_field` no longer raises on `strftime`, and a text field writes the value rather than raising `Errgonomic::SerializeError`
33
+ - 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?`
34
+ - `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
35
+ - 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
36
+ - `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`
37
+ - `Errgonomic::SerializeError` names the value that went unhandled: `cannot serialize an unwrapped Some("cell-a1b2")` rather than `cannot serialize an unwrapped Option`, with the value's `inspect` bounded to 60 characters so a large one does not bury the message. A payload built out of many values now says which one raised
38
+ - `delegate_optional` names its reader the way Rails' `delegate` does: `prefix: true` prefixes the target's name, and a Symbol or String prefix is the prefix itself. 0.8.x honoured only `prefix: true` and defined every other delegation under the bare method name, taking over a method of that name the model defined itself. `prefix: true` over a target that cannot name a method raises `ArgumentError` where the delegation is written
39
+ - A `delegate_optional` reader forwards what it was called with: positional arguments, keyword arguments and a block, where 0.8.x generated a reader that took no parameters and passed none on, so delegating to a method with any signature at all raised `ArgumentError`. The generated call is written out rather than sent, so a private or protected method on the target no longer delegates
40
+ - `delegate_optional` lifts its target instead of assuming an Option, so a model delegates whether or not it has converted: a plain record reads as `Some`, a nil target as `None`, and a `None` target stays `None`, where 0.8.x raised `NoMethodError` on `map` for anything but an Option. It lifts one layer at each end, so a delegated reader that answers an Option comes back as one Option rather than two
41
+ - `delegate_optional` accepts `allow_nil: true` as a no-op, so a `delegate` declaration swaps over unchanged, and raises `ArgumentError` on `allow_nil: false`, which asks for something a reader answering an Option cannot do. A declaration with no `to:` raises where it is written, with Rails' wording, where 0.8.x returned silently and defined nothing
42
+ - A `delegate_optional` reader is defined against the file and line of the declaration, so a backtrace through it and `instance_method(:reader).source_location` name the model rather than the gem
43
+ - `delegate_optional :model_name, to: :class` and any other target named for a Ruby keyword reach the target through an explicit receiver, where the generated body used to read as the keyword and raise `SyntaxError` as the model loaded
44
+ - `delegate_optional` refuses a writer (`delegate_optional :name=, to: :author`) with an `ArgumentError` where the declaration is written, rather than the `SyntaxError` the generated reader used to raise: an assignment through an absent target has nowhere to put the value
45
+ - `prefix: true` over a module target says that a module has no name to prefix with, where it used to give the message for a target that cannot name a method
46
+ - [Behavior change] `to_s` on an Option or a Result renders as `inspect` does (`Some(1).to_s # => "Some(1)"`, `Err(:x).to_s # => "Err(:x)"`) where 0.8.x raised `Errgonomic::SerializeError`. A `to_s` that raises replaces the real exception while a `rescue` builds its log line. `to_json` and `as_json` still raise
47
+ - [Behavior change] `Option#map_or` and `Option#map_or_else` answer the bare value their default or block gives, as Rust's do, where 0.8.x wrapped it: `Some(2).map_or(0) { |v| v * 2 }` is `4` and `None().map_or(0) { }` is `0`. They are the exit from the Option, where `map` stays inside it. `Result` has no `map_or` to correct
48
+ - Ordering an Option or a Result against anything else raises `Errgonomic::TypeMismatchError`, naming both operands and the spellings that work (`some_and?` / `ok_and?`, `map`, `unwrap_or`). `<=>` used to answer `nil`, which `Comparable` turned into an `ArgumentError` blaming the Option for a comparison the bare value on the other side is what broke. Ordering between two Options or two Results is unchanged, `nil` included where their inner values do not compare
49
+ - `Option#each` yields the inner value once for a `Some` and not at all for a `None`, and answers an Enumerator that knows its size without a block, so an Option reads as the zero-or-one collection it is. `Enumerable` is deliberately not included: its `filter`, `select` and `first` answer plain values where an Option's own answer Options
50
+ - `sequence_options` and `sequence_results` on `Enumerable` gather a collection of Options or Results into an Option or a Result of an Array, short-circuiting at the first `None` or `Err`, and returning that `Err` as it stands so it keeps its error. An empty enumerable gives `Some([])` / `Ok([])`, and a member that is not an Option or a Result raises `Errgonomic::TypeMismatchError`
51
+ - `Option#try` and `Option#try!` under the Rails integration send to the value inside a `Some` and answer `nil` for a `None`, where ActiveSupport's `Object#try` answered `nil` for every method on a wrapper (it asks `respond_to?`, which an Option refuses) and handed a block the wrapper itself. A method the value does not have is `nil` as Rails' `try` is; `try!` raises for it
52
+ - `Errgonomic.strict_equality = true` (and the block form `Errgonomic.with_strict_equality`) makes `==`, `!=` and `eql?` between an Option or a Result and a value that is not one raise `Errgonomic::TypeMismatchError` rather than answering false, naming both classes and the spelling to reach for. `nil` counts as cross-type and points at `none?`, and so does the other container: an Option compared to a Result names both and says to unwrap the one you meant. Two Options compare as they always did and `hash` is unchanged; the default stays quiet, and `rake test:strict` runs the Rails integration suite with it on
53
+ - `expect!` on an Option or a Result, and `present_or_raise!` on an Option, take a block that is called only on the branch that raises, so a message built from the value it is missing costs nothing on the path that succeeds. The positional message is unchanged
54
+ - `Option#presence` is supported rather than soft-deprecated: it is the Rails spelling of `unwrap_or(nil)` and no longer nudges. It stays discriminant-based, so `Some("").presence` is `""` where `"".presence` is `nil`
55
+ - The nudge from the soft-deprecated `present_or`, `present_or_else` and `present_or_raise!` fires once per process per method rather than once per call, so a hot path no longer floods stderr, and it names `present_or_raise!` with its bang
56
+ - [Docs] The README says why `take`, `replace`, `insert` and `get_or_insert` are absent: each writes through an `&mut Option`, and an Option here is a value rather than a slot
57
+ - [Docs] `map` wraps whatever its block returns, as Rust's does, so a block that returns an Option gives `Some(Some(x))`. The README and the method say so, and name `and_then` as the spelling for such a block. Its docstring also no longer claims a pedantic runtime check it never had
58
+ - [Docs] `Array#compact` keeps a `None`, because it tests for the `nil` object rather than asking `nil?`. The README names it alongside the `None#nil?` compromise and gives `reject(&:none?)`, `select(&:some?)` and `flat_map(&:to_a)` as the spellings that do what it looks like it does
59
+ - [Dev, Test] `rake test:strict` passes `TESTOPTS` through to the run it spawns, so `--seed` works there as it does for `rake test`
60
+ - [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
61
+
62
+ ## [0.8.3] - 2026-08-12
63
+
64
+ - A `has_one` reads as an Option, the way an optional `belongs_to` already did
65
+ - A singular association with `accepts_nested_attributes_for` is left unwrapped, so nested attribute assignment keeps working
66
+ - `to_option` on an Option returns it unchanged instead of wrapping it a second time
67
+
68
+ ## [0.8.2] - 2026-08-12
69
+
70
+ - `as_json` refuses an unwrapped Option or Result with `Errgonomic::SerializeError`, so a container cannot reach a payload as an undefined structure
71
+ - Nullable columns wrap when the schema loads rather than when the concern is included, and an optional `belongs_to` declared after the include is wrapped too
72
+ - Including the concern on a base class reaches every model beneath it
73
+ - `errgonomic_optionals` reports the wrapped columns as well as the wrapped associations
74
+
75
+ ## [0.8.1] - 2026-08-12
76
+
77
+ - Presence helpers on an Option hand back the value inside it: `present_or` and its family unwrap rather than returning the wrapper. The family is soft-deprecated on Options in favor of the combinators and nudges toward them on stderr, and the blank side raises a teaching error
78
+ - A query written with an Option finds its rows: the predicate builder unwraps, so `where(col: Some(v))` binds the value
79
+ - An encrypted attribute is left unwrapped, and `errgonomic_optional_except` opts a named attribute out of wrapping
80
+ - [Dev] - Bump activestorage and json past their security advisories
81
+
82
+ ## [0.8.0] - 2026-08-07
83
+
84
+ - `Option#present?` and `#blank?` follow the discriminant, not the inner value: `Some(false)` and `Some(nil)` are present, `None()` is blank
85
+ - New combinators: `Option#filter`, `Option#flatten` and `Option#xor`
86
+ - Booleans lift into the containers: `true.then_some(v)`, `false.ok_or(err)`, and the lazy block forms of each
87
+ - Optional collections: `OptionalHash` and `OptionalArray` return an Option from a lookup, and `dig` walks a nested wrapper and checks array bounds instead of raising
88
+ - `inspect` reads as `Some(1)` and `Err(:nope)`, so a container is legible in a debugger or a test failure
89
+ - Option and Result satisfy Ruby's `eql?`/`hash` contract, so they work as hash keys
90
+ - Ordering follows Rust: `None` sorts before `Some`, `Ok` before `Err`
91
+ - A method an Option does not define raises `Errgonomic::UnwrappedAccessError` naming the combinators to reach for, rather than a bare `NoMethodError`
92
+ - The Rust spellings `is_some`, `is_none`, `is_some_and`, `is_none_or` and their Result counterparts delegate to the Ruby predicates, with a nudge on stderr
93
+ - A wrapped reader that re-enters itself raises `Errgonomic::RecursiveOptionalReadError` at the first repeated read, instead of measuring call stack depth and failing thousands of frames later
94
+ - `delegate_optional` honors `private:`
95
+ - Docs: the README covers the current API, Option equality semantics and when `unwrap!` is appropriate, and the ActiveRecord compromises are written down as a named, closed register
96
+ - [Dev, Test] - The gem builds as a flake output with gems from gems4nix, CI tracks the latest Ruby 3.4, the tree is rubocop clean, and CONTRIBUTING states the development methodology
97
+
98
+ ## [0.7.0] - 2026-04-22
99
+
100
+ - `Result#map_err` maps the error of an `Err` and leaves an `Ok` alone
101
+ - `Result#deconstruct` makes a Result pattern matchable: `case result in Errgonomic::Result::Ok, value`
102
+
103
+ ## [0.6.0] - 2026-03-23
104
+
105
+ - Breaking: `and_then` yields the inner value and `or_else` yields the inner error, where both used to yield the container
106
+ - Opting out of the pedantic block checks now works. `give_me_ambiguous_downstream_errors` was read through an expression that was always true, so the check fired whatever you set; the default is still to raise when a combinator's block returns something other than an Option or Result
107
+ - `Result#map` returns a new `Ok` instead of mutating the receiver in place
108
+ - `UnwrapError#value` exposes the inner error, and the value argument is optional
109
+ - `ActiveRecordOptional` is opt-in per model: a model includes the concern itself and `Errgonomic::Rails.setup_after` wraps nothing
110
+ - [Dev, Test] - Replace rspec with minitest, and run `rake test` in CI alongside the doctests
111
+
112
+ ## [0.5.1] - 2026-03-03
113
+
114
+ - `TypeMismatchError` descends from `Errgonomic::Error` again, so `rescue Errgonomic::Error` catches it
115
+
116
+ ## [0.5.0] - 2026-03-02
117
+
118
+ - An unwrapped Option or Result refuses to serialize: `to_s` and `to_json` raise the new `Errgonomic::SerializeError` rather than emitting an undefined structure. Interpolating a container into a string now raises
119
+ - `TypeMismatchError` descends from `Errgonomic::TypeError`, a new subclass of Ruby's `TypeError`
120
+
121
+ ## [0.4.2] - 2026-02-27
122
+
123
+ - An Option binds into a query: the connection adapter quotes `Some(v)` as the value it wraps and `None()` as `NULL`
124
+ - `Errgonomic::Rails.setup_after` no longer eager loads the application to wrap every model with a table. A model that wants wrapped readers includes `Errgonomic::Rails::ActiveRecordOptional` itself
125
+
126
+ ## [0.4.1] - 2026-02-20
4
127
 
5
128
  - Bugfix: `unwrap_or_else` yields the inner error
6
129
 
7
- ## [0.2.0] - 2025-05-01
130
+ ## [0.4.0] - 2025-11-24
131
+
132
+ - ActiveRecord integration: a model that includes `Errgonomic::Rails::ActiveRecordOptional` reads its nullable columns and optional `belongs_to` associations as Options, and `validates :x, some: true` is the matching presence check
133
+ - `delegate_optional` defines a reader that maps a method through an optional association
134
+ - `Result#map`, `Result#tap_ok` and `Result#tap_err`
135
+ - `Err#unwrap!` raises an `UnwrapError` carrying the inner error value
136
+
137
+ ## [0.3.0] - 2025-05-01
8
138
 
9
139
  - Type assertions: `type_or_raise!`, `type_or`
10
140
 
data/CONTRIBUTING.md CHANGED
@@ -30,6 +30,7 @@ The inner rungs run constantly; the outer rungs are slower and run when preparin
30
30
  bundle exec rubocop # formatted & lint-clean
31
31
  bundle exec yard doctest # doctests pass — most behavior is specified here
32
32
  bundle exec rake test # unit tests pass (incl. the Rails integration test)
33
+ bundle exec rake test:strict # the same suite with cross-type equality raising
33
34
  ```
34
35
 
35
36
  A change that fails any of these is not ready. Keep formatting-only changes in their own commit so they do not obscure a behavioral diff. `bundle exec rake` runs the full suite (test + yard:doctest) in one shot.
@@ -42,7 +43,7 @@ nix build .#errgonomic # the gem builds as a derivation; rake runs in
42
43
  nix flake check --all-systems # builds every check on the local system, evaluates all four
43
44
  ```
44
45
 
45
- **After push (CI gate):** a push is done when CI is green, not when `git push` succeeds. Check whatever CI this repo runs (`gh run list`, `gh run view --log-failed`); checks take minutes, so it is fine to schedule the check as a followup and keep working — but the change is not landed until they pass. A CI failure is a regression: diagnose it from the logs, reproduce it locally where you can, and capture it as a test so it cannot recur silently. CI earns you the coverage you cannot run locally — a target your machine isn't, a matrix leg, a slower suite — for free. Here, CI (`.github/workflows/main.yml`) runs `yard doctest` and `rake test` on the latest Ruby 3.4.x on ubuntu-latest, matching the Ruby pinned by the flake.
46
+ **After push (CI gate):** a push is done when CI is green, not when `git push` succeeds. Check whatever CI this repo runs (`gh run list`, `gh run view --log-failed`); checks take minutes, so it is fine to schedule the check as a followup and keep working — but the change is not landed until they pass. A CI failure is a regression: diagnose it from the logs, reproduce it locally where you can, and capture it as a test so it cannot recur silently. CI earns you the coverage you cannot run locally — a target your machine isn't, a matrix leg, a slower suite — for free. Here, CI (`.github/workflows/main.yml`) runs `yard doctest`, `rake test` and `rake test:strict` on the latest Ruby 3.4.x on ubuntu-latest, matching the Ruby pinned by the flake.
46
47
 
47
48
  Tests *are* the requirements: a behavior is defined by the test that asserts it. Prefer doctests where an example clarifies a function's contract — they document and test at once and cannot drift out of date without failing the build. In this repo, the YARD `@example` blocks under `lib/**/*.rb` are the primary suite.
48
49
 
@@ -58,7 +59,7 @@ Comments explain intent and rationale — the *why* behind a non-obvious choice.
58
59
 
59
60
  ## What we value, in order
60
61
 
61
- When two designs compete, prefer them in this order.
62
+ 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.
62
63
 
63
64
  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.
64
65
  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.
data/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Errgonomic provides some lightweight, opinionated ergonomics for error handling in Ruby. These semantics are a blend of Rails `present?` conventions, and Rust `Option` and `Result` type combinators. Without going full Option and Result. Probably.
4
4
 
5
+ ## Design
6
+
7
+ Errgonomic aims at the intersection of two idioms rather than translating one into the other. Rails supplies the mechanism: a concern, an attribute reader overridden with `super`, the reader as the boundary of a model's public surface. Rust supplies the shape of the value: an `Option` you handle with combinators, instead of a value that may or may not be `nil`. Convention over configuration and least surprise are the tests every design choice here has to pass, and where the two idioms already agree we follow the convention and say nothing more about it.
8
+
9
+ Where the gem leaves one of them, the docs say so and say why. The reason is nearly always mechanical: ActiveRecord assumes things about accessors that a strict Option cannot satisfy. The [ActiveRecord compromises](#activerecord-compromises) are that register, enumerated and closed.
10
+
5
11
  ## Installation
6
12
 
7
13
  Install the gem and add to the application's Gemfile by executing:
@@ -89,13 +95,22 @@ Some(1).expect!("must be set") # => 1
89
95
 
90
96
  Some(1).map { |x| x + 1 } # => Some(2)
91
97
  Some(2).and_then { |x| Some(x + 1) } # => Some(3)
98
+ Some(2).map_or(0) { |x| x * 2 } # => 4, a bare value
99
+ None().map_or(0) { |x| x * 2 } # => 0
92
100
  None().or(Some(1)) # => Some(1)
93
101
  Some(:left).xor(None()) # => Some(:left)
94
102
  Some(1).zip(Some(2)) # => Some([1, 2])
95
103
  Some(1).ok_or("nope") # => Ok(1)
96
104
  None().ok_or("nope") # => Err("nope")
105
+
106
+ Some(1).each { |x| log(x) } # yields once; None() yields nothing
107
+ Some(1).each.to_a # => [1]
97
108
  ```
98
109
 
110
+ `each` is the whole of the collection surface. An Option does not include `Enumerable`, because `filter` and `first` already answer Options here and Enumerable's answer plain values; `opt.each` hands you an Enumerator when you want the rest of them.
111
+
112
+ `map` wraps whatever the block returns, as Rust's does, so a block that itself returns an Option gives `Some(Some(x))`. `and_then` is the spelling for that block.
113
+
99
114
  Options support pattern matching:
100
115
 
101
116
  ```ruby
@@ -107,15 +122,29 @@ in Errgonomic::Option::None
107
122
  end
108
123
  ```
109
124
 
110
- An unhandled Option refuses to leak into your output: `to_s`, `to_json`, and `as_json` raise `Errgonomic::SerializeError`, so you handle the inner value deliberately rather than shipping `#<Errgonomic::Option::Some...>` to a user. The refusal covers `as_json` because Hash and Array serialization recurses through that method, and an Option nested in a payload would otherwise serialize as `{"value": ...}`.
125
+ An unhandled Option refuses to leak into your output: `to_json` and `as_json` raise `Errgonomic::SerializeError`, so you handle the inner value deliberately rather than shipping `#<Errgonomic::Option::Some...>` to a user. The refusal names what it was carrying (`cannot serialize an unwrapped Some("cell-a1b2")`), so a payload built out of many values says which one went unhandled; the value's `inspect` is bounded to 60 characters, with an ellipsis past that. The refusal covers `as_json` because Hash and Array serialization recurses through that method, and an Option nested in a payload would otherwise serialize as `{"value": ...}`. A converted ActiveRecord model is the one exception, at the model boundary: it unwraps each attribute as it serializes, so a record's own `as_json` says what an unconverted record's says. See [Rails integration](#rails-integration).
126
+
127
+ `to_s` renders rather than refusing: `Some(1).to_s` is `"Some(1)"` and `None().to_s` is `"None"`, matching `inspect`, and the same holds for `Ok` and `Err`. Rust gives `Option` a `Debug` and no `Display`, so raising was the faithful reading, but a `to_s` that raises replaces the real exception while a `rescue` builds its log line, which is the worst possible place to be strict. The rendered form is unambiguous: a `Some(1)` in a log says a wrapper arrived where a value was meant.
128
+
129
+ `expect!` also takes a block, on an Option and a Result alike, so a message that interpolates is built only on the branch that raises: `tier.expect! { "no tier for #{account.id}" }`. `present_or_raise!` takes one on the same terms. The positional form is unchanged.
111
130
 
112
131
  `unwrap!` and `expect!` are for tests and consoles, not application code: they raise on `None`, which is exactly the ambiguous failure the type exists to prevent. Application code should always have a combinator or pattern match that handles the `None` branch explicitly; if none fits, that is a gap worth an issue rather than a reason to unwrap.
113
132
 
114
133
  Presence follows the discriminant, as in Rust: `Some` is `present?` and `None` is `blank?`, regardless of the wrapped value. So `Some(false).present?` and `Some(nil).present?` are both `true`. If you care about the inner value's own presence, unwrap it first.
115
134
 
116
- The presence helpers are soft-deprecated on Options in favor of the combinators. The present side unwraps, where on any other object it returns the receiver `Some(v).present_or_raise!(msg)`, `present_or(default)`, `present_or_else { }`, and `presence` all yield `v`, and `None` raises, substitutes, or answers `nil` and each call prints a one-line stderr nudge naming the combinator to use instead (`expect!`, `unwrap_or`, `unwrap_or_else`, `unwrap_or(nil)`). The blank side (`blank_or*`) raises `UnwrappedAccessError` outright: an Option's blankness is its discriminant, so test it with `none?`.
135
+ Truthiness is the Rails reflex that breaks. An Option is an object, so `None()` is truthy: `isbn || 'unassigned'` hands back the `None`, `if isbn` takes the present branch, and nothing raises to say so. Reach for `unwrap_or('unassigned')`, or for `map` and `and_then` when the fallback is itself an Option. Safe navigation looks for the `nil` object rather than asking `nil?`, so `isbn&.strip` calls into the Option and raises `Errgonomic::UnwrappedAccessError`, where `isbn.map(&:strip)` does what was meant. Under the Rails integration `None#nil?` answers `true`, so an explicit `nil?` check behaves, but `||` and `&.` never consult it.
136
+
137
+ Writers unwrap under that integration, which changes what a truthiness slip costs rather than removing it. `self.isbn = isbn || 'unassigned'` no longer leaks a wrapper into the database; it silently persists whatever `isbn` held, `nil` included, because a `None` is truthy and the fallback is never reached. The write succeeds and nothing raises. `unwrap_or('unassigned')` is the spelling that means it.
117
138
 
118
- Equality is between Options only: `Some(5) == Some(5)`, but `Some(5) == 5` and `None() == nil` are `false`. That is quiet, never an error, matching how every Ruby object compares across types. Rust rejects `Some(5) == 5` at compile time; Ruby cannot, so guard the idiom in review and tests: compare against a wrapped value (`opt == Some(5)`) or test the inner value (`opt.some_and? { |v| v == 5 }`).
139
+ `presence` is the Rails spelling of `unwrap_or(nil)`, and it is supported: `Some(x).presence` is `x` and `None().presence` is `nil`, so `isbn.presence || 'unassigned'` reaches the value rather than the wrapper. It follows the discriminant, as every presence question on an Option does, so `Some("").presence` is `""` where `"".presence` on any other object is `nil`. An Option's presence is whether it holds a value, not what that value amounts to; unwrap first (`isbn.unwrap_or("").presence`) to ask the inner value's own presence.
140
+
141
+ The remaining present-side helpers are soft-deprecated on Options in favor of the combinators. They unwrap, where on any other object they return the receiver: `Some(v).present_or_raise!(msg)`, `present_or(default)` and `present_or_else { }` all yield `v`, and `None` raises, substitutes, or computes. Each prints a one-line stderr nudge naming the combinator to use instead (`expect!`, `unwrap_or`, `unwrap_or_else`), once per process per method rather than once per call, so a hot path does not flood the log. The blank side (`blank_or*`) raises `UnwrappedAccessError` outright: an Option's blankness is its discriminant, so test it with `none?`.
142
+
143
+ Four of Rust's methods are deliberately absent: `take`, `replace`, `insert` and `get_or_insert`. Every one of them writes through an `&mut Option`, and an Option here is a value rather than a slot: `Some(1)` is something you pass around and compare, not a cell whose contents you swap out from under another reference. Build the Option you want and assign it where the old one lived.
144
+
145
+ Equality is between Options only: `Some(5) == Some(5)`, but `Some(5) == 5` and `None() == nil` are `false`. That is quiet, never an error, matching how every Ruby object compares across types. Rust rejects `Some(5) == 5` at compile time; Ruby cannot, so guard the idiom in review and tests: compare against a wrapped value (`opt == Some(5)`) or test the inner value (`opt.some_and? { |v| v == 5 }`). `Errgonomic.strict_equality = true` turns that guard into an error, which is what a test suite wants; see [Pedantic runtime checks](#pedantic-runtime-checks).
146
+
147
+ Ordering is between Options too, and unlike equality it says so out loud. `None()` sorts before any `Some` and two `Some`s order by their inner values, so a collection of Options sorts. Ordering one against a bare value raises `Errgonomic::TypeMismatchError` naming both operands and the spellings that work: `Some(read_at) <= Time.current` used to answer `nil` from `<=>`, which `Comparable` turned into an `ArgumentError` naming the Option as the operand at fault. Test the inner value (`read_at.some_and? { |t| t <= Time.current }`) or reach for it with `map` or `unwrap_or`. Two Options whose inner values do not compare still answer `nil`, as Ruby expects. That message is the gem's only where the Option is the receiver: with it on the right (`2 < Some(1)`, `[Some(1), 2].max`) `Integer` answers the comparison itself and Ruby raises its own `ArgumentError: comparison of Integer with Errgonomic::Option::Some failed`. Results order the same way, with `ok_and?` in place of `some_and?`.
119
148
 
120
149
  ### Result
121
150
 
@@ -148,7 +177,7 @@ in Errgonomic::Result::Err, Exception => e
148
177
  end
149
178
  ```
150
179
 
151
- Like Options, unwrapped Results refuse `to_s`, `to_json`, and `as_json`. And `Object#result?` / `Object#assert_result!` help enforce at runtime that a value is a Result.
180
+ Like Options, unwrapped Results refuse `to_json` and `as_json`, and render `to_s` as `inspect` does. And `Object#result?` / `Object#assert_result!` help enforce at runtime that a value is a Result.
152
181
 
153
182
  ### Optional collections
154
183
 
@@ -164,7 +193,7 @@ h.fetch_option(:smell) # => None()
164
193
  [:a, nil].fetch_option(2) # => None()
165
194
  ```
166
195
 
167
- `into_optional` wraps the collection in `Errgonomic::OptionalHash` / `Errgonomic::OptionalArray`, a view whose lookups all return Options. The wrappers are deliberately small `[]`, `[]=`, `dig`, presence checks, and (for arrays) `first`/`last` and are composed around the plain collection rather than subclassing it, because a subclass sheds its custom semantics every time `select` or `transform_values` returns a plain Hash. `to_h` / `to_a` hand back a detached copy.
196
+ `into_optional` wraps the collection in `Errgonomic::OptionalHash` / `Errgonomic::OptionalArray`, a view whose lookups all return Options. The wrappers are deliberately small: `[]`, `[]=`, `dig`, presence checks, and (for arrays) `first`/`last`. They are composed around the plain collection rather than subclassing it, because a subclass sheds its custom semantics every time `select` or `transform_values` returns a plain Hash. `to_h` / `to_a` hand back a detached copy.
168
197
 
169
198
  ```ruby
170
199
  h = { person: { name: 'Ada', middle_name: nil } }.into_optional
@@ -177,6 +206,29 @@ h.dig(:person, :nickname) # => None() (absent)
177
206
 
178
207
  `dig` checks presence at every step, so an absent path and a present `nil` stay distinguishable, which core `dig` conflates. Digging into a non-collection raises `Errgonomic::TypeMismatchError` rather than answering `None()`, in the gem's pedantic style.
179
208
 
209
+ `sequence_options` and `sequence_results` are the all-or-nothing collection, which Rust spells as a `collect` into `Option<Vec<T>>` or `Result<Vec<T>, E>`. The name is Haskell's `sequence`, the operation Rust's `collect` performs underneath, rather than anything a Rubyist would already recognize. They are on `Enumerable`, so they compose with `map` instead of needing a wrapper type. The first `None` or `Err` short-circuits, and an `Err` comes back as it stands, still carrying its error.
210
+
211
+ ```ruby
212
+ [Some(1), Some(2)].sequence_options # => Some([1, 2])
213
+ [Some(1), None()].sequence_options # => None()
214
+ [].sequence_options # => Some([])
215
+
216
+ [Ok(1), Ok(2)].sequence_results # => Ok([1, 2])
217
+ [Ok(1), Err(:nope)].sequence_results # => Err(:nope)
218
+ ```
219
+
220
+ A member that is not an Option, or not a Result, raises `Errgonomic::TypeMismatchError` in the same pedantic style as `Option#flatten`. It raises regardless of `with_ambiguous_downstream_errors`, which relaxes what a block returned rather than what a caller passed in. A Hash enumerates as pairs, which are Arrays, so `hash.values.sequence_options` is the spelling for a hash of Options.
221
+
222
+ Three operations over a collection of Options are easy to confuse with one another, so it is worth naming all three:
223
+
224
+ | Rust | meaning | errgonomic |
225
+ | --- | --- | --- |
226
+ | `iter.flatten()` | drop the absent members | `reject(&:none?)`, `select(&:some?)`, or `flat_map(&:to_a)` to unwrap while dropping |
227
+ | `Option::flatten` | unnest an `Option<Option<T>>` | `Option#flatten` |
228
+ | `collect::<Option<Vec<_>>>()` | all or nothing | `sequence_options`, `sequence_results` |
229
+
230
+ `Array#compact` is not in that first row. It is implemented in C and tests for the `nil` object rather than asking `nil?`, so it keeps a `None` where the idiom reads as though it drops it, and something downstream then dereferences the wrapper. Use `reject(&:none?)` or `select(&:some?)` to keep the wrappers, `flat_map(&:to_a)` to unwrap in the same pass, and `sequence_options` when an absent member should take the whole collection with it. The first two ask every member the question, so a plain `nil` still in the list raises `NoMethodError`; `flat_map(&:to_a)` survives one, since `nil.to_a` is `[]`, but not a bare value.
231
+
180
232
  ### Booleans
181
233
 
182
234
  Booleans lift into the containers, following Rust's `bool`: `then_some`, and `ok_or`/`ok_or_else` from nightly. Rust splits the lazy form into `then`, but that name is core Ruby (`Kernel#then`), which Errgonomic will not redefine; `then_some` takes either a value or a block instead. Rust's `ok_or` returns `Result<(), E>`; Ruby has no unit type, so `Ok` carries `true`.
@@ -189,7 +241,7 @@ valid.ok_or("invalid input") # => Ok(true) / Err("invalid input")
189
241
 
190
242
  ### Pedantic runtime checks
191
243
 
192
- Combinators that accept a block (`and_then`, `or_else`, ...) check at runtime that the block returned an Option or Result, raising `Errgonomic::ArgumentError` otherwise. That beats an ambiguous `undefined method` error somewhere downstream. If you would rather have the ambiguous downstream errors, you can opt out but not quietly:
244
+ Combinators that accept a block (`and_then`, `or_else`, ...) check at runtime that the block returned an Option or Result, raising `Errgonomic::ArgumentError` otherwise. That beats an ambiguous `undefined method` error somewhere downstream. If you would rather have the ambiguous downstream errors, you can opt out, but not quietly:
193
245
 
194
246
  ```ruby
195
247
  Errgonomic.with_ambiguous_downstream_errors do
@@ -197,23 +249,56 @@ Errgonomic.with_ambiguous_downstream_errors do
197
249
  end
198
250
  ```
199
251
 
252
+ Cross-type equality is the other pedantic check, and it is off by default because a quiet `false` is what every Ruby object answers. Turn it on and a comparison between a wrapper and a value that is not one raises `Errgonomic::TypeMismatchError`, naming both classes and the spelling to reach for:
253
+
254
+ ```ruby
255
+ Errgonomic.strict_equality = true
256
+
257
+ Some(5) == 5 # => raises Errgonomic::TypeMismatchError
258
+ Some(5) != 5 # => raises
259
+ Some(5).eql?(5) # => raises
260
+ None() == nil # => raises, pointing at none?
261
+ Ok(1) == 1 # => raises
262
+ Some(1) == Ok(1) # => raises: an Option and a Result are different containers
263
+ Some(5) == Some(5) # => true, as always
264
+
265
+ 1 == Some(1) # => raises, through Integer's coercion fallback
266
+ nil == None() # => false, quietly
267
+ "a" == Some("a") # => false, quietly
268
+ ```
269
+
270
+ A Result is cross-type for an Option and an Option is cross-type for a Result: they are different containers, neither is the other, and the message says to unwrap whichever one you meant. Two Options, or two Results, compare as they always did, and `hash` is untouched, so an Option stays usable as a Hash key with it on.
271
+
272
+ Strictness fires when the wrapper is the receiver, and also when the left operand hands the comparison over: `1 == Some(1)` raises because `Integer#==` falls back to asking the right-hand side. `nil == None()` and `"a" == Some("a")` stay quietly false, because `NilClass` and `String` answer for themselves and never consult the operand. Put the wrapper on the left in a test if you want the check to reach every comparison. It is meant for a test suite or CI, not for production, and there is a block form for scoping it the way the ambiguous-error opt-out is scoped:
273
+
274
+ ```ruby
275
+ Errgonomic.with_strict_equality do
276
+ assert_equal Some(5), book.pages
277
+ end
278
+ ```
279
+
280
+ This gem runs its own Rails integration suite that way, as `rake test:strict`.
281
+
200
282
  ### Rails integration
201
283
 
202
284
  When `Rails::Railtie` is defined, Errgonomic installs a Railtie with two opt-in integrations for ActiveRecord:
203
285
 
204
- - `include Errgonomic::Rails::ActiveRecordOptional` in a model makes its nullable attributes and `optional: true` associations return `Some(value)` or `None()` instead of a value-or-nil. Every nullable column and optional association is wrapped, with no per-attribute opt-in. Three kinds of reader stay unwrapped: attributes declared with `encrypts` and singular associations with `accepts_nested_attributes_for`, both of which ActiveRecord's own machinery reads raw, and anything named by `errgonomic_optional_except`.
286
+ - `include Errgonomic::Rails::ActiveRecordOptional` in a model makes its nullable attributes and `optional: true` associations return `Some(value)` or `None()` instead of a value-or-nil. Every nullable column and optional association is wrapped, with no per-attribute opt-in. Four kinds of reader stay unwrapped: a reader a framework macro declares and then reads for itself, which is the associations `has_rich_text` and `has_one_attached` declare and the digest column `has_secure_password` hands to BCrypt; a singular association with `accepts_nested_attributes_for`, which ActiveRecord assigns through the reader and reads raw; a `has_one ..., required: true`, whose absence is a validation failure rather than a value; and anything named by `errgonomic_optional_except`.
205
287
 
206
288
  ```ruby
207
289
  class Credential < ApplicationRecord
208
290
  errgonomic_optional_except :legacy_token
209
291
  include Errgonomic::Rails::ActiveRecordOptional
210
292
 
211
- encrypts :access_secret # also left unwrapped, declared either side of the include
293
+ encrypts :access_secret # wrapped like any other nullable column
212
294
  has_one :rotation_schedule # wrapped: Some(schedule) or None()
213
295
  has_one :owner, required: true # left unwrapped: absence is a validation failure
296
+ has_secure_password # left unwrapped: BCrypt reads password_digest raw
214
297
  end
215
298
  ```
216
299
 
300
+ The framework exclusions need no declaration and hold wherever the macro is written. `has_rich_text` and `has_one_attached` are recognized by the class their associations name, so neither engine has to be loaded for a model to be asked; `has_secure_password` by the module it includes for the attribute, so `has_secure_password :recovery_password` excludes `recovery_password_digest` as well.
301
+
217
302
  **Where the include goes.** A model that includes the concern converts itself, and only itself. The include may sit at the top of the model with the other concerns, which is where Rails convention puts one. An `optional: true` association declared below it is wrapped as it is declared, rather than only the associations the class happened to declare above it.
218
303
 
219
304
  ```ruby
@@ -249,23 +334,84 @@ class Credential < ApplicationRecord
249
334
  end
250
335
  ```
251
336
 
252
- `Model.errgonomic_optionals` reports which readers a model wrapped, which is how to check that a conversion did what it meant to.
337
+ **Overriding a wrapped reader.** Wrapped readers live in a module the concern includes into the model, so a model's own `def` of the same name coexists with the wrapper and reads the Option through `super`. The rule is one of layering: a `def` in the model's own class body, or a module the model itself includes after the errgonomic include, sits above the wrapper and reads the Option from `super`.
338
+
339
+ The type does not change inside the override. `super` hands back exactly what every other caller of the reader gets, so an override that keeps the Option keeps the model's contract:
340
+
341
+ ```ruby
342
+ class Book < ApplicationRecord
343
+ include Errgonomic::Rails::ActiveRecordOptional
344
+
345
+ belongs_to :author, optional: true
346
+
347
+ def isbn
348
+ super.map(&:strip) # super is Some(isbn) or None(), and so is this
349
+ end
350
+
351
+ def display_isbn
352
+ isbn.unwrap_or('unassigned')
353
+ end
354
+ end
355
+ ```
356
+
357
+ An accessor that hands back a plain value is a different method with a different name, the way a Rust `fn display_name(&self) -> String` sits beside a `name: Option<String>` field. `display_isbn` is that method; `isbn` stays the field.
358
+
359
+ A same-named `def` that never calls `super` is legal Ruby and the model owns its return value outright: the wrapper stays installed beneath it and nothing reaches it. It is the un-idiomatic spelling, and it leaves one loose end: `Model.errgonomic_optionals` still reports the reader as wrapped, because the conversion did wrap it.
360
+
361
+ **Storage stays nullable; the reader is the boundary.** Only the reader returns an Option. `self[:isbn]`, `read_attribute(:isbn)`, `isbn_was`, `isbn_change`, and `attributes` all answer the raw column value or `nil`, which is where Rails already draws the line for a reader override: the attribute is the storage, the reader is the interface. Rust would expect the Option all the way down, and this is the largest place the gem does not follow it, because dirty tracking, serialization, and query building each read the attribute directly and an Option would have to survive all of them.
362
+
363
+ Writers take either a plain value or an Option of one, for attributes and singular associations alike, so a wrapped reader's value assigns straight back: `other_book.title = book.title` and `other_book.author = book.author` both do what they read like. `book.isbn = '9780765377104'` means what it always did, and assigning `None()` stores `nil`. Assigning an Option is assigning the value inside it for every column type, so a wrapped `false` stores `false`, and the storage behind the reader stays raw: `changes`, `read_attribute_before_type_cast` and `attributes` see the value, never the wrapper. `find`, `find_by`, `exists?`, `update_all`, `insert_all`, `upsert` and an attribute default take Options on the same terms, unwrapping where the value enters ActiveRecord rather than at a writer.
364
+
365
+ Every one of those seams is installed on ActiveModel or ActiveRecord itself, as the quoting and predicate-builder seams are. They apply to every model in the application, whether or not it includes the concern: the concern decides what a reader returns, not what a writer accepts. None of them asks anything of the column type, so a type that never calls `super` from its own `cast` or `serialize` needs no cooperation: an application's own `ActiveModel::Type::Value` subclass and a `json` column both take an Option wherever a plain value goes.
366
+
367
+ **Validation reads the value.** Standard validators on a converted model behave exactly as they do on an unconverted one. `inclusion`, `exclusion`, `presence`, `length`, `format`, `numericality` and the rest weigh the value inside the Option, and a `None` validates like `nil`, because every `EachValidator` fetches its attribute through `read_attribute_for_validation`, which unwraps. `validates :isbn, some: true` is the Option-aware presence check, asking only whether the value is there: `Some('')` passes `some:` and fails `presence: true`, exactly as `''` fails it. Custom validation code is the exception, because it reads the public reader: a `validate :check_isbn` whose body calls `isbn` gets `Some('9780765377104')`, the same as every other caller.
368
+
369
+ **Form helpers render the value.** A form built on a converted model renders what a form on an unconverted one renders. `form_with`, `form_for` and the field helpers read the record through `ActionView::Helpers::Tags::Base#value` whenever the value did not come from user input, which is every record an edit form loads from the database, and that seam unwraps. So `text_field` writes the value into the markup, `check_box` reads a wrapped `false` as unchecked rather than raising on `to_i`, and `datetime_field` formats the time inside the `Some` rather than raising on `strftime`. A `None` renders an empty field, exactly as `nil` does. Only the reader path goes through that seam, so an association object handed to a helper explicitly is the caller's own value: `fields_for :award, author.award` passes the `Some`, and wants `author.award.unwrap_or(nil)` or the `fields_for :award` form, which reads the association itself.
370
+
371
+ **Serialization.** 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`. Both idioms agree on the default: Rails writes an absent value as `null`, and so does serde unless a field asks otherwise. The refusal stands everywhere else, so a hand-built Option in an arbitrary payload (`{ isbn: book.isbn }.to_json`) still raises `Errgonomic::SerializeError`.
372
+
373
+ An association under `include:` follows the same rule: `Some(author)` serializes as the record's own hash, and a `None` leaves the key out, which is what `include:` already does with a `nil` association. A `has_many` is never an Option and is untouched. A wrapped reader named in `methods:` unwraps one layer as well, so `as_json(methods: :isbn)` writes the value; a method that hands back a plain value is unchanged.
374
+
375
+ Omission is the opt-in, as it is in serde, and it is declared on the model rather than on `belongs_to` or `has_one`:
376
+
377
+ ```ruby
378
+ class ApplicationRecord < ActiveRecord::Base
379
+ include Errgonomic::Rails::ActiveRecordOptional
380
+ errgonomic_serialize_none :omit # drop keys whose value is None
381
+ end
382
+
383
+ class Book < ApplicationRecord
384
+ errgonomic_serialize_none :null # this model keeps them, as null (the default)
385
+ end
386
+
387
+ class Manuscript < ApplicationRecord
388
+ errgonomic_serialize_none :omit, only: %i[isbn] # only this reader is dropped; except: also accepted
389
+ end
390
+ ```
391
+
392
+ The declaration reads as well above the include as below it, as `errgonomic_optional_except` does. The nearest declaration wins and replaces whatever it inherits, rather than layering onto it, so a reader a scoped declaration does not name keeps the default. Omission drops keys from the payload the caller asked for, so it composes with the caller's own `only:` and `except:`. It governs by reader name wherever the key came from, so a `methods:` entry naming a wrapped reader that reads `None` is dropped along with the reader, while a plain method that happens to return `nil` is kept. A declaration that cannot change a payload raises `ArgumentError` where it is written, naming what to write instead. That covers a mode other than `:null` or `:omit`, `only:` together with `except:`, and a scoped `:null`, which asks for the default on the readers it names and leaves the rest at the default anyway.
393
+
394
+ `Model.errgonomic_optionals` reports which readers a model wrapped, including nullable foreign-key columns, so `book.author_id` is `Some(1)` alongside `book.author`. That is how to check that a conversion did what it meant to. A subclass reports what it inherited alongside anything it wrapped itself, so the report names every wrapped reader the class responds to; `Model.errgonomic_optional_names` is the set that class wrapped on its own.
395
+
396
+ `delegate_optional` is Rails' `delegate` with `allow_nil`, where the absent case is a `None` rather than a `nil`: `delegate_optional :name, to: :author` gives `book.name # => Some('Cixin Liu')`, and `None()` where there is no author. The prefix forms are Rails': `prefix: true` names the reader after the target (`author_name`), and `prefix: :writer` names it `writer_name`. `private: true` works as it does there. The reader forwards whatever it was called with, arguments and block alike. `allow_nil: true` is accepted and says nothing new, so a `delegate` declaration swaps over unchanged unless it delegates a writer. `delegate_optional :name=, to: :author` raises instead: an assignment through an absent target has nowhere to put the value, and dropping it silently is what the type is there to prevent. `allow_nil: false` asks for a reader that raises on absence, which this does not have, so it raises `ArgumentError` where it is written, as a declaration with no `to:` does.
397
+
398
+ It is available on every model, converted or not, because it lifts both ends one layer. The target is lifted, so a plain record reads as `Some` and a `nil` as `None`. What the delegated call returns is lifted too, so a delegated reader that is itself an Option comes back as one Option rather than two.
253
399
 
254
- - `delegate_optional :name, to: :association` (available on all models) delegates through an optional association, returning an Option instead of raising on nil.
400
+ `Object#to_option` lifts any value into an Option (`nil.to_option # => None()`). It lifts once and only once: an Option passes through unchanged (`Some(1).to_option # => Some(1)`), so lifting a value whose provenance you do not know is safe. That is the rule everywhere in the integration. An ActiveRecord attribute or association is never an optional of an optional, so a wrapped reader never nests a second Option around a value that already is one. Nesting is invisible until something reaches for the inner value, which is the ambiguous failure the type exists to prevent.
255
401
 
256
- `Object#to_option` is also available in Rails to lift any value into an Option (`nil.to_option # => None()`).
402
+ `try` reaches the value inside the Option: `book.isbn.try(:strip)` strips the ISBN and answers `nil` where there is none, and the block form yields the value (`book.isbn.try { |isbn| isbn.strip }`). ActiveSupport's `Object#try` asks `respond_to?` first, which an Option answers `false` to for anything it does not define, so without this it would be a quiet `nil` for every method and would hand a block the wrapper rather than the value. A method the value does not have is still `nil`, as it is for any other receiver, and `try!` is Rails' strict variant, which raises for that and still answers `nil` for a `None`. Both are defined only under this integration, where ActiveSupport's `try` is what they follow.
257
403
 
258
404
  #### ActiveRecord compromises
259
405
 
260
- ActiveRecord assumes things about accessors that a strict Rust Option cannot satisfy, so the integration carries five deliberate compromises. Everywhere else, treat a departure from Rust's `Option` semantics as a bug; these five are intended:
406
+ This is the register of where the gem leaves the Rust idiom, and why. ActiveRecord assumes things about accessors that a strict Rust `Option` cannot satisfy, so the integration carries five deliberate compromises, each one forced by a specific piece of ActiveRecord machinery rather than chosen. Everywhere else, treat a departure from Rust's `Option` semantics as a bug; these five are intended:
261
407
 
262
- 1. `None#nil?` answers `true`, so ActiveRecord internals and ordinary `.nil?` checks treat an absent value as absent. Equality does not follow suit: `None() == nil` is still `false`.
263
- 2. `Some` delegates `persisted?`, `marked_for_destruction?`, and `touch_later` to its record, so a `Some` can stand in for its record during persistence.
264
- 3. Quoting and the predicate builder are patched so an `Option` passed into `where`/`quote` is unwrapped at the SQL boundary: `Some(v)` binds exactly as `v`, and `None()` as `nil`, so a hash condition asks for `IS NULL`. An array of Options unwraps too. An Option interpolated into raw SQL (`where("id = ?", opt)`) still raises, as it should.
265
- 4. `SomeValidator` provides a presence-style validation for Option attributes.
266
- 5. Attributes declared with `encrypts` are never wrapped: ActiveRecord Encryption's own machinery (a length validator it registers outside `Model.validators`) reads the raw value and cannot survive an Option.
408
+ 1. `None#nil?` answers `true`, so ActiveRecord internals and ordinary `.nil?` checks treat an absent value as absent. Equality does not follow suit: `None() == nil` is still `false`. Nor does `Array#compact`, the common collection idiom for dropping absent members: it tests for the `nil` object, so it keeps a `None` where `reject(&:none?)` drops it.
409
+ 2. `Some` delegates `persisted?` and `touch_later` to its record, so a `Some` can stand in for it where ActiveRecord reads an association back through its public reader, as a `belongs_to ..., touch: true` does after a save.
410
+ 3. An `Option` is unwrapped where a value enters ActiveRecord, above the column type in every case. Quoting and the predicate builder are patched so an `Option` passed into `where`/`quote` is unwrapped at the SQL boundary: `Some(v)` binds exactly as `v`, and `None()` as `nil`, so a hash condition asks for `IS NULL`. An array of Options unwraps too. An Option interpolated into raw SQL (`where("id = ?", opt)`) still raises, as it should. Assignment unwraps on the same principle. A singular association writer takes an Option of a record: `book.author = Some(author)` assigns it and `book.author = None()` clears the association, while a `Some` of the wrong class still raises `AssociationTypeMismatch`. An attribute writer takes an Option of a value, for every column type, and unwraps before the attribute is built, so `book.isbn = other.isbn` round-trips and nothing behind the reader ever holds a wrapper. A value that reaches the database without passing a writer unwraps where it enters ActiveRecord, above the column type in every case: in the ids and conditions `find` and `find_by` are given, on a class, a relation and an association alike; in the rows `update_all`, `insert_all` and `upsert` take; and in a default declared with `attribute :isbn, :string, default: Some('unassigned')`, unwrapped where it is written.
411
+ 4. `SomeValidator` asks whether a value is there at all, where `presence` asks whether it amounts to anything: `Some('')` passes `validates :x, some: true` and fails `presence: true`. It lifts what it is handed, so it asks the same question of any model, converted or not.
412
+ 5. Where the framework's own machinery reads a value raw, it gets one. Validation unwraps at `read_attribute_for_validation`, the seam every `EachValidator` fetches an attribute through; serialization at `read_attribute_for_serialization`, the seam every attribute in a payload is fetched through; and a form helper at `ActionView::Helpers::Tags::Base#value`, the seam every field reads its record through. So a standard validator weighs the value, a payload carries it and a form renders it, rather than the wrapper. A singular association with `accepts_nested_attributes_for` goes further and keeps its plain reader: nested attributes are assigned through the reader, and ActiveRecord asks whatever it finds there whether it is a new record. So does a reader a framework macro declares and then reads for itself, which is the associations behind `has_rich_text` and `has_one_attached` and the digest column `has_secure_password` hands to BCrypt.
267
413
 
268
- The set is closed. If a future integration appears to need a sixth compromise, that is a signal ActiveRecord is pushing back somewhere unmapped, and it warrants a design discussion rather than a quiet patch. `errgonomic_optional_except` is deliberately not on the list: it is configuration, an escape hatch that softens the all-or-nothing include for whatever conflict shows up next, rather than a semantic exception.
414
+ The set is closed. If a future integration appears to need a sixth compromise, that is a signal ActiveRecord is pushing back somewhere unmapped, and it warrants a design discussion rather than a quiet patch. `errgonomic_optional_except` and `errgonomic_serialize_none` are deliberately not on the list: they are configuration, an escape hatch that softens the all-or-nothing include for whatever conflict shows up next and a choice of how an absent value is written, rather than semantic exceptions.
269
415
 
270
416
  ## Development
271
417
 
data/Rakefile CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'bundler/gem_tasks'
4
+ require 'shellwords'
4
5
 
5
6
  require 'rake/testtask'
6
7
  Rake::TestTask.new(:test) do |t|
@@ -16,7 +17,16 @@ YARD::Doctest::RakeTask.new do |task|
16
17
  task.pattern = FileList['lib/**/*.rb'].join(' ')
17
18
  end
18
19
 
19
- task default: %i[test yard:doctest]
20
+ namespace :test do
21
+ desc 'Run the Rails integration suite with strict equality on'
22
+ task :strict do
23
+ ruby '-Ilib', '-Itest', 'test/support/strict_equality.rb', *Shellwords.split(ENV.fetch('TESTOPTS', ''))
24
+ end
25
+ end
26
+
27
+ # yard:doctest ends the process when it finishes, so anything after it in
28
+ # the default list would never run.
29
+ task default: %i[test test:strict yard:doctest]
20
30
 
21
31
  namespace :gems4nix do
22
32
  desc 'Regenerate gem-groups.json after Gemfile/Gemfile.lock changes'