diff --git a/.rubocop.yml b/.rubocop.yml index 55ddda0..1f1a6d8 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -4,7 +4,7 @@ plugins: rubocop-yard # the assertions. Long lines carrying a `#=>` expectation are allowed. Layout/LineLength: AllowedPatterns: - - '#=>' + - "#=>" # Some(), None(), Ok() and Err() are the library's Rust-style value # constructors; their capitalized names are the point. @@ -38,12 +38,18 @@ Metrics: # The ActiveRecord hooks in the optional concern are one subject — where a # reader may come from and what leaves it alone — and reading them together is # the point. The generated reader is a heredoc, which the length cops count as -# if it were code. +# if it were code. What a model declares about errgonomic before the concern +# acts is one subject in the same way. Metrics/BlockLength: Exclude: - lib/errgonomic/rails/active_record_optional.rb + - lib/errgonomic/rails/active_record_delegate_optional.rb - test/**/* Metrics/MethodLength: Exclude: - lib/errgonomic/rails/active_record_optional.rb - test/**/* +Metrics/ModuleLength: + Exclude: + - lib/errgonomic/rails/active_record_optional.rb + - test/**/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 27e11a8..f70562c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ ## [Unreleased] +- `ActiveRecordOptional` installs its wrapped readers into a per-class module, so a model's own reader of the same name composes with the wrapper through `super` instead of one silently replacing the other +- A wrapped reader lifts a value exactly one layer: an Option arriving from beneath the wrapper passes through instead of being wrapped a second time +- `belongs_to` and `has_one` writers accept an Option: `Some(record)` assigns the record it wraps and `None()` clears the association, so a wrapped reader can be assigned straight onto another record +- An attribute writer accepts an Option and stores the value inside it, `None()` storing `nil`, for every column type: `record.pinned = Some(false)` stores `false` where it used to store `true`, string, text, json, date and datetime writers no longer raise, and the numeric writers no longer route through the soft-deprecated `Option#presence`. Dirty tracking, `attributes` and the before-type-cast reader see the raw value +- A value that reaches the database without passing an attribute writer unwraps at the type cast instead, so `update_all`, `insert_all`, `upsert` and a default declared with `attribute :col, :string, default: Some(v)` take Options for every column type +- Validation on a converted model reads the value inside a wrapped attribute rather than the wrapper: `inclusion` and `exclusion` compare against the value, `presence` rejects `Some('')` as it rejects `''`, `length` and `format` no longer raise, and a `None` validates like `nil`. `validates :x, some: true` stays the Option-aware presence check, and now answers for a plain value on any model. An application's own `EachValidator` or `validates_each` block on a converted model is handed the inner value where 0.8.x handed it the Option, so one written against the wrapper as `value.some?` needs the adaptation `SomeValidator` took: `value.to_option.some?` +- An attribute declared with `encrypts` is wrapped like any other nullable column, now that the encryption length validator reads it through the validation seam. It round-trips as an Option, a `deterministic: true` attribute stays queryable, and `downcase:` still normalizes on write +- `Some` no longer delegates `marked_for_destruction?` to its record. The presence, absence and associated validators were what asked it, and they now receive the record itself +- A column type unwraps an Option when it serializes a value as well as when it casts one, so `find_by(col: Some(v))` works where it previously reached the type with the wrapper still on: `find_by` binds into a cached statement rather than through the predicate builder. This is what makes `find_by` usable against an encrypted attribute, whose type serializes through the underlying type and then calls `to_s` +- A converted model serializes as the unconverted one does: `as_json`, `to_json` and `serializable_hash` fetch every attribute through `read_attribute_for_serialization`, which unwraps, so `Some(v)` writes `v` and `None()` writes `null` where 0.8.x raised `Errgonomic::SerializeError`. An association under `include:` serializes as its record's hash, or leaves the key out where a `nil` association already does, and a wrapped reader named in `methods:` unwraps one layer. An Option handed to an arbitrary payload still raises +- `errgonomic_serialize_none :omit` drops the keys a record has no value for instead of writing them as `null`, declared on either side of the include, on a model or on a base class above it, and scoped to named readers with `only:` or `except:`. The nearest declaration wins and replaces whatever it inherits; `:null` is the default and needs no declaration. A declaration that cannot change a payload raises `ArgumentError` where it is written: an unknown mode, `only:` together with `except:`, or a scoped `:null` +- [Dev, Test] - Doctests run against an in-memory ActiveRecord connection, so an `@example` under `lib/errgonomic/rails` specifies the integration the same way every other example specifies the core + ## [0.4.1] - 2025-02-20 - Bugfix: `unwrap_or_else` yields the inner error diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bbd886c..1184dfe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,7 +58,7 @@ Comments explain intent and rationale — the *why* behind a non-obvious choice. ## What we value, in order -When two designs compete, prefer them in this order. +When two designs compete, prefer them in this order. Before that ordering applies, a design choice has to fit the gem's purpose, which the README's Design section states: the intersection of Rust and Rails idioms, with every deviation from either documented alongside its reason. 1. **Correct.** The code states its behavior and is tested against that statement. Invalid states are made unrepresentable rather than guarded against after the fact. Runtime failures produce diagnostics that tell an operator what went wrong and what to do. 2. **Simple.** The code reads clearly at the right level of abstraction, and is idiomatic and approachable to another developer. Fewer moving parts, fewer sources of truth. diff --git a/README.md b/README.md index cf895f6..0008a26 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ Errgonomic provides some lightweight, opinionated ergonomics for error handling in Ruby. These semantics are a blend of Rails `present?` conventions, and Rust `Option` and `Result` type combinators. Without going full Option and Result. Probably. +## Design + +Errgonomic aims at the intersection of two idioms rather than translating one into the other. Rails supplies the mechanism: a concern, an attribute reader overridden with `super`, the reader as the boundary of a model's public surface. Rust supplies the shape of the value: an `Option` you handle with combinators, instead of a value that may or may not be `nil`. Convention over configuration and least surprise are the tests every design choice here has to pass, and where the two idioms already agree we follow the convention and say nothing more about it. + +Where the gem leaves one of them, the docs say so and say why. The reason is nearly always mechanical: ActiveRecord assumes things about accessors that a strict Option cannot satisfy. The [ActiveRecord compromises](#activerecord-compromises) are that register, enumerated and closed. + ## Installation Install the gem and add to the application's Gemfile by executing: @@ -107,12 +113,16 @@ in Errgonomic::Option::None end ``` -An unhandled Option refuses to leak into your output: `to_s`, `to_json`, and `as_json` raise `Errgonomic::SerializeError`, so you handle the inner value deliberately rather than shipping `#` to a user. The refusal covers `as_json` because Hash and Array serialization recurses through that method, and an Option nested in a payload would otherwise serialize as `{"value": ...}`. +An unhandled Option refuses to leak into your output: `to_s`, `to_json`, and `as_json` raise `Errgonomic::SerializeError`, so you handle the inner value deliberately rather than shipping `#` to a user. The refusal covers `as_json` because Hash and Array serialization recurses through that method, and an Option nested in a payload would otherwise serialize as `{"value": ...}`. A converted ActiveRecord model is the one exception, at the model boundary: it unwraps each attribute as it serializes, so a record's own `as_json` says what an unconverted record's says. See [Rails integration](#rails-integration). `unwrap!` and `expect!` are for tests and consoles, not application code: they raise on `None`, which is exactly the ambiguous failure the type exists to prevent. Application code should always have a combinator or pattern match that handles the `None` branch explicitly; if none fits, that is a gap worth an issue rather than a reason to unwrap. Presence follows the discriminant, as in Rust: `Some` is `present?` and `None` is `blank?`, regardless of the wrapped value. So `Some(false).present?` and `Some(nil).present?` are both `true`. If you care about the inner value's own presence, unwrap it first. +Truthiness is the Rails reflex that breaks. An Option is an object, so `None()` is truthy: `isbn || 'unassigned'` hands back the `None`, `if isbn` takes the present branch, and nothing raises to say so. Reach for `unwrap_or('unassigned')`, or for `map` and `and_then` when the fallback is itself an Option. Safe navigation looks for the `nil` object rather than asking `nil?`, so `isbn&.strip` calls into the Option and raises `Errgonomic::UnwrappedAccessError`, where `isbn.map(&:strip)` does what was meant. Under the Rails integration `None#nil?` answers `true`, so an explicit `nil?` check behaves, but `||` and `&.` never consult it. + +Writers unwrap under that integration, which changes what a truthiness slip costs rather than removing it. `self.isbn = isbn || 'unassigned'` no longer leaks a wrapper into the database; it silently persists whatever `isbn` held, `nil` included, because a `None` is truthy and the fallback is never reached. The write succeeds and nothing raises. `unwrap_or('unassigned')` is the spelling that means it. + The presence helpers are soft-deprecated on Options in favor of the combinators. The present side unwraps, where on any other object it returns the receiver — `Some(v).present_or_raise!(msg)`, `present_or(default)`, `present_or_else { }`, and `presence` all yield `v`, and `None` raises, substitutes, or answers `nil` — and each call prints a one-line stderr nudge naming the combinator to use instead (`expect!`, `unwrap_or`, `unwrap_or_else`, `unwrap_or(nil)`). The blank side (`blank_or*`) raises `UnwrappedAccessError` outright: an Option's blankness is its discriminant, so test it with `none?`. Equality is between Options only: `Some(5) == Some(5)`, but `Some(5) == 5` and `None() == nil` are `false`. That is quiet, never an error, matching how every Ruby object compares across types. Rust rejects `Some(5) == 5` at compile time; Ruby cannot, so guard the idiom in review and tests: compare against a wrapped value (`opt == Some(5)`) or test the inner value (`opt.some_and? { |v| v == 5 }`). @@ -201,14 +211,14 @@ 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. 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`. +- `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: a singular association with `accepts_nested_attributes_for`, which ActiveRecord assigns through the reader and reads raw; a `has_one ..., required: true`, whose absence is a validation failure rather than a value; 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 + encrypts :access_secret # wrapped like any other nullable column has_one :rotation_schedule # wrapped: Some(schedule) or None() has_one :owner, required: true # left unwrapped: absence is a validation failure end @@ -249,23 +259,78 @@ class Credential < ApplicationRecord end ``` -`Model.errgonomic_optionals` reports which readers a model wrapped, which is how to check that a conversion did what it meant to. +**Overriding a wrapped reader.** Wrapped readers live in a module the concern includes into the model, so a model's own `def` of the same name coexists with the wrapper and reads the Option through `super`. The rule is one of layering: a `def` in the model's own class body, or a module the model itself includes after the errgonomic include, sits above the wrapper and reads the Option from `super`. + +The type does not change inside the override. `super` hands back exactly what every other caller of the reader gets, so an override that keeps the Option keeps the model's contract: + +```ruby +class Book < ApplicationRecord + include Errgonomic::Rails::ActiveRecordOptional + + belongs_to :author, optional: true + + def isbn + super.map(&:strip) # super is Some(isbn) or None(), and so is this + end + + def display_isbn + isbn.unwrap_or('unassigned') + end +end +``` + +An accessor that hands back a plain value is a different method with a different name, the way a Rust `fn display_name(&self) -> String` sits beside a `name: Option` field. `display_isbn` is that method; `isbn` stays the field. + +A same-named `def` that never calls `super` is legal Ruby and the model owns its return value outright: the wrapper stays installed beneath it and nothing reaches it. It is the un-idiomatic spelling, and it leaves one loose end: `Model.errgonomic_optionals` still reports the reader as wrapped, because the conversion did wrap it. + +**Storage stays nullable; the reader is the boundary.** Only the reader returns an Option. `self[:isbn]`, `read_attribute(:isbn)`, `isbn_was`, `isbn_change`, and `attributes` all answer the raw column value or `nil`, which is where Rails already draws the line for a reader override: the attribute is the storage, the reader is the interface. Rust would expect the Option all the way down, and this is the largest place the gem does not follow it, because dirty tracking, serialization, and query building each read the attribute directly and an Option would have to survive all of them. + +Writers take either a plain value or an Option of one, for attributes and singular associations alike, so a wrapped reader's value assigns straight back: `other_book.title = book.title` and `other_book.author = book.author` both do what they read like. `book.isbn = '9780765377104'` means what it always did, and assigning `None()` stores `nil`. Assigning an Option is assigning the value inside it for every column type, so a wrapped `false` stores `false`, and the storage behind the reader stays raw: `changes`, `read_attribute_before_type_cast` and `attributes` see the value, never the wrapper. `update_all`, `insert_all`, `upsert` and an attribute default take Options on the same terms, unwrapping at the type cast rather than at a writer. + +The assignment and cast seams are installed on ActiveModel itself, as the quoting and predicate-builder seams are. They apply to every model in the application, whether or not it includes the concern: the concern decides what a reader returns, not what a writer accepts. One gap remains at that boundary: a type whose `cast` or `serialize` never calls `super` sits outside the seam and sees the Option itself. `ActiveRecord::Type::Json#serialize` is one, so `find_by(meta: Some(hash))` on a json column raises where `where(meta: Some(hash))` does not. + +**Validation reads the value.** Standard validators on a converted model behave exactly as they do on an unconverted one. `inclusion`, `exclusion`, `presence`, `length`, `format`, `numericality` and the rest weigh the value inside the Option, and a `None` validates like `nil`, because every `EachValidator` fetches its attribute through `read_attribute_for_validation`, which unwraps. `validates :isbn, some: true` is the Option-aware presence check, asking only whether the value is there: `Some('')` passes `some:` and fails `presence: true`, exactly as `''` fails it. Custom validation code is the exception, because it reads the public reader: a `validate :check_isbn` whose body calls `isbn` gets `Some('9780765377104')`, the same as every other caller. + +**Serialization.** A converted model serializes as the unconverted one does. `as_json`, `to_json` and `serializable_hash` fetch every attribute through `read_attribute_for_serialization`, which unwraps, so `Some(v)` writes `v` and `None()` writes `null`. Both idioms agree on the default: Rails writes an absent value as `null`, and so does serde unless a field asks otherwise. The refusal stands everywhere else, so a hand-built Option in an arbitrary payload (`{ isbn: book.isbn }.to_json`) still raises `Errgonomic::SerializeError`. + +An association under `include:` follows the same rule: `Some(author)` serializes as the record's own hash, and a `None` leaves the key out, which is what `include:` already does with a `nil` association. A `has_many` is never an Option and is untouched. A wrapped reader named in `methods:` unwraps one layer as well, so `as_json(methods: :isbn)` writes the value; a method that hands back a plain value is unchanged. + +Omission is the opt-in, as it is in serde, and it is declared on the model rather than on `belongs_to` or `has_one`: + +```ruby +class ApplicationRecord < ActiveRecord::Base + include Errgonomic::Rails::ActiveRecordOptional + errgonomic_serialize_none :omit # drop keys whose value is None +end + +class Book < ApplicationRecord + errgonomic_serialize_none :null # this model keeps them, as null (the default) +end + +class Manuscript < ApplicationRecord + errgonomic_serialize_none :omit, only: %i[isbn] # only this reader is dropped; except: also accepted +end +``` + +The declaration reads as well above the include as below it, as `errgonomic_optional_except` does. The nearest declaration wins and replaces whatever it inherits, rather than layering onto it, so a reader a scoped declaration does not name keeps the default. Omission drops keys from the payload the caller asked for, so it composes with the caller's own `only:` and `except:`. It governs by reader name wherever the key came from, so a `methods:` entry naming a wrapped reader that reads `None` is dropped along with the reader, while a plain method that happens to return `nil` is kept. A declaration that cannot change a payload raises `ArgumentError` where it is written, naming what to write instead. That covers a mode other than `:null` or `:omit`, `only:` together with `except:`, and a scoped `:null`, which asks for the default on the readers it names and leaves the rest at the default anyway. + +`Model.errgonomic_optionals` reports which readers a model wrapped, including nullable foreign-key columns, so `book.author_id` is `Some(1)` alongside `book.author`. That is how to check that a conversion did what it meant to. - `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()`). +`Object#to_option` lifts any value into an Option (`nil.to_option # => None()`). It lifts once and only once: an Option passes through unchanged (`Some(1).to_option # => Some(1)`), so lifting a value whose provenance you do not know is safe. That is the rule everywhere in the integration. An ActiveRecord attribute or association is never an optional of an optional, so a wrapped reader never nests a second Option around a value that already is one. Nesting is invisible until something reaches for the inner value, which is the ambiguous failure the type exists to prevent. #### ActiveRecord compromises -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: +This is the register of where the gem leaves the Rust idiom, and why. ActiveRecord assumes things about accessors that a strict Rust `Option` cannot satisfy, so the integration carries five deliberate compromises, each one forced by a specific piece of ActiveRecord machinery rather than chosen. 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 and the predicate builder are patched so an `Option` passed into `where`/`quote` is unwrapped at the SQL boundary: `Some(v)` binds exactly as `v`, and `None()` as `nil`, so a hash condition asks for `IS NULL`. An array of Options unwraps too. An Option interpolated into raw SQL (`where("id = ?", opt)`) still raises, as it should. -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. +2. `Some` delegates `persisted?` and `touch_later` to its record, so a `Some` can stand in for it where ActiveRecord reads an association back through its public reader, as a `belongs_to ..., touch: true` does after a save. +3. Quoting and the predicate builder are patched so an `Option` passed into `where`/`quote` is unwrapped at the SQL boundary: `Some(v)` binds exactly as `v`, and `None()` as `nil`, so a hash condition asks for `IS NULL`. An array of Options unwraps too. An Option interpolated into raw SQL (`where("id = ?", opt)`) still raises, as it should. Assignment unwraps on the same principle. A singular association writer takes an Option of a record: `book.author = Some(author)` assigns it and `book.author = None()` clears the association, while a `Some` of the wrong class still raises `AssociationTypeMismatch`. An attribute writer takes an Option of a value, for every column type, and unwraps before the attribute is built, so `book.isbn = other.isbn` round-trips and nothing behind the reader ever holds a wrapper. The type cast unwraps on the same terms for a value that reaches the database without passing a writer: `update_all`, `insert_all`, `upsert`, and a default declared with `attribute :isbn, :string, default: Some('unassigned')`. Serialization unwraps alongside it, for `find_by`, which binds its values into a cached statement instead of through the predicate builder. +4. `SomeValidator` asks whether a value is there at all, where `presence` asks whether it amounts to anything: `Some('')` passes `validates :x, some: true` and fails `presence: true`. It lifts what it is handed, so it asks the same question of any model, converted or not. +5. Where ActiveRecord's own machinery reads a value raw, it gets one. Validation unwraps at `read_attribute_for_validation`, the seam every `EachValidator` fetches an attribute through, and serialization at `read_attribute_for_serialization`, the seam every attribute in a payload is fetched through, so a standard validator weighs the value and a payload carries it rather than the wrapper. A singular association with `accepts_nested_attributes_for` goes further and keeps its plain reader: nested attributes are assigned through the reader, and ActiveRecord asks whatever it finds there whether it is a new record. -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. +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` and `errgonomic_serialize_none` are deliberately not on the list: they are configuration, an escape hatch that softens the all-or-nothing include for whatever conflict shows up next and a choice of how an absent value is written, rather than semantic exceptions. ## Development diff --git a/doctest_helper.rb b/doctest_helper.rb index 5bc96b3..7e14e1a 100644 --- a/doctest_helper.rb +++ b/doctest_helper.rb @@ -1,3 +1,60 @@ # frozen_string_literal: true +require 'active_record' +require 'logger' + require_relative 'lib/errgonomic' +require_relative 'lib/errgonomic/rails' + +# The Rails integration patches ActiveRecord as it loads, so examples under +# lib/errgonomic/rails need a live connection and a model to run against. +ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') +ActiveRecord::Base.logger = Logger.new(File::NULL) + +# One nullable column per type the cast boundary has to map. +ActiveRecord::Schema.verbose = false +ActiveRecord::Schema.define do + create_table 'notes', force: :cascade do |t| + t.boolean :pinned + t.string :title + t.text :body + t.json :meta + t.integer :rank + t.float :score + t.decimal :price + t.date :due_on + t.datetime :read_at + end +end + +Errgonomic::Rails.setup_before + +class Note < ActiveRecord::Base + include Errgonomic::Rails::ActiveRecordOptional +end + +# Two validators that answer differently for the same wrapped value. +class Memo < ActiveRecord::Base + self.table_name = 'notes' + include Errgonomic::Rails::ActiveRecordOptional + validates :title, presence: true + validates :body, some: true +end + +# yard-doctest sends any expectation that answers nil? to assert_nil, and +# under the Rails integration None() answers it. Compare an expected Option by +# value, so `# => None()` keeps meaning what it says. +module DoctestOptionEquality + def assert_example(example, expected, actual, bind) + # An expectation is a literal in the example's own binding, so leaving + # the other branches to super costs nothing but evaluating it twice. + return super unless evaluate_with_assertion(expected, bind).is_a?(Errgonomic::Option::Any) + + assert_equal(evaluate_with_assertion(expected, bind), evaluate_with_assertion(actual, bind)) + rescue Minitest::Assertion => e + add_filepath_to_backtrace(e, example.filepath) + raise e + end +end + +YARD::Doctest::Example.prepend(DoctestOptionEquality) diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index 2f812d0..ea4d05a 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -4,6 +4,10 @@ module Errgonomic module Option # The base class for all options. Some and None are subclasses. # + # An Option is an object, so it is always truthy. A None does not stand + # in for nil in a conditional, and `||` hands back the wrapper rather + # than the fallback. Reach for a combinator to get at the inner value. + # class Any include Comparable @@ -333,10 +337,13 @@ def expect!(msg) value end - # returns the inner value if present, else returns the default value + # returns the inner value if present, else returns the default value. + # This is the spelling `opt || default` cannot give you: an Option is + # truthy, so `||` never reaches the fallback. # @example # Some(1).unwrap_or(2) # => 1 # None().unwrap_or(2) # => 2 + # None() || 2 # => None() def unwrap_or(default) return default if none? diff --git a/lib/errgonomic/rails/active_record_delegate_optional.rb b/lib/errgonomic/rails/active_record_delegate_optional.rb index 4d590d3..150a4c7 100644 --- a/lib/errgonomic/rails/active_record_delegate_optional.rb +++ b/lib/errgonomic/rails/active_record_delegate_optional.rb @@ -25,6 +25,47 @@ def errgonomic_optional_exceptions superclass.respond_to?(:errgonomic_optional_exceptions) ? superclass.errgonomic_optional_exceptions.dup : [] end + # How a None reaches a payload. :null writes it as null, which is + # what Rails does with nil and what serde does with None unless a + # field asks otherwise, so it is the default and needs no + # declaration. :omit leaves the key out instead. only: and except: + # scope the mode to named readers, and a reader outside the scope + # keeps the default. Configuration reads as well above the include + # as below it, so it lives here rather than in the concern. + def errgonomic_serialize_none(mode, only: nil, except: nil) + complaint = errgonomic_serialize_none_complaint(mode, only, except) + raise ::ArgumentError, "errgonomic_serialize_none #{complaint}" if complaint + + @errgonomic_serialize_none = { + mode: mode, + only: only && Array(only).map(&:to_s), + except: except && Array(except).map(&:to_s) + } + end + + # The nearest declaration is the whole story for a class: it replaces + # whatever it inherits rather than layering onto it, so a scoped one + # leaves every reader it does not name at the default. + def errgonomic_serialize_none_declaration + return @errgonomic_serialize_none if defined?(@errgonomic_serialize_none) + return nil unless superclass.respond_to?(:errgonomic_serialize_none_declaration) + + superclass.errgonomic_serialize_none_declaration + end + + # A declaration that cannot change what a payload looks like is a + # mistake rather than a no-op, so say what to write instead. :null is + # already what every unnamed reader gets, so scoping it names one set + # of readers for the default and leaves the rest at the default too. + def errgonomic_serialize_none_complaint(mode, only, except) + return "takes :null or :omit, not #{mode.inspect}" unless %i[null omit].include?(mode) + return 'takes only: or except:, not both; name the readers on one of them' if only && except + return unless mode == :null && (only || except) + + ':null is the default for every reader and takes no only: or except:; ' \ + 'declare :omit on the readers to leave out' + 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 9195fe1..a835100 100644 --- a/lib/errgonomic/rails/active_record_optional.rb +++ b/lib/errgonomic/rails/active_record_optional.rb @@ -4,34 +4,52 @@ module Errgonomic module Rails # Concern to make ActiveRecord optional attributes and associations return an Option. # - # 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 sixth would be a + # The reader is the boundary and the storage stays nullable: the + # attribute, dirty tracking and the raw readers all see nil, where Rails + # already draws the line for a reader override. Rust would expect the + # Option all the way down; ActiveRecord reads the attribute in too many + # places for that. + # + # Five compromises below are where the Rust idiom gives way to + # ActiveRecord machinery, each forced by something ActiveRecord does with + # an accessor rather than chosen. 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. # # 1. None#nil? answers true, so AR internals and ordinary nil checks # treat an absent value as absent. Equality does not follow suit: # None() == nil stays false. - # 2. Some delegates persisted?, marked_for_destruction?, and touch_later - # to its record, so a Some can stand in for it during persistence. - # 3. Quoting and predicate-building prepends unwrap Options at the SQL - # boundary, so an Option can be passed to where/quote. - # 4. SomeValidator provides a presence-style validation for Option - # attributes. - # 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. + # 2. Some delegates persisted? and touch_later to its record, so a Some + # can stand in for it where ActiveRecord reads an association back + # through its public reader. + # 3. Boundaries into ActiveRecord unwrap Options: quoting and predicate + # building at the SQL boundary, attribute and singular association + # writers on assignment, and the type cast and serialization for a + # value that reaches the database without passing a writer, as + # update_all, insert_all, upsert, an attribute default and find_by do. + # 4. SomeValidator asks whether a value is there at all, where presence + # asks whether it amounts to anything: Some("") passes some: true and + # fails presence. It lifts what it is handed, so it asks the same + # question of any model, converted or not. + # 5. Where ActiveRecord's own machinery reads a value raw, it gets one. + # Validation unwraps at read_attribute_for_validation, the seam every + # EachValidator fetches an attribute through, and serialization at + # read_attribute_for_serialization, the seam every attribute in a + # payload is fetched through, so a standard validator weighs the value + # and a payload carries it rather than the wrapper. A singular + # association with nested attributes goes further and keeps its plain + # reader: nested attributes are assigned through the reader, and + # ActiveRecord asks whatever it finds there 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 - # exception. + # errgonomic_optional_except and errgonomic_serialize_none are not on the + # list: they are configuration, an escape hatch for whatever conflict + # shows up next and a choice of how an absent value is written, not + # semantic exceptions. module ActiveRecordOptional extend ActiveSupport::Concern included do + errgonomic_optional_readers reflect_on_all_associations(:belongs_to) .select { |r| r.options[:optional] } .each { |r| errgonomic_wrap_optional(r.name) } @@ -40,7 +58,67 @@ module ActiveRecordOptional .each { |r| errgonomic_wrap_optional(r.name) } end + # Every EachValidator fetches the attribute through here, so unwrapping + # once at this seam is what lets the standard validators weigh the value + # rather than the wrapper around it. + # + # @example presence weighs the value; some: asks only whether it is there + # Memo.new(title: Some(''), body: Some('')).tap(&:valid?).errors[:title] # => ["can't be blank"] + # Memo.new(title: Some(''), body: Some('')).tap(&:valid?).errors[:body] # => [] + def read_attribute_for_validation(key) + Errgonomic::Rails.unwrap_option(super) + end + + # Every attribute in a serialized payload is fetched through here, so a + # converted model's as_json, to_json and serializable_hash say what the + # unconverted one says. Rails writes an absent value as null, and so + # does serde unless a field asks otherwise, so a None does too. + # + # @example + # note = Note.create!(title: Some('The Dark Forest')) + # Note.find(note.id).as_json['title'] # => 'The Dark Forest' + # Note.find(note.id).as_json.fetch('body') # => nil + def read_attribute_for_serialization(key) + Errgonomic::Rails.unwrap_option(super) + end + + # A method named in methods: is read off the record rather than through + # the attribute seam, so a wrapped reader named there arrives wrapped. + # + # @example + # Note.new(title: Some('Wanderer')).serializable_hash(only: [], methods: :title) # => { 'title' => 'Wanderer' } + def serializable_hash(options = nil) + hash = super + Array(options.to_h[:methods]).each do |name| + key = name.to_s + hash[key] = Errgonomic::Rails.unwrap_option(hash[key]) if hash.key?(key) + end + errgonomic_omit_absent_keys(hash) + end + class_methods do + # Wrapped readers live in a module of their own, the way ActiveRecord + # keeps its attribute methods, so a model's own def of the same name + # coexists with the wrapper instead of one silently replacing the + # other. Included rather than prepended: the model's def wins, and + # its super reads the Option. + def errgonomic_optional_readers + return @errgonomic_optional_readers if defined?(@errgonomic_optional_readers) + + @errgonomic_optional_readers = const_set(:ErrgonomicOptionalReaders, Module.new) + private_constant :ErrgonomicOptionalReaders + include @errgonomic_optional_readers + @errgonomic_optional_readers + end + + # Every class gets its module before its body runs, so where a reader + # sits in the ancestor chain never depends on when the schema loads + # or where the include was written. + def inherited(subclass) + super + subclass.errgonomic_optional_readers + end + # What a model wrapped is the signal that a conversion did what it # meant to, and the columns are not wrapped until the schema loads, so # asking loads it. @@ -67,11 +145,21 @@ def errgonomic_optional_exclusions end inherited | - Array(encrypted_attributes).map(&:to_s) | Array(try(:errgonomic_optional_exceptions)).map(&:to_s) | errgonomic_nested_attribute_associations end + # A wrapped reader whose absent value the declaration in force asks + # to be left out of a payload rather than written as null. + def errgonomic_serialize_none_omit?(name) + declaration = errgonomic_serialize_none_declaration + return false unless declaration && declaration[:mode] == :omit + return declaration[:only].include?(name) if declaration[:only] + return declaration[:except].exclude?(name) if declaration[:except] + + true + end + # A model that keeps value-or-nil throughout, for whatever the # application knows about it that the concern does not. Where the # concern is included on a base class, this is how a model leaves. @@ -150,21 +238,11 @@ def errgonomic_nested_attribute_associations 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 - # after the include is the ordinary spelling, and the exclusion is read - # from ActiveRecord's own register when a reader is about to be - # wrapped, so this only has to take back a reader already wrapped. - 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_optional_names.delete(name) - remove_method(name) + errgonomic_optional_readers.remove_method(name) end end @@ -174,7 +252,7 @@ def errgonomic_wrap_optional(name) return if errgonomic_optional_exclusions.include?(name) || errgonomic_optional?(name) errgonomic_optional_names << name - class_eval <<-RUBY, __FILE__, __LINE__ + 1 + errgonomic_optional_readers.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{name} reads = Thread.current[:errgonomic_optional_reads] ||= {} key = [object_id, :#{name}] @@ -189,29 +267,56 @@ def #{name} ensure reads.delete(key) end - val.nil? ? Errgonomic::Option::None.new : Errgonomic::Option::Some.new(val) + # One layer, always: an attribute or association is never an + # optional of an optional, so an Option from beneath passes through. + val.to_option end RUBY end end + + private + + # ActiveModel reads an included association off the record, so what it + # yields is an Option. Take the record out of it, and leave an absent + # one out of the payload, where a nil association is already left out. + def serializable_add_includes(options = {}) + super do |association, records, opts| + records = Errgonomic::Rails.unwrap_option(records) + yield association, records, opts unless records.nil? + end + end + + # Deleting from the payload rather than from the attribute list is what + # keeps the caller's own only: and except: in force. A wrapped reader + # never holds Some(nil), so a nil here is the None it was declared for. + def errgonomic_omit_absent_keys(hash) + klass = self.class + return hash unless klass.errgonomic_serialize_none_declaration&.fetch(:mode) == :omit + + hash.delete_if do |key, value| + value.nil? && klass.errgonomic_optional?(key) && klass.errgonomic_serialize_none_omit?(key) + end + end end end end -# Validates that an Option attribute is Some, analogous to a presence -# validation on a plain attribute. +# Validates that an attribute is there at all, where presence asks whether it +# amounts to anything: an empty string is a value, nil and None are not. +# Lifting the value means the same question can be asked of a model the +# concern never converted. class SomeValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) - record.errors.add(attribute, 'is invalid') unless value.some? + record.errors.add(attribute, 'is invalid') unless value.to_option.some? end end module Errgonomic module Option - # Delegate ActiveRecord lifecycle checks to the wrapped record, so a Some - # can stand in for its record during persistence. + # A belongs_to declared touch: true reads the associated record back + # through the public reader after a save, then asks it to touch itself. class Some - delegate :marked_for_destruction?, to: :value delegate :persisted?, to: :value delegate :touch_later, to: :value end @@ -274,6 +379,9 @@ module Rails # Teach ActiveRecord SQL quoting to unwrap Options, quoting a None as # SQL NULL. module ActiveRecordQuoting + # @example + # ActiveRecord::Base.connection.quote(Some(1)) # => "1" + # ActiveRecord::Base.connection.quote(None()) # => "NULL" def quote(value) return super(value) unless value.is_a?(Errgonomic::Option::Any) @@ -299,8 +407,15 @@ def build(attribute, value, *args) end end - # Unwrap Options in a query condition, reaching one level into an array - # so a list of Options binds like a list of values. + # Take the value inside an Option at a boundary into ActiveRecord, and a + # None as nil, reaching one level into an array so a list of Options + # passes as a list of values. + # + # @example + # Errgonomic::Rails.unwrap_options(Some(1)) # => 1 + # Errgonomic::Rails.unwrap_options(None()) # => nil + # Errgonomic::Rails.unwrap_options([Some(1), None()]) # => [1, nil] + # Errgonomic::Rails.unwrap_options(1) # => 1 def self.unwrap_options(value) case value when Errgonomic::Option::Any @@ -311,7 +426,116 @@ def self.unwrap_options(value) value end end + + # Take the value inside an Option, and a None as nil, where the boundary + # takes one value: an attribute is a single typed field, so a collection + # that happens to hold an Option is that collection. + # + # @example + # Errgonomic::Rails.unwrap_option(Some(1)) # => 1 + # Errgonomic::Rails.unwrap_option(None()) # => nil + # Errgonomic::Rails.unwrap_option([Some(1)]) # => [Some(1)] + def self.unwrap_option(value) + value.is_a?(Errgonomic::Option::Any) ? value.unwrap_or(nil) : value + end end end ActiveRecord::PredicateBuilder.prepend(Errgonomic::Rails::ActiveRecordPredicateBuilder) + +module Errgonomic + module Rails + # A singular association writer is a setter, not a typed field, so it + # takes what a wrapped reader hands back: Some(record) assigns the record, + # None() clears the association. A Some of the wrong class still fails the + # association's own type check, naming the class inside it. + module ActiveRecordSingularAssociationWriter + def writer(value) + super(Errgonomic::Rails.unwrap_options(value)) + end + end + end +end + +ActiveRecord::Associations::SingularAssociation.prepend(Errgonomic::Rails::ActiveRecordSingularAssociationWriter) + +module Errgonomic + module Rails + # An attribute writer hands its value to a type cast that has never heard + # of an Option, and each type fails its own way: a Some is truthy and not + # one of ActiveModel's FALSE_VALUES, so a wrapped false cast to true. + # Unwrap before the attribute is built rather than inside the cast, so the + # value assigned, dirty tracking and the before-type-cast reader all agree + # on what was assigned. Every writer passes here, as do new, + # assign_attributes and update. + module ActiveModelAttributeWrite + # @example + # Note.new(pinned: Some(false)).pinned # => Some(false) + # Note.new(pinned: None()).pinned # => None() + # Note.new(title: Some('The Dark Forest')).title # => Some('The Dark Forest') + # Note.new(rank: Some(3)).rank # => Some(3) + # Note.new(due_on: Some(Date.new(2026, 7, 31))).due_on # => Some(Date.new(2026, 7, 31)) + # + # @example A wrapper never reaches the attribute behind the reader + # Note.new(pinned: Some(false)).attributes['pinned'] # => false + # Note.new(pinned: Some(false)).read_attribute_before_type_cast('pinned') # => false + def write_from_user(name, value) + super(name, Errgonomic::Rails.unwrap_option(value)) + end + end + end +end + +ActiveModel::AttributeSet.prepend(Errgonomic::Rails::ActiveModelAttributeWrite) + +module Errgonomic + module Rails + # Values that never pass an attribute writer are cast on their way to a + # bind parameter instead: update_all, insert_all and upsert each cast a + # hash of values against the column type, as does an attribute default. + # The cast is the one place they all share, so a Some casts as its inner + # value and a None as nil. + # + module ActiveModelTypeCast + # @example + # ActiveModel::Type::Boolean.new.cast(Some(false)) # => false + # ActiveModel::Type::String.new.cast(Some('The Dark Forest')) # => 'The Dark Forest' + # ActiveModel::Type::Integer.new.cast(None()) # => nil + # ActiveModel::Type::Integer.new.cast(Some(3)) # => 3 + def cast(value) + super(Errgonomic::Rails.unwrap_option(value)) + end + + # find_by binds its values straight into a cached statement rather than + # through the predicate builder, so the column type serializes what the + # caller passed. Most types reach here through their own serialize, and + # an encrypted one hands its value to the underlying type's before + # calling to_s on the result. + # + # @example + # ActiveModel::Type::String.new.serialize(Some('The Dark Forest')) # => 'The Dark Forest' + # ActiveModel::Type::String.new.serialize(None()) # => nil + def serialize(value) + super(Errgonomic::Rails.unwrap_option(value)) + end + end + + # Two of ActiveModel's type helpers read the value before Type::Value + # ever sees it: Numeric asks it for presence, which on an Option is the + # soft-deprecated unwrap, and Mutable serializes it, which an Option + # refuses. They need a module of their own, because a module already + # somewhere in a type's ancestors is not inserted into it a second time. + module ActiveModelTypeHelperCast + # @example + # ActiveModel::Type::Integer.new.cast(Some(0)) # => 0 + # ActiveRecord::Type::Json.new.cast(Some({ 'a' => 1 })) # => { 'a' => 1 } + def cast(value) + super(Errgonomic::Rails.unwrap_option(value)) + end + end + end +end + +ActiveModel::Type::Value.prepend(Errgonomic::Rails::ActiveModelTypeCast) +ActiveModel::Type::Helpers::Numeric.prepend(Errgonomic::Rails::ActiveModelTypeHelperCast) +ActiveModel::Type::Helpers::Mutable.prepend(Errgonomic::Rails::ActiveModelTypeHelperCast) diff --git a/test/rails_test.rb b/test/rails_test.rb index dd1df48..46fe392 100644 --- a/test/rails_test.rb +++ b/test/rails_test.rb @@ -3,6 +3,7 @@ require 'active_record' require 'minitest/autorun' require 'logger' +require 'stringio' require_relative '../lib/errgonomic/rails' @@ -35,6 +36,18 @@ create_table 'profiles', force: :cascade do |t| t.string :tagline t.references :author + t.references :agency + t.timestamps + end + + create_table 'agencies', force: :cascade do |t| + t.string :name, null: false + t.timestamps + end + + create_table 'citations', force: :cascade do |t| + t.string :note + t.references :subject, polymorphic: true t.timestamps end @@ -53,6 +66,32 @@ create_table 'credentials', force: :cascade do |t| t.string :access_key, limit: 255 t.string :access_secret, limit: 255 + t.string :access_token, limit: 255 + t.string :handle, limit: 255 + t.timestamps + end + + create_table 'manuscripts', force: :cascade do |t| + t.string :title + t.string :currency + t.string :status + t.string :isbn + t.integer :pages + t.boolean :accepted + t.references :author + t.timestamps + end + + create_table 'notes', force: :cascade do |t| + t.boolean :pinned + t.string :title + t.text :body + t.json :meta + t.integer :rank + t.float :score + t.decimal :price + t.date :due_on + t.datetime :read_at t.timestamps end end @@ -75,6 +114,10 @@ class Author < ActiveRecord::Base class Profile < ActiveRecord::Base belongs_to :author + belongs_to :agency +end + +class Agency < ActiveRecord::Base end class Award < ActiveRecord::Base @@ -89,6 +132,23 @@ class Publisher < ActiveRecord::Base has_one :profile, required: true, foreign_key: :author_id end +# A polymorphic belongs_to takes the class it points at from the record +# assigned, so the writer has to reach the record inside the Option before +# either key is written. +class Citation < ActiveRecord::Base + include Errgonomic::Rails::ActiveRecordOptional + belongs_to :subject, polymorphic: true, optional: true +end + +# A has_one :through assigns by creating or destroying the join record and +# reads the assigned record's own key to do it. +class Contributor < ActiveRecord::Base + self.table_name = 'authors' + include Errgonomic::Rails::ActiveRecordOptional + has_one :profile, foreign_key: :author_id, dependent: :destroy + has_one :agency, through: :profile +end + class Book < ActiveRecord::Base has_many :reviews has_many :reviewers, through: :reviews, source: :user @@ -120,11 +180,14 @@ 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. +# Encryption surrounds an attribute with machinery of its own, and declares +# itself after the concern is included, as applications write it. A +# deterministic attribute is queryable, and downcase: normalizes it on write. class Credential < ActiveRecord::Base include Errgonomic::Rails::ActiveRecordOptional encrypts :access_secret + encrypts :access_token, deterministic: true + encrypts :handle, deterministic: true, downcase: true end # Nested attributes are assigned through the public reader, and ActiveRecord @@ -169,6 +232,28 @@ class OptedOutBook < ActiveRecord::Base include Errgonomic::Rails::ActiveRecordOptional end +# touch: and dependent: reach the associated record after a save or a +# destroy, and a wrapped reader is what they find it through. +class TouchingBook < ActiveRecord::Base + self.table_name = 'books' + include Errgonomic::Rails::ActiveRecordOptional + belongs_to :author, optional: true, touch: true +end + +class DependentBook < ActiveRecord::Base + self.table_name = 'books' + include Errgonomic::Rails::ActiveRecordOptional + belongs_to :author, optional: true, dependent: :destroy +end + +# An autosaved has_one is destroyed with its parent once it is marked, along +# a path that reads the association's own target rather than the reader. +class CuratedAuthor < ActiveRecord::Base + self.table_name = 'authors' + include Errgonomic::Rails::ActiveRecordOptional + has_one :profile, foreign_key: :author_id, autosave: true +end + # A subclass has its own schema state, so it reaches the wrapping seam a # second time for columns its parent already wrapped. class Novel < Book; end @@ -210,6 +295,235 @@ class VendorLedger < ActiveRecord::Base self.table_name = 'magazines' end +# A model's own reader has to survive the wrapping and reach the Option +# through super. These two differ only in where the defs sit relative to the +# include and to the association macro. +class PrefacedBook < ActiveRecord::Base + self.table_name = 'books' + + def isbn + super.or_else { Some('unassigned') } + end + + def author + super.map(&:name) + end + + belongs_to :author, optional: true + include Errgonomic::Rails::ActiveRecordOptional +end + +class AnnotatedBook < ActiveRecord::Base + self.table_name = 'books' + include Errgonomic::Rails::ActiveRecordOptional + belongs_to :author, optional: true + + def isbn + super.or_else { Some('unassigned') } + end + + def author + super.map(&:name) + end +end + +# A has_one composes the same way, and a def that never calls super owns +# its value outright. +class AnnotatedAuthor < ActiveRecord::Base + self.table_name = 'authors' + include Errgonomic::Rails::ActiveRecordOptional + has_one :profile, foreign_key: :author_id + + def profile + super.map(&:tagline) + end + + def bio + 'undisclosed' + end +end + +# Touching the schema from the class body generates the column wrappers +# before the override below is read. +class EagerlyLoadedBook < ActiveRecord::Base + self.table_name = 'books' + include Errgonomic::Rails::ActiveRecordOptional + load_schema + + def isbn + super.or_else { Some('unassigned') } + end +end + +# A model below the base class has no include of its own to sit above or +# below, and overrides a wrapped reader the same way. +class Broadsheet < HouseRecord + self.table_name = 'magazines' + + def issn + super.or_else { Some('unregistered') } + end +end + +# A layer beneath the wrapper may hand back an Option of its own. +module TrimmedIsbn + def isbn + super.to_option.map(&:strip) + end +end + +class TrimmedBook < ActiveRecord::Base + self.table_name = 'books' + include TrimmedIsbn + include Errgonomic::Rails::ActiveRecordOptional +end + +# One nullable column per type an attribute writer has to cast, so what an +# Option stores can be compared against what its inner value stores. +class Note < ActiveRecord::Base + include Errgonomic::Rails::ActiveRecordOptional +end + +# A declared default is cast on its way into a new record rather than +# assigned through a writer. +class DefaultedNote < ActiveRecord::Base + self.table_name = 'notes' + attribute :pinned, :boolean, default: Some(false) + attribute :rank, :integer, default: Some(0) + attribute :meta, :json, default: Some({ 'shelf' => 'new' }) + attribute :title, :string, default: None() +end + +# A converted model carrying one validator family per wrapped column, so +# each is asked what it makes of a Some and of a None. +class Manuscript < ActiveRecord::Base + include Errgonomic::Rails::ActiveRecordOptional + validates :currency, inclusion: { in: %w[USD EUR] }, allow_nil: true + validates :status, exclusion: { in: %w[withdrawn] }, allow_nil: true + validates :isbn, length: { maximum: 13 }, format: { with: /\A[0-9]*\z/ }, allow_nil: true + validates :pages, numericality: { greater_than: 0 }, allow_nil: true +end + +# Validators that weigh a value in other ways: absence asks whether it +# amounts to nothing, acceptance matches it against a literal, comparison +# orders it. +class RetractedManuscript < ActiveRecord::Base + self.table_name = 'manuscripts' + include Errgonomic::Rails::ActiveRecordOptional + validates :status, absence: true + validates :accepted, acceptance: true + validates :pages, comparison: { greater_than: 0 }, allow_nil: true +end + +# presence and some: ask different questions of the same attribute: whether +# the value amounts to anything, and whether it is there at all. +class SubmittedManuscript < ActiveRecord::Base + self.table_name = 'manuscripts' + include Errgonomic::Rails::ActiveRecordOptional + validates :title, presence: true, some: true +end + +# some: is available on any model, so it has to answer for a plain value too. +class PlainManuscript < ActiveRecord::Base + self.table_name = 'manuscripts' + validates :title, some: true +end + +# A belongs_to without optional: true is required, and Rails validates it +# with a presence validation of its own. +class AttributedManuscript < ActiveRecord::Base + self.table_name = 'manuscripts' + self.belongs_to_required_by_default = true + include Errgonomic::Rails::ActiveRecordOptional + belongs_to :author +end + +# A wrapped has_one validated for presence, the association side of the same +# seam. +class ProfiledAuthor < ActiveRecord::Base + self.table_name = 'authors' + include Errgonomic::Rails::ActiveRecordOptional + has_one :profile, foreign_key: :author_id + validates :profile, presence: true +end + +# Serialization twins over one row: what a conversion changes about a payload +# is whatever these two disagree about. +class SerializedBook < ActiveRecord::Base + self.table_name = 'books' + include Errgonomic::Rails::ActiveRecordOptional + belongs_to :author, optional: true, class_name: 'SerializedAuthor' + + def display_isbn + isbn.unwrap_or('unassigned') + end +end + +class SerializedAuthor < ActiveRecord::Base + self.table_name = 'authors' + include Errgonomic::Rails::ActiveRecordOptional + has_many :books, class_name: 'SerializedBook', foreign_key: :author_id +end + +class PlainBook < ActiveRecord::Base + self.table_name = 'books' + belongs_to :author, optional: true, class_name: 'PlainAuthor' + + def display_isbn + isbn || 'unassigned' + end +end + +class PlainAuthor < ActiveRecord::Base + self.table_name = 'authors' + has_many :books, class_name: 'PlainBook', foreign_key: :author_id +end + +# Omission is the opt-in, and an application's own base class is where it is +# declared once for every model below. +class TerseRecord < ActiveRecord::Base + self.abstract_class = true + include Errgonomic::Rails::ActiveRecordOptional + errgonomic_serialize_none :omit +end + +class TerseBook < TerseRecord + self.table_name = 'books' + belongs_to :author, optional: true, class_name: 'SerializedAuthor' + + # A plain method is not a reader a declaration governs, whatever it answers. + def blurb + nil + end +end + +# A model below the declaration names the other mode, and gets what a model +# with no declaration anywhere gets. +class VerboseBook < TerseRecord + self.table_name = 'books' + errgonomic_serialize_none :null +end + +# only: and except: scope the mode to named readers, and a reader outside the +# scope keeps the default. +class SelectiveBook < TerseRecord + self.table_name = 'books' + errgonomic_serialize_none :omit, only: %i[isbn] +end + +class ExceptedBook < TerseRecord + self.table_name = 'books' + errgonomic_serialize_none :omit, except: %i[isbn] +end + +# Rails convention puts a concern at the top of a model, but configuration +# reads as well above the include as below it, so it has to work either way. +class EarlyTerseBook < ActiveRecord::Base + self.table_name = 'books' + errgonomic_serialize_none :omit + include Errgonomic::Rails::ActiveRecordOptional +end + class BugTest < Minitest::Test def test_optional_attributes author = Author.create!(name: 'Cixin Liu') @@ -260,6 +574,16 @@ def test_where_with_a_some_matches_the_row # A None reads as absent, which for a hash condition means IS NULL rather # than an = NULL that can never match. + # find_by binds its values outside the predicate builder, so an Option + # reaches the column type rather than the quoting seam. A regression guard: + # both of these hold today, and the serialize boundary has to keep them. + def test_find_by_takes_an_option_on_a_string_or_integer_column + note = Note.create!(title: 'Ball Lightning', rank: 987) + + assert_equal note.id, Note.find_by(title: Some('Ball Lightning')).id + assert_equal note.id, Note.find_by(rank: Some(987)).id + end + def test_where_with_a_none_asks_for_null unshelved = Book.create!(title: 'Ball Lightning') @@ -282,6 +606,7 @@ def test_where_with_an_array_of_options # Hash and Array serialization recurses with as_json, never to_json, so # the refusal has to sit on as_json to survive nesting. def test_nested_options_and_results_refuse_to_serialize + assert_raises(Errgonomic::SerializeError) { Some(5).to_json } assert_raises(Errgonomic::SerializeError) { Some(5).as_json } assert_raises(Errgonomic::SerializeError) { { a: Some(5) }.to_json } assert_raises(Errgonomic::SerializeError) { { a: None() }.to_json } @@ -291,6 +616,174 @@ def test_nested_options_and_results_refuse_to_serialize assert_raises(Errgonomic::SerializeError) { [Ok(5)].to_json } end + # A conversion changes what a reader returns, not what a record serializes: + # the payload has to match the model that was never converted, key for key. + def test_a_converted_record_serializes_as_the_unconverted_one_does + author = Author.create!(name: 'Cixin Liu') + row = Book.create!(title: 'The Dark Forest', isbn: '9780765377104', author_id: author.id) + + assert_equal PlainBook.find(row.id).serializable_hash, SerializedBook.find(row.id).serializable_hash + assert_equal PlainBook.find(row.id).as_json, SerializedBook.find(row.id).as_json + assert_equal PlainBook.find(row.id).to_json, SerializedBook.find(row.id).to_json + end + + # Rails writes an absent value as null, and so does serde unless a field + # asks otherwise, so a None does too and no declaration is needed to say so. + def test_a_none_serializes_as_null + row = Book.create!(title: 'Supernova Era') + + assert_equal PlainBook.find(row.id).as_json, SerializedBook.find(row.id).as_json + assert_nil SerializedBook.find(row.id).as_json['isbn'] + assert_includes SerializedBook.find(row.id).to_json, '"isbn":null' + end + + # ActiveSupport recurses through as_json, so a record inside an ordinary + # payload serializes the way the record itself does. + def test_a_converted_record_serializes_inside_a_payload + row = Book.create!(title: 'The Dark Forest') + + assert_equal({ book: PlainBook.find(row.id) }.to_json, { book: SerializedBook.find(row.id) }.to_json) + assert_equal [PlainBook.find(row.id)].to_json, [SerializedBook.find(row.id)].to_json + end + + # An included association is fetched through its reader, so a Some + # serializes as the record's own hash and a None leaves the key out, which + # is what a nil association does on a model that was never converted. + def test_an_included_association_serializes_through_the_option + author = Author.create!(name: 'Cixin Liu') + shelved = Book.create!(title: 'The Dark Forest', author_id: author.id) + unshelved = Book.create!(title: 'Supernova Era') + + assert_equal PlainBook.find(shelved.id).as_json(include: :author), + SerializedBook.find(shelved.id).as_json(include: :author) + assert_equal 'Cixin Liu', SerializedBook.find(shelved.id).as_json(include: :author).dig('author', 'name') + + assert_equal PlainBook.find(unshelved.id).as_json(include: :author), + SerializedBook.find(unshelved.id).as_json(include: :author) + refute_includes SerializedBook.find(unshelved.id).as_json(include: :author), 'author' + end + + # A collection is never an Option, so an included has_many is untouched. + def test_an_included_has_many_serializes_untouched + author = Author.create!(name: 'Cixin Liu') + Book.create!(title: 'The Dark Forest', author_id: author.id) + + assert_equal PlainAuthor.find(author.id).as_json(include: :books), + SerializedAuthor.find(author.id).as_json(include: :books) + titles = SerializedAuthor.find(author.id).as_json(include: :books)['books'].map { |book| book['title'] } + + assert_equal ['The Dark Forest'], titles + end + + # methods: reads its value straight off the record rather than through the + # attribute seam, so a wrapped reader named there unwraps one layer and a + # method that hands back a plain value is left alone. + def test_a_serialized_method_unwraps_one_layer + row = Book.create!(title: 'The Dark Forest', isbn: '9780765377104') + + assert_equal PlainBook.find(row.id).as_json(methods: :display_isbn), + SerializedBook.find(row.id).as_json(methods: :display_isbn) + assert_equal '9780765377104', SerializedBook.find(row.id).as_json(methods: :isbn)['isbn'] + assert_equal '9780765377104', SerializedBook.find(row.id).serializable_hash(methods: :isbn)['isbn'] + assert_nil SerializedBook.create!(title: 'Supernova Era').serializable_hash(methods: :isbn)['isbn'] + end + + # Omission is the opt-in, and it drops only the keys the record has no + # value for: a Some is a value like any other. + def test_omit_drops_the_keys_a_record_has_no_value_for + absent = TerseBook.find(Book.create!(title: 'Supernova Era').id).as_json + present = TerseBook.find(Book.create!(title: 'The Dark Forest', isbn: '9780765377104').id).as_json + + refute_includes absent, 'isbn' + refute_includes absent, 'published_at' + assert_equal 'Supernova Era', absent['title'] + + assert_equal '9780765377104', present['isbn'] + refute_includes present, 'published_at' + end + + # A declaration above the include says the same thing as one below it. + def test_serialize_none_is_declared_on_either_side_of_the_include + hash = EarlyTerseBook.find(Book.create!(title: 'Supernova Era').id).as_json + + refute_includes hash, 'isbn' + assert_equal 'Supernova Era', hash['title'] + end + + # Omission governs by reader name wherever the key came from, so a methods: + # entry naming a wrapped reader goes the way the reader does. + def test_omit_governs_a_method_entry_by_reader_name + hash = TerseBook.find(Book.create!(title: 'Supernova Era').id).as_json(methods: %i[isbn blurb]) + + refute_includes hash, 'isbn' + assert_nil hash.fetch('blurb') + end + + # A model below the declaration says :null and is back to the default. + def test_a_subclass_declares_its_way_back_to_null + row = Book.create!(title: 'Supernova Era') + + assert_equal PlainBook.find(row.id).as_json, VerboseBook.find(row.id).as_json + end + + # A scoped declaration replaces the one it inherits, so a reader it does + # not name keeps the default rather than the mode above it. + def test_omit_scoped_to_named_readers + row = Book.create!(title: 'Supernova Era') + only = SelectiveBook.find(row.id).as_json + except = ExceptedBook.find(row.id).as_json + + refute_includes only, 'isbn' + assert_nil only.fetch('published_at') + + assert_nil except.fetch('isbn') + refute_includes except, 'published_at' + end + + # An absent association is left out of a payload either way, and a present + # one is its record's hash either way. + def test_omit_leaves_an_absent_association_out + author = Author.create!(name: 'Cixin Liu') + shelved = Book.create!(title: 'The Dark Forest', author_id: author.id) + unshelved = Book.create!(title: 'Supernova Era') + + refute_includes TerseBook.find(unshelved.id).as_json(include: :author), 'author' + assert_equal 'Cixin Liu', TerseBook.find(shelved.id).as_json(include: :author).dig('author', 'name') + end + + # A mode the concern does not know would be a silent no-op, so it is + # refused where it is written. + def test_an_unknown_serialize_none_mode_is_refused + error = assert_raises(ArgumentError) { declare_serialize_none(:skip) } + + assert_match(/:null or :omit/, error.message) + end + + # Two scopes in one declaration cannot both be the set it applies to. + def test_only_and_except_together_are_refused + error = assert_raises(ArgumentError) { declare_serialize_none(:omit, only: %i[isbn], except: %i[genre_id]) } + + assert_match(/not both/, error.message) + end + + # A declaration replaces the one it inherits, so a scoped :null asks for + # the default on the readers it names and the default on the rest, which + # is no request at all. + def test_a_scoped_null_is_refused + only = assert_raises(ArgumentError) { declare_serialize_none(:null, only: %i[isbn]) } + except = assert_raises(ArgumentError) { declare_serialize_none(:null, except: %i[isbn]) } + + assert_match(/declare :omit/, only.message) + assert_match(/declare :omit/, except.message) + end + + # A model that keeps value-or-nil throughout has nothing to unwrap. + def test_an_opted_out_model_serializes_unchanged + row = Zine.create!(title: 'Wired', issn: '1059-1028') + + assert_equal VendorLedger.find(row.id).as_json, PlainZine.find(row.id).as_json + end + # to_option lifts a value that may be nil. An Option is already lifted, and # a second lift nests invisibly: Some(Some(x)) still answers some?, so the # mistake surfaces far from where it was made. @@ -338,18 +831,46 @@ def test_private_delegate_optional assert_equal 'Fiction', scifi.send(:parent_name).unwrap! end - def test_encrypted_attributes_are_left_unwrapped + # An encrypted attribute is a nullable column like any other, and the + # ciphertext never reaches the reader. + def test_an_encrypted_attribute_round_trips_as_an_option credential = Credential.create!(access_key: 'abc123', access_secret: 'shhh') - assert_equal 'shhh', credential.access_secret + assert_equal 'shhh', credential.reload.access_secret.unwrap! + + credential.access_secret = Some('rotated') + credential.save! + + assert_equal 'rotated', credential.reload.access_secret.unwrap! assert_equal 'abc123', credential.access_key.unwrap! end - def test_encrypted_attributes_may_be_absent + def test_an_absent_encrypted_attribute_reads_as_none credential = Credential.create!(access_key: 'abc123') - assert_nil credential.access_secret - assert credential.reload.access_secret.nil? + assert credential.access_secret.none? + assert credential.reload.access_secret.none? + end + + # A deterministic attribute encrypts to a stable ciphertext, so a query + # against it has to reach the same value the writer stored. + def test_a_deterministic_encrypted_attribute_is_queryable + credential = Credential.create!(access_key: 'abc123', access_token: 'tok-42') + + assert_equal 'tok-42', credential.reload.access_token.unwrap! + assert_equal credential.id, Credential.find_by(access_token: 'tok-42').id + assert_equal credential.id, Credential.where(access_token: Some('tok-42')).first.id + assert_equal credential.id, Credential.find_by(access_token: Some('tok-42')).id + assert_nil Credential.find_by(access_token: 'tok-43') + end + + # downcase: normalizes on the way in, so what was written mixed-case reads + # back and matches lowercase. + def test_a_downcased_encrypted_attribute_normalizes_on_write + credential = Credential.create!(access_key: 'abc123', handle: Some('MixedCase')) + + assert_equal 'mixedcase', credential.reload.handle.unwrap! + assert_equal credential.id, Credential.find_by(handle: 'MIXEDCASE').id end # ActiveRecord loads a model's schema on first use, not at definition, so a @@ -416,6 +937,362 @@ def test_has_one_writes_and_dependent_destroy_still_work assert_equal 0, Profile.where(author_id: author.id).count end + # touch: reads the associated record back through the public reader and + # asks it to touch itself. + def test_a_touching_belongs_to_reaches_the_record_inside_the_option + author = Author.create!(name: 'Cixin Liu') + Author.where(id: author.id).update_all(updated_at: Time.at(0)) + + TouchingBook.create!(title: 'The Dark Forest', author: Some(author)) + + assert_operator author.reload.updated_at, :>, Time.at(0) + end + + def test_a_dependent_belongs_to_destroys_the_record_inside_the_option + author = Author.create!(name: 'Cixin Liu') + book = DependentBook.create!(title: 'The Dark Forest', author: Some(author)) + + book.destroy! + + assert_nil Author.find_by(id: author.id) + end + + # Marking the record a wrapped reader hands back still reaches the save. + def test_an_autosaved_has_one_marked_for_destruction_is_destroyed + author = CuratedAuthor.create!(name: 'Cixin Liu') + author.create_profile!(tagline: 'writes sci-fi') + author.profile.unwrap!.mark_for_destruction + author.save! + + assert_equal 0, Profile.where(author_id: author.id).count + end + + # A wrapped reader on one record is the ordinary source for a writer on + # another, so the writer takes the Option the reader hands back. + def test_belongs_to_writer_takes_an_option + author = Author.create!(name: 'Cixin Liu') + book = Book.create!(title: 'The Dark Forest') + + book.author = Some(author) + book.save! + + assert_equal author.id, book.reload.author_id.unwrap! + + book.author = None() + book.save! + + assert book.reload.author.none? + end + + # Unwrapping is not a loosening of the type check: the wrong class inside a + # Some is still the wrong class, and the message says which one arrived. + def test_belongs_to_writer_rejects_a_some_of_the_wrong_class + book = Book.create!(title: 'The Dark Forest') + profile = Profile.create!(tagline: 'writes sci-fi') + + error = assert_raises(ActiveRecord::AssociationTypeMismatch) { book.author = Some(profile) } + + assert_match(/Author/, error.message) + assert_match(/Profile/, error.message) + refute_match(/Some/, error.message) + end + + def test_has_one_writer_takes_an_option + author = Author.create!(name: 'Cixin Liu') + + author.profile = Some(Profile.new(tagline: 'writes sci-fi')) + + assert_equal 'writes sci-fi', author.reload.profile.unwrap!.tagline + + author.profile = None() + + assert author.reload.profile.none? + end + + def test_has_one_writer_rejects_a_some_of_the_wrong_class + author = Author.create!(name: 'Cixin Liu') + + error = assert_raises(ActiveRecord::AssociationTypeMismatch) { author.profile = Some(author) } + + assert_match(/Profile/, error.message) + refute_match(/Some/, error.message) + end + + # A polymorphic writer takes two keys from one record, so the record has to + # be in hand before either is written. + def test_polymorphic_belongs_to_writer_takes_an_option + author = Author.create!(name: 'Cixin Liu') + citation = Citation.create!(note: 'foreword') + + citation.subject = Some(author) + citation.save! + + assert_equal 'Author', citation.reload.subject_type.unwrap! + assert_equal author.id, citation.subject_id.unwrap! + + citation.subject = None() + citation.save! + + assert citation.reload.subject.none? + assert citation.subject_type.none? + assert citation.subject_id.none? + end + + # A has_one :through assigns by writing the join row, and reads the + # assigned record's key to do it. + def test_has_one_through_writer_takes_an_option + contributor = Contributor.create!(name: 'Cixin Liu') + agency = Agency.create!(name: 'Tor') + + contributor.agency = Some(agency) + + assert_equal agency, contributor.reload.agency.unwrap! + assert_equal agency.id, Profile.find_by(author_id: contributor.id).agency_id + + contributor.agency = None() + + assert contributor.reload.agency.none? + assert_nil Profile.find_by(author_id: contributor.id) + end + + def test_has_one_through_writer_rejects_a_some_of_the_wrong_class + contributor = Contributor.create!(name: 'Cixin Liu') + book = Book.create!(title: 'The Dark Forest') + + error = assert_raises(ActiveRecord::AssociationTypeMismatch) { contributor.agency = Some(book) } + + assert_match(/Agency/, error.message) + assert_match(/Book/, error.message) + refute_match(/Some/, error.message) + assert_nil Profile.find_by(author_id: contributor.id) + end + + # The idiom a conversion runs into everywhere: one record's association + # copied straight onto another's, through the wrapped reader. + def test_an_association_copied_from_a_wrapped_reader_round_trips + author = Author.create!(name: 'Cixin Liu') + book = Book.create!(title: 'The Dark Forest', author_id: author.id) + other_book = Book.create!(title: 'Death\'s End') + + other_book.author = book.author + other_book.save! + + assert_equal author, other_book.reload.author.unwrap! + end + + # An Option assigned through an attribute writer stores what its inner + # value stores. Boolean is the type with the sharpest edge: a Some is + # truthy and is not one of ActiveModel's FALSE_VALUES, so a cast that saw + # the wrapper would read a wrapped false as true. + def test_a_boolean_writer_takes_an_option + note = Note.create!(pinned: Some(false)) + + assert_equal false, note.reload.pinned.unwrap! + + note.update!(pinned: Some(true)) + + assert_equal true, note.reload.pinned.unwrap! + + note.update!(pinned: None()) + + assert note.reload.pinned.none? + end + + def test_a_string_writer_takes_an_option + note = Note.create!(title: Some('The Dark Forest'), body: Some('a novel')) + + assert_equal 'The Dark Forest', note.reload.title.unwrap! + assert_equal 'a novel', note.body.unwrap! + + note.update!(title: Some(''), body: None()) + + assert_equal '', note.reload.title.unwrap! + assert note.body.none? + end + + def test_a_json_writer_takes_an_option + note = Note.create!(meta: Some({ 'isbn' => '9780765377104' })) + + assert_equal({ 'isbn' => '9780765377104' }, note.reload.meta.unwrap!) + + note.update!(meta: None()) + + assert note.reload.meta.none? + end + + # A numeric writer reaches its value without Option#presence, which is + # soft-deprecated and nudges on stderr on every call. + def test_a_numeric_writer_takes_an_option_without_a_deprecation_nudge + note = nil + nudges = capture_stderr do + note = Note.create!(rank: Some(0), score: Some(0.0), price: Some(0)) + end + + assert_equal '', nudges + assert_equal 0, note.reload.rank.unwrap! + assert_in_delta 0.0, note.score.unwrap! + assert_equal 0, note.price.unwrap! + + note.update!(rank: Some(3), score: Some(1.5), price: Some(2.25)) + + assert_equal 3, note.reload.rank.unwrap! + assert_in_delta 1.5, note.score.unwrap! + assert_equal BigDecimal('2.25'), note.price.unwrap! + + note.update!(rank: None(), score: None(), price: None()) + + assert note.reload.rank.none? + assert note.score.none? + assert note.price.none? + end + + # A date cast hands an object it does not recognize back unchanged, so the + # attribute behind the reader is where a surviving wrapper would show. + def test_a_date_writer_takes_an_option + due_on = Date.new(2026, 7, 31) + read_at = Time.utc(2026, 7, 31, 12, 0, 0) + note = Note.create!(due_on: Some(due_on), read_at: Some(read_at)) + + assert_equal due_on, note.attributes['due_on'] + assert_equal read_at, note.attributes['read_at'] + assert_equal due_on, note.reload.due_on.unwrap! + assert_equal read_at, note.read_at.unwrap! + + note.update!(due_on: None(), read_at: None()) + + assert note.reload.due_on.none? + assert note.read_at.none? + end + + # Dirty tracking, the before-type-cast reader and attributes read the + # attribute rather than the reader, so an Option must not survive as far as + # the attribute. + def test_an_option_assignment_leaves_raw_values_behind_the_reader + note = Note.create! + note.title = Some("Death's End") + note.pinned = Some(false) + + assert_equal [nil, "Death's End"], note.changes['title'] + assert_equal [nil, false], note.changes['pinned'] + assert_equal "Death's End", note.read_attribute_before_type_cast('title') + assert_equal false, note.read_attribute_before_type_cast('pinned') + assert_equal "Death's End", note.attributes['title'] + + note.save! + + assert_equal [nil, "Death's End"], note.saved_changes['title'] + end + + # A wrapped reader hands its value straight to another record's writer, + # which is the copy idiom a conversion leans on. + def test_an_attribute_copied_from_a_wrapped_reader_round_trips + note = Note.create!(title: 'The Dark Forest', body: 'a novel', rank: 3, due_on: Date.new(2026, 7, 31)) + other = Note.create! + + other.title = note.title + other.rank = note.rank + other.due_on = note.due_on + other.assign_attributes(body: note.body) + other.save! + + assert_equal 'The Dark Forest', other.reload.title.unwrap! + assert_equal 'a novel', other.body.unwrap! + assert_equal 3, other.rank.unwrap! + assert_equal Date.new(2026, 7, 31), other.due_on.unwrap! + end + + # update_all writes through the bind path rather than an attribute writer. + def test_update_all_takes_an_option + note = Note.create!(title: 'Supernova Era') + + nudges = capture_stderr do + Note.where(id: note.id).update_all( + title: Some("Death's End"), pinned: Some(false), meta: Some({ 'isbn' => '9780765377104' }), + rank: Some(0), score: Some(1.5), price: Some(2.25) + ) + end + + assert_equal '', nudges + assert_equal "Death's End", note.reload.title.unwrap! + assert_equal false, note.pinned.unwrap! + assert_equal({ 'isbn' => '9780765377104' }, note.meta.unwrap!) + assert_equal 0, note.rank.unwrap! + assert_in_delta 1.5, note.score.unwrap! + assert_equal BigDecimal('2.25'), note.price.unwrap! + + Note.where(id: note.id).update_all(title: None(), rank: None(), meta: None()) + + assert note.reload.title.none? + assert note.rank.none? + assert note.meta.none? + end + + def test_insert_all_takes_an_option + nudges = capture_stderr do + Note.insert_all([{ title: Some('Supernova Era'), pinned: Some(false), meta: Some(%w[a b]), + rank: Some(3), score: Some(1.5), price: Some(2.25), + created_at: Time.now, updated_at: Time.now }]) + end + note = Note.order(:id).last + + assert_equal '', nudges + assert_equal 'Supernova Era', note.title.unwrap! + assert_equal false, note.pinned.unwrap! + assert_equal %w[a b], note.meta.unwrap! + assert_equal 3, note.rank.unwrap! + assert_in_delta 1.5, note.score.unwrap! + assert_equal BigDecimal('2.25'), note.price.unwrap! + end + + def test_upsert_takes_an_option + note = Note.create!(title: 'Supernova Era') + + nudges = capture_stderr do + Note.upsert({ id: note.id, title: Some("Death's End"), meta: Some({ 'isbn' => '978' }), + rank: Some(0), score: Some(1.5), price: Some(2.25), + created_at: Time.now, updated_at: Time.now }) + end + + assert_equal '', nudges + assert_equal "Death's End", note.reload.title.unwrap! + assert_equal({ 'isbn' => '978' }, note.meta.unwrap!) + assert_equal 0, note.rank.unwrap! + assert_in_delta 1.5, note.score.unwrap! + assert_equal BigDecimal('2.25'), note.price.unwrap! + end + + # An attribute takes one value, and an Option of one is that value. An + # Option inside a collection is a different shape, and stays where it is. + def test_only_a_top_level_option_is_unwrapped_on_assignment + note = Note.create!(meta: Some([1, 2])) + + assert_equal [1, 2], note.reload.meta.unwrap! + + assert_raises(Errgonomic::SerializeError) { note.update!(meta: [Some(1), 2]) } + end + + # A default is cast on its way into a new record, without passing a writer. + def test_an_attribute_default_takes_an_option + note = nil + nudges = capture_stderr { note = DefaultedNote.new } + + assert_equal '', nudges + assert_equal false, note.pinned + assert_equal 0, note.rank + assert_equal({ 'shelf' => 'new' }, note.meta) + assert_nil note.title + end + + # The predicate builder already unwraps a hash condition; unwrapping on + # assignment must leave that alone. + def test_where_still_matches_an_option_condition + note = Note.create!(title: 'Supernova Era', pinned: false) + + assert_equal [note], Note.where(id: note.id, pinned: Some(false)).to_a + assert_empty Note.where(id: note.id, pinned: Some(true)) + assert_equal [note], Note.where(id: note.id, read_at: None()).to_a + 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. @@ -524,8 +1401,242 @@ def test_errgonomic_optional_except_skips_named_attributes assert_equal 'shhh', credential.access_secret.unwrap! end + # A wrapped reader and a model's own def of the same name must coexist: + # the model's def wins and reaches the wrapper through super, whether it + # is written above the include or below it. + def test_a_column_reader_defined_before_the_include_composes_with_the_wrapper + assert_equal Some('unassigned'), PrefacedBook.create!(title: 'Supernova Era').isbn + assert_equal Some('9780765377104'), PrefacedBook.create!(title: 'Death\'s End', isbn: '9780765377104').isbn + end + + def test_a_column_reader_defined_after_the_include_composes_with_the_wrapper + assert_equal Some('unassigned'), AnnotatedBook.create!(title: 'Supernova Era').isbn + assert_equal Some('9780765377104'), AnnotatedBook.create!(title: 'Death\'s End', isbn: '9780765377104').isbn + end + + # An association reader written above its macro composes with the wrapper + # the same way one written below it does. + def test_an_association_reader_defined_before_the_macro_composes_with_the_wrapper + author = Author.create!(name: 'Cixin Liu') + + assert_equal 'Cixin Liu', PrefacedBook.create!(title: 'The Dark Forest', author_id: author.id).author.unwrap! + assert PrefacedBook.create!(title: 'Supernova Era').author.none? + end + + def test_an_association_reader_defined_after_the_macro_composes_with_the_wrapper + author = Author.create!(name: 'Cixin Liu') + + assert_equal 'Cixin Liu', AnnotatedBook.create!(title: 'The Dark Forest', author_id: author.id).author.unwrap! + assert AnnotatedBook.create!(title: 'Supernova Era').author.none? + end + + def test_a_has_one_override_composes_with_the_wrapper + author = AnnotatedAuthor.create!(name: 'Cixin Liu') + + assert author.profile.none? + + Profile.create!(author_id: author.id, tagline: 'writes sci-fi') + + assert_equal 'writes sci-fi', author.reload.profile.unwrap! + end + + # A def that never calls super owns its return value: the wrapper is + # still installed beneath it, and nothing reaches it. + def test_a_reader_that_declines_to_call_super_owns_its_value + assert_equal 'undisclosed', AnnotatedAuthor.create!(name: 'Cixin Liu', bio: 'writes sci-fi').bio + end + + # Whether the class body has already touched the schema decides when the + # column wrappers are generated, and an override composes with them either + # way. + def test_an_override_is_unaffected_by_when_the_schema_loads + assert_equal Some('unassigned'), EagerlyLoadedBook.create!(title: 'Supernova Era').isbn + assert_equal Some('9780765377104'), EagerlyLoadedBook.create!(title: 'Death\'s End', isbn: '9780765377104').isbn + end + + def test_a_model_below_the_base_class_can_override_a_wrapped_reader + assert_equal Some('unregistered'), Broadsheet.create!(title: 'Nature').issn + assert_equal Some('1937-7843'), Broadsheet.create!(title: 'Clarkesworld', issn: '1937-7843').issn + end + + # An override does not take a reader out of the wrapped set: what a + # conversion touched is still what the model reports. + def test_an_overridden_reader_is_still_reported_as_wrapped + assert_includes AnnotatedBook.errgonomic_optionals, 'isbn' + assert_includes AnnotatedBook.errgonomic_optionals, 'author' + assert_includes AnnotatedAuthor.errgonomic_optionals, 'bio' + end + + # An attribute is never an optional of an optional, so a value that + # arrives from beneath the wrapper already lifted passes through as it is. + def test_the_wrapper_lifts_a_value_exactly_one_layer + book = TrimmedBook.create!(title: "Death's End", isbn: ' 9780765377104 ') + + assert_equal '9780765377104', book.isbn.unwrap! + assert TrimmedBook.create!(title: 'Supernova Era').isbn.none? + end + + # The wrapper and the override own different rungs of the ancestor chain, + # which is what lets super reach one from the other. + def test_a_wrapper_and_an_override_own_different_rungs + # A column reader exists once the schema has loaded, and asking which + # readers were wrapped is what loads it. + Book.errgonomic_optionals + + assert_equal AnnotatedBook, AnnotatedBook.instance_method(:isbn).owner + assert_equal Book.errgonomic_optional_readers, Book.instance_method(:isbn).owner + end + + # A validator compares against the value, not the wrapper: an Option is + # never a member of the list a model spells out. + def test_inclusion_validates_the_value_inside_a_some + assert_predicate Manuscript.new(currency: Some('USD')), :valid? + refute_predicate Manuscript.new(currency: Some('GBP')), :valid? + assert_predicate Manuscript.new(currency: None()), :valid? + end + + def test_exclusion_validates_the_value_inside_a_some + assert_predicate Manuscript.new(status: Some('draft')), :valid? + refute_predicate Manuscript.new(status: Some('withdrawn')), :valid? + assert_predicate Manuscript.new(status: None()), :valid? + end + + # An empty string is absent as far as presence is concerned, and wrapping + # it does not make it a value. + def test_presence_rejects_an_empty_string_inside_a_some + blank = SubmittedManuscript.new(title: Some('')) + + refute_predicate blank, :valid? + assert_equal ['can\'t be blank'], blank.errors[:title] + + assert_predicate SubmittedManuscript.new(title: Some('Death\'s End')), :valid? + refute_predicate SubmittedManuscript.new(title: None()), :valid? + end + + # Both validators reach the value through to_s. + def test_length_and_format_validate_the_value_inside_a_some + assert_predicate Manuscript.new(isbn: Some('9780765377104')), :valid? + refute_predicate Manuscript.new(isbn: Some('97807653771049')), :valid? + refute_predicate Manuscript.new(isbn: Some('978-0765377')), :valid? + assert_predicate Manuscript.new(isbn: None()), :valid? + end + + def test_numericality_validates_the_value_inside_a_some + assert_predicate Manuscript.new(pages: Some(400)), :valid? + refute_predicate Manuscript.new(pages: Some(-1)), :valid? + refute_predicate Manuscript.new(pages: Some('four hundred')), :valid? + assert_predicate Manuscript.new(pages: None()), :valid? + end + + # An empty string casts away to nil on an integer column, which allow_nil + # skips, and wrapping it changes neither step. + def test_numericality_gives_an_empty_string_the_same_verdict_wrapped_or_not + assert_equal Manuscript.new(pages: '').valid?, Manuscript.new(pages: Some('')).valid? + end + + # An application's own validator reads through the same seam, so it is + # handed the value like every validator Rails ships. + def test_a_custom_validator_receives_the_value_inside_a_some + seen = [] + audited = Class.new(ActiveRecord::Base) do + def self.name = 'AuditedManuscript' + self.table_name = 'manuscripts' + include Errgonomic::Rails::ActiveRecordOptional + end + audited.validates_each(:title) { |_record, _attribute, value| seen << value } + + audited.new(title: Some('Death\'s End')).valid? + audited.new(title: None()).valid? + + assert_equal ["Death's End", nil], seen + end + + # An empty string amounts to nothing, which is what absence asks about. + def test_absence_weighs_the_value_inside_a_some + assert_predicate RetractedManuscript.new(status: Some('')), :valid? + refute_predicate RetractedManuscript.new(status: Some('withdrawn')), :valid? + assert_predicate RetractedManuscript.new(status: None()), :valid? + end + + # Acceptance matches the value against a literal, which no wrapper equals. + def test_acceptance_matches_the_value_inside_a_some + assert_predicate RetractedManuscript.new(accepted: Some(true)), :valid? + refute_predicate RetractedManuscript.new(accepted: Some(false)), :valid? + assert_predicate RetractedManuscript.new(accepted: None()), :valid? + end + + # Comparison orders the value against a bound, which an Option cannot be + # ordered against. + def test_comparison_orders_the_value_inside_a_some + assert_predicate RetractedManuscript.new(pages: Some(400)), :valid? + refute_predicate RetractedManuscript.new(pages: Some(-1)), :valid? + assert_predicate RetractedManuscript.new(pages: None()), :valid? + end + + # The presence validation Rails adds for a required belongs_to reads the + # association through the same seam. + def test_a_required_belongs_to_reports_a_missing_record + author = Author.create!(name: 'Cixin Liu') + missing = AttributedManuscript.new(author: None()) + + refute_predicate missing, :valid? + assert_equal ['must exist'], missing.errors[:author] + + assert_predicate AttributedManuscript.new(author: Some(author)), :valid? + end + + def test_a_presence_validated_has_one_reports_a_missing_record + author = ProfiledAuthor.new(name: 'Cixin Liu') + + refute_predicate author, :valid? + assert_equal ['can\'t be blank'], author.errors[:profile] + + author.profile = Profile.new(tagline: 'writes sci-fi') + + assert_predicate author, :valid? + end + + # some: asks only whether the value is there, which is what separates it + # from presence, and it asks it of any model: a plain value is a value. + def test_some_asks_whether_the_value_is_there + blank = SubmittedManuscript.new(title: Some('')) + blank.valid? + + refute_includes blank.errors[:title], 'is invalid' + + absent = SubmittedManuscript.new(title: None()) + absent.valid? + + assert_includes absent.errors[:title], 'is invalid' + + assert_predicate PlainManuscript.new(title: 'Death\'s End'), :valid? + refute_predicate PlainManuscript.new(title: nil), :valid? + end + + # Every wrapped column of an unsaved record is None, and validation walks + # all of them. + def test_validating_a_converted_record_with_no_validations_does_not_raise + assert_predicate Note.new, :valid? + end + private + def declare_serialize_none(mode, **scope) + Class.new(ActiveRecord::Base) do + self.table_name = 'books' + errgonomic_serialize_none(mode, **scope) + end + end + + def capture_stderr + original = $stderr + $stderr = StringIO.new + yield + $stderr.string + ensure + $stderr = original + end + def deeper(frames, &block) return block.call if frames.zero?