From 74abbb0e3c08a26a25ce5d147a20b49887c7794d Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Fri, 7 Aug 2026 18:19:52 -0500 Subject: [PATCH 1/3] Leave encrypted attributes unwrapped Including the concern in a model that also uses encrypts made every save raise. Encryption registers a length validator on each encrypted column, and the validator measures the value with to_s, which an Option refuses. The validator never appears in Model.validators, only in the runtime callback chain, so the cause was invisible from the model. Skip encrypted attributes when computing the wrapped set. Applications declare encrypts after the include as often as before it, so also hook the class method and give a late-declared attribute its plain reader back. --- .../rails/active_record_optional.rb | 19 +++++++++++ test/rails_test.rb | 33 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/lib/errgonomic/rails/active_record_optional.rb b/lib/errgonomic/rails/active_record_optional.rb index 1da2928..1a4197d 100644 --- a/lib/errgonomic/rails/active_record_optional.rb +++ b/lib/errgonomic/rails/active_record_optional.rb @@ -27,8 +27,10 @@ module ActiveRecordOptional optional_associations = reflect_on_all_associations(:belongs_to) .select { |r| r.options[:optional] } .map(&:name) + excluded = Array(encrypted_attributes).map(&:to_s) optional_attributes = column_names .select { |n| column_for_attribute(n).null } + .reject { |n| excluded.include?(n) } @errgonomic_optionals = (optional_attributes + optional_associations) @errgonomic_optionals.each do |name| class_eval <<-RUBY, __FILE__, __LINE__ + 1 @@ -56,6 +58,23 @@ def #{name} def errgonomic_optionals @errgonomic_optionals end + + # Encryption surrounds an attribute with machinery that reads the raw + # value, including a length validator that calls to_s on it, so a + # wrapped encrypted attribute cannot be saved. Declaring encrypts + # after the include is the ordinary spelling, so catch it here too and + # give the attribute its plain reader back. + def encrypts(*names, **options) + super.tap { errgonomic_unwrap_optionals(*names) } + end + + def errgonomic_unwrap_optionals(*names) + names.map(&:to_s).each do |name| + next unless @errgonomic_optionals&.delete(name) + + remove_method(name) + end + end end end end diff --git a/test/rails_test.rb b/test/rails_test.rb index 7d405c1..4e434cd 100644 --- a/test/rails_test.rb +++ b/test/rails_test.rb @@ -31,8 +31,20 @@ t.references :parent, foreign_key: { to_table: :genres } t.timestamps end + + create_table 'credentials', force: :cascade do |t| + t.string :access_key, limit: 255 + t.string :access_secret, limit: 255 + t.timestamps + end end +ActiveRecord::Encryption.configure( + primary_key: 'test primary key', + deterministic_key: 'test deterministic key', + key_derivation_salt: 'test key derivation salt' +) + # Before classes are loaded we need to define helper methods like `delegate_optional` Errgonomic::Rails.setup_before @@ -72,6 +84,13 @@ class LoopyAuthor < ActiveRecord::Base include Errgonomic::Rails::ActiveRecordOptional end +# Encryption adds a length validator that reads the raw attribute, and +# declares itself after the concern is included, as applications write it. +class Credential < ActiveRecord::Base + include Errgonomic::Rails::ActiveRecordOptional + encrypts :access_secret +end + class BugTest < Minitest::Test def test_optional_attributes author = Author.create!(name: 'Cixin Liu') @@ -123,6 +142,20 @@ def test_private_delegate_optional assert_equal 'Fiction', scifi.send(:parent_name).unwrap! end + def test_encrypted_attributes_are_left_unwrapped + credential = Credential.create!(access_key: 'abc123', access_secret: 'shhh') + + assert_equal 'shhh', credential.access_secret + assert_equal 'abc123', credential.access_key.unwrap! + end + + def test_encrypted_attributes_may_be_absent + credential = Credential.create!(access_key: 'abc123') + + assert_nil credential.access_secret + assert credential.reload.access_secret.nil? + end + private def deeper(frames, &block) From 8b00b38b14aba7a041e8ce81ccaac1531d641719 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Fri, 7 Aug 2026 18:20:59 -0500 Subject: [PATCH 2/3] Add a per-attribute opt-out to the optional concern Wrapping was all or nothing per model, so a single attribute the surrounding machinery insists on reading raw blocked the whole model from adopting the concern. errgonomic_optional_except names attributes to leave alone. It sits with delegate_optional, on every model, because it has to be callable before the include that computes the wrapped set. --- README.md | 11 ++++++++++- .../rails/active_record_delegate_optional.rb | 11 +++++++++++ lib/errgonomic/rails/active_record_optional.rb | 2 +- test/rails_test.rb | 15 +++++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9505cae..cdf15e6 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,16 @@ end When `Rails::Railtie` is defined, Errgonomic installs a Railtie with two opt-in integrations for ActiveRecord: -- `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. This is all-or-nothing per model: every nullable column and optional association is wrapped, with no per-attribute opt-in. +- `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. Two kinds of attribute stay unwrapped: those declared with `encrypts`, whose surrounding machinery reads the raw value, and those named by `errgonomic_optional_except`, which must appear before the include. + +```ruby +class Credential < ApplicationRecord + errgonomic_optional_except :legacy_token + include Errgonomic::Rails::ActiveRecordOptional + + encrypts :access_secret # also left unwrapped, declared either side of the include +end +``` - `delegate_optional :name, to: :association` (available on all models) delegates through an optional association, returning an Option instead of raising on nil. `Object#to_option` is also available in Rails to lift any value into an Option (`nil.to_option # => None()`). diff --git a/lib/errgonomic/rails/active_record_delegate_optional.rb b/lib/errgonomic/rails/active_record_delegate_optional.rb index add3a41..bc02052 100644 --- a/lib/errgonomic/rails/active_record_delegate_optional.rb +++ b/lib/errgonomic/rails/active_record_delegate_optional.rb @@ -9,6 +9,17 @@ module ActiveRecordDelegateOptional extend ActiveSupport::Concern class_methods do + # Names attributes that ActiveRecordOptional must leave alone. It has + # to be callable before the include, which is what computes the + # wrapped set, so it lives here rather than in the concern itself. + def errgonomic_optional_except(*names) + @errgonomic_optional_exceptions = errgonomic_optional_exceptions + names.map(&:to_s) + end + + def errgonomic_optional_exceptions + @errgonomic_optional_exceptions ||= [] + end + def delegate_optional(*methods, to: nil, prefix: nil, private: nil) return if to.nil? diff --git a/lib/errgonomic/rails/active_record_optional.rb b/lib/errgonomic/rails/active_record_optional.rb index 1a4197d..f7c38dc 100644 --- a/lib/errgonomic/rails/active_record_optional.rb +++ b/lib/errgonomic/rails/active_record_optional.rb @@ -27,7 +27,7 @@ module ActiveRecordOptional optional_associations = reflect_on_all_associations(:belongs_to) .select { |r| r.options[:optional] } .map(&:name) - excluded = Array(encrypted_attributes).map(&:to_s) + excluded = Array(encrypted_attributes).map(&:to_s) + Array(try(:errgonomic_optional_exceptions)) optional_attributes = column_names .select { |n| column_for_attribute(n).null } .reject { |n| excluded.include?(n) } diff --git a/test/rails_test.rb b/test/rails_test.rb index 4e434cd..34a3816 100644 --- a/test/rails_test.rb +++ b/test/rails_test.rb @@ -91,6 +91,14 @@ class Credential < ActiveRecord::Base encrypts :access_secret end +# An opt-out named before the include keeps an attribute unwrapped, for +# machinery the concern does not know about. +class OptedOutCredential < ActiveRecord::Base + self.table_name = 'credentials' + errgonomic_optional_except :access_key + include Errgonomic::Rails::ActiveRecordOptional +end + class BugTest < Minitest::Test def test_optional_attributes author = Author.create!(name: 'Cixin Liu') @@ -156,6 +164,13 @@ def test_encrypted_attributes_may_be_absent assert credential.reload.access_secret.nil? end + def test_errgonomic_optional_except_skips_named_attributes + credential = OptedOutCredential.create!(access_key: 'abc123', access_secret: 'shhh') + + assert_equal 'abc123', credential.access_key + assert_equal 'shhh', credential.access_secret.unwrap! + end + private def deeper(frames, &block) From 5127faca1ce6ba301db3c7ec9a9d7669db013cca Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Fri, 7 Aug 2026 20:20:03 -0500 Subject: [PATCH 3/3] Name the encrypted-attribute exclusion as compromise five The compromise list promised that a new integration exception gets a design discussion and a place on the list rather than a quiet patch; this is that entry. errgonomic_optional_except is deliberately kept off the list as configuration rather than a semantic exception. --- README.md | 5 +++-- lib/errgonomic/rails/active_record_optional.rb | 11 +++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cdf15e6..6a0e193 100644 --- a/README.md +++ b/README.md @@ -215,14 +215,15 @@ end #### ActiveRecord compromises -ActiveRecord assumes things about accessors that a strict Rust Option cannot satisfy, so the integration carries four deliberate compromises. Everywhere else, treat a departure from Rust's `Option` semantics as a bug; these four are intended: +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: 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`. 2. `Some` delegates `persisted?`, `marked_for_destruction?`, and `touch_later` to its record, so a `Some` can stand in for its record during persistence. 3. Quoting is patched so an `Option` passed into `where`/`quote` is unwrapped at the SQL boundary. 4. `SomeValidator` provides a presence-style validation for Option attributes. +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. -The set is closed. If a future integration appears to need a fifth compromise, that is a signal ActiveRecord is pushing back somewhere unmapped, and it warrants a design discussion rather than a quiet patch. +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. ## Development diff --git a/lib/errgonomic/rails/active_record_optional.rb b/lib/errgonomic/rails/active_record_optional.rb index f7c38dc..a88e02e 100644 --- a/lib/errgonomic/rails/active_record_optional.rb +++ b/lib/errgonomic/rails/active_record_optional.rb @@ -4,9 +4,9 @@ module Errgonomic module Rails # Concern to make ActiveRecord optional attributes and associations return an Option. # - # Four pragmatic compromises below satisfy ActiveRecord's assumptions + # Five pragmatic compromises below satisfy ActiveRecord's assumptions # about how accessors behave. They are deliberate exceptions to "Option - # behaves like Rust's Option", and the set is closed: a fifth would be a + # behaves like Rust's Option", and the set is closed: a sixth would be a # signal that ActiveRecord is pushing back somewhere unmapped, deserving # a design discussion rather than a quiet patch. # @@ -19,6 +19,13 @@ module Rails # Option can be passed to where/quote. # 4. SomeValidator provides a presence-style validation for Option # attributes. + # 5. Attributes declared with encrypts are never wrapped: ActiveRecord + # Encryption registers a length validator outside Model.validators + # that reads the raw value and cannot survive an Option. + # + # errgonomic_optional_except is not on the list: it is configuration, an + # escape hatch for whatever conflict shows up next, not a semantic + # exception. module ActiveRecordOptional extend ActiveSupport::Concern