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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,14 +201,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. 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`.
- `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`.

```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
has_one :rotation_schedule # wrapped: Some(schedule) or None()
has_one :owner, required: true # left unwrapped: absence is a validation failure
end
```

Expand Down
31 changes: 27 additions & 4 deletions lib/errgonomic/rails/active_record_optional.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ module Rails
# boundary, so an 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.
# 5. Readers that ActiveRecord's own machinery reads raw are never
# wrapped: an attribute declared with encrypts, whose length validator
# sits outside Model.validators and calls to_s on the value, and a
# singular association with nested attributes, which are assigned
# through the reader and ask the value whether it is a new record.
#
# errgonomic_optional_except is not on the list: it is configuration, an
# escape hatch for whatever conflict shows up next, not a semantic
Expand Down Expand Up @@ -64,7 +66,10 @@ def errgonomic_optional_exclusions
[]
end

inherited | Array(encrypted_attributes).map(&:to_s) | Array(try(:errgonomic_optional_exceptions)).map(&:to_s)
inherited |
Array(encrypted_attributes).map(&:to_s) |
Array(try(:errgonomic_optional_exceptions)).map(&:to_s) |
errgonomic_nested_attribute_associations
end

# A model that keeps value-or-nil throughout, for whatever the
Expand Down Expand Up @@ -127,6 +132,24 @@ def has_one(name, scope = nil, **options)
super.tap { errgonomic_wrap_optional(name) unless options[:required] }
end

# Nested attributes are assigned through the public reader, and
# ActiveRecord asks whatever it finds there whether it is a new
# record. An absent association has to arrive as nil for that, so a
# singular association with nested attributes keeps its plain reader.
def accepts_nested_attributes_for(*names, **options)
super.tap { errgonomic_unwrap_optionals(*names) }
end

# ActiveRecord keeps its own register of these, so the exclusion can be
# read from there rather than recorded as it goes past.
def errgonomic_nested_attribute_associations
return [] unless respond_to?(:nested_attributes_options)

nested_attributes_options.keys.map(&:to_s).select do |name|
%i[has_one belongs_to].include?(reflect_on_association(name)&.macro)
end
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
Expand Down
50 changes: 50 additions & 0 deletions test/rails_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,23 @@ class Credential < ActiveRecord::Base
encrypts :access_secret
end

# Nested attributes are assigned through the public reader, and ActiveRecord
# asks whatever it finds there whether it is a new record, so a wrapped
# singular association cannot survive the round trip.
class Editor < ActiveRecord::Base
self.table_name = 'authors'
include Errgonomic::Rails::ActiveRecordOptional
has_one :profile, foreign_key: :author_id
accepts_nested_attributes_for :profile, allow_destroy: true
end

class Anthology < ActiveRecord::Base
self.table_name = 'books'
include Errgonomic::Rails::ActiveRecordOptional
belongs_to :author, optional: true
accepts_nested_attributes_for :author
end

# An opt-out named before the include keeps an attribute unwrapped, for
# machinery the concern does not know about.
class OptedOutCredential < ActiveRecord::Base
Expand Down Expand Up @@ -379,6 +396,39 @@ def test_has_one_writes_and_dependent_destroy_still_work
assert_equal 0, Profile.where(author_id: author.id).count
end

# ActiveRecord reads the association, asks it whether it is a new record,
# and assigns through it, so the reader has to stay plain for the whole
# nested-attributes cycle: build, update, and destroy.
def test_nested_attributes_on_a_has_one_keep_working
editor = Editor.create!(name: 'Cixin Liu')

editor.update!(profile_attributes: { tagline: 'writes sci-fi' })

assert_equal 'writes sci-fi', editor.reload.profile.tagline

editor.update!(profile_attributes: { id: editor.profile.id, tagline: 'revised' })

assert_equal 'revised', editor.reload.profile.tagline

editor.update!(profile_attributes: { id: editor.profile.id, _destroy: '1' })

assert_nil editor.reload.profile
end

def test_nested_attributes_on_an_optional_belongs_to_keep_working
anthology = Anthology.create!(title: 'Wandering Earth', author_attributes: { name: 'Cixin Liu' })

assert_equal 'Cixin Liu', anthology.reload.author.name
end

# The unwrapped set is discoverable, so a converted model can say which
# readers ActiveRecord kept for itself.
def test_an_association_with_nested_attributes_is_reported_as_unwrapped
refute_includes Editor.errgonomic_optionals, 'profile'
refute_includes Anthology.errgonomic_optionals, 'author'
assert_includes Anthology.errgonomic_optional_exclusions, 'author'
end

# required: true says the record is always there, which is a validation,
# not an absence to represent.
def test_a_required_has_one_is_left_unwrapped
Expand Down
Loading