Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions lib/errgonomic/rails/active_record_delegate_optional.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
30 changes: 28 additions & 2 deletions lib/errgonomic/rails/active_record_optional.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions test/rails_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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)
Expand Down
Loading