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
42 changes: 38 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 All @@ -33,6 +35,9 @@ module ActiveRecordOptional
reflect_on_all_associations(:belongs_to)
.select { |r| r.options[:optional] }
.each { |r| errgonomic_wrap_optional(r.name) }
reflect_on_all_associations(:has_one)
.reject { |r| r.options[:required] }
.each { |r| errgonomic_wrap_optional(r.name) }
end

class_methods do
Expand Down Expand Up @@ -61,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 @@ -116,6 +124,32 @@ def belongs_to(name, scope = nil, **options)
super.tap { errgonomic_wrap_optional(name) if options[:optional] }
end

# A has_one is absent whenever no row points back at the record, so
# its reader carries the same absence a nullable column does.
# required: true is the exception: it asserts the record is there, and
# absence is a validation failure rather than a value to handle.
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
119 changes: 119 additions & 0 deletions test/rails_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@
t.timestamps
end

create_table 'profiles', force: :cascade do |t|
t.string :tagline
t.references :author
t.timestamps
end

create_table 'awards', force: :cascade do |t|
t.string :name, null: false
t.references :author
t.timestamps
end

create_table 'magazines', force: :cascade do |t|
t.string :title, null: false
t.string :issn
Expand All @@ -56,7 +68,25 @@

class Author < ActiveRecord::Base
has_many :books
has_one :profile, dependent: :destroy
include Errgonomic::Rails::ActiveRecordOptional
has_one :award, dependent: :destroy
end

class Profile < ActiveRecord::Base
belongs_to :author
end

class Award < ActiveRecord::Base
belongs_to :author
end

# A has_one declared required asserts the record is there, so its reader is
# left alone: absence is a validation failure rather than a value.
class Publisher < ActiveRecord::Base
self.table_name = 'authors'
include Errgonomic::Rails::ActiveRecordOptional
has_one :profile, required: true, foreign_key: :author_id
end

class Book < ActiveRecord::Base
Expand Down Expand Up @@ -97,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 @@ -319,6 +366,78 @@ def self.name = 'Quarterly'
assert_equal %w[issn], quarterly.errgonomic_optionals
end

# A has_one is absent whenever no row points back, so its reader carries
# the same absence a nullable column does, whichever side of the include
# it is declared on.
def test_has_one_reads_as_an_option
author = Author.create!(name: 'Cixin Liu')

assert author.profile.none?
assert author.award.none?

author.create_profile!(tagline: 'writes sci-fi')
author.create_award!(name: 'Hugo')

assert_equal 'writes sci-fi', author.reload.profile.unwrap!.tagline
assert_equal 'Hugo', author.award.unwrap!.name
end

# Absence is representable, so the association's own machinery has to keep
# working through the wrapper.
def test_has_one_writes_and_dependent_destroy_still_work
author = Author.create!(name: 'Cixin Liu')
author.profile = Profile.new(tagline: 'writes sci-fi')
author.save!

assert_equal 'writes sci-fi', author.reload.profile.unwrap!.tagline

author.destroy!

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
publisher = Publisher.create!(name: 'Tor', profile: Profile.new(tagline: 'imprint'))

assert_equal 'imprint', publisher.reload.profile.tagline
assert_raises(ActiveRecord::RecordInvalid) { Publisher.create!(name: 'Baen') }
end

# An inherited reader is already wrapped, and a second wrap nests: Some of
# a Some for a present value, while an absent one collapses back to None
# because None answers nil?. Half of it is silent.
Expand Down
Loading