diff --git a/README.md b/README.md index 9505cae..6a0e193 100644 --- a/README.md +++ b/README.md @@ -199,21 +199,31 @@ 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()`). #### 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_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 1da2928..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 @@ -27,8 +34,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) + Array(try(:errgonomic_optional_exceptions)) 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 +65,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..34a3816 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,21 @@ 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 + +# 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') @@ -123,6 +150,27 @@ 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 + + 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)