errgonomic 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 124c029eafbcbb48377836a8facaaea281559a922924d544bd7ce3a6b47d0ad8
4
- data.tar.gz: '089786b6eb3ce3be3068803da318543f50dd33878d2b453d22e68296888d7a85'
3
+ metadata.gz: 94eb90cd1a2a6e07d42a5bc9318fc34cea84cff058c6d78299c2aa1673279b3e
4
+ data.tar.gz: c8516b096821a7a1902805687a51b7f07f1443adeccf50f3b85659006f431b61
5
5
  SHA512:
6
- metadata.gz: b7d3e991e2d9981f08fc6ea8437f2052b1ae6ba08e20831867c48f6cdc37cb8907697f33a7fee3c4ab3cb91bf6e8768c1a551a7892e0070172b49d6220deeed1
7
- data.tar.gz: e0c8fb408cdae888b7475c15a91ca597f4f57463997101cabbca8fe91318f912b3fa490689ef4caa05858d68ad3e0477a45ad723142f38941e5d7ba12ee54cfb
6
+ metadata.gz: 4a3803504c4789e6bf2df90f28348e90f72089121e2a2b06f2ba8ec28db7082286447ff07ca6c2c23cd5a41e38d86e31b454237ae3d2299ed4d1f97d0b57518a
7
+ data.tar.gz: c868b2d29da869091801aac2b5295b5a69a67ac405a9a14d38e62021048ff5133dfd288d61caadbfb783f512da3cd409fda19453c55a13610a8525af6a2b46fb
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.11.0] - 2026-09-16
4
+
5
+ This release makes a converted model's query methods and a delegated predicate answer the verdict a boolean caller asked for, rather than something an `if` reads as true.
6
+
7
+ ### Upgrading from 0.10.2
8
+
9
+ An application that prepended its own `query_attribute` unwrap to work around a wrapped column's `attr?` answering true for `false`, `0` or `""` can delete it: the concern does it now, and a prepended copy is redundant rather than harmful. A `delegate_optional` whose method name ends in `?` returns `true` or `false` where it returned an Option, so `some?`, `unwrap_or` and the other combinators on that result are a `NoMethodError`. Read the boolean instead. The one loss is a `?` method that answers a value by design, which now answers `true`: declare it without the `?`, or write `some_and?` by hand. The seam to watch is `Object#to_option`, which lifts whatever it is handed, so `record.delegated?.to_option` is `Some(false)` for an absent target where it was `None`.
10
+
11
+ ### Changes
12
+
13
+ - [Bug fix] A converted model's `query_attribute`, its private `attribute?` alias and the `attr?` methods Rails generates from them answer what the unconverted model answers for a wrapped column, so `note.pinned?` is `false` for an explicit `false` and `note.rank?` is `false` for a `0`. The concern overrides `query_attribute` to unwrap the reader's Option before handing the value to Rails' own cast. Previously the cast saw a `Some`, missed its `true` and `false, nil` arms and fell through to `!blank?`, where `Option#blank?` is `none?`, so every present value answered true ([#88](https://github.com/omc/errgonomic/issues/88))
14
+ - [Behavior change] `delegate_optional` with a method name ending in `?` answers `true` or `false` through `some_and?`, reading an absent target as `false`, where it answered `Some(true)`, `Some(false)` and `None` before. A `?` name asks for a verdict and no Option can serve as one, because every Option is truthy: `Some(false)` and `None` both took the true branch of an `if`, which is the silent wrong branch that `delegate ..., allow_nil: true` does not have. Every other name is lifted into an Option as before, and there is no opt-out keyword ([#89](https://github.com/omc/errgonomic/issues/89))
15
+
3
16
  ## [0.10.2] - 2026-09-10
4
17
 
5
18
  This release makes every `Err` carry an error and removes `Option#ok`, the one method that built an `Err` without one.
data/README.md CHANGED
@@ -403,7 +403,7 @@ The declaration reads as well above the include as below it, as `errgonomic_opti
403
403
 
404
404
  `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. A subclass reports what it inherited alongside anything it wrapped itself, so the report names every wrapped reader the class responds to; `Model.errgonomic_optional_names` is the set that class wrapped on its own.
405
405
 
406
- `delegate_optional` is Rails' `delegate` with `allow_nil`, where the absent case is a `None` rather than a `nil`: `delegate_optional :name, to: :author` gives `book.name # => Some('Cixin Liu')`, and `None()` where there is no author. The prefix forms are Rails': `prefix: true` names the reader after the target (`author_name`), and `prefix: :writer` names it `writer_name`. `private: true` works as it does there. The reader forwards whatever it was called with, arguments and block alike. `allow_nil: true` is accepted and says nothing new, so a `delegate` declaration swaps over unchanged unless it delegates a writer. `delegate_optional :name=, to: :author` raises instead: an assignment through an absent target has nowhere to put the value, and dropping it silently is what the type is there to prevent. `allow_nil: false` asks for a reader that raises on absence, which this does not have, so it raises `ArgumentError` where it is written, as a declaration with no `to:` does.
406
+ `delegate_optional` is Rails' `delegate` with `allow_nil`, where the absent case is a `None` rather than a `nil`: `delegate_optional :name, to: :author` gives `book.name # => Some('Cixin Liu')`, and `None()` where there is no author. The prefix forms are Rails': `prefix: true` names the reader after the target (`author_name`), and `prefix: :writer` names it `writer_name`. `private: true` works as it does there. The reader forwards whatever it was called with, arguments and block alike. `allow_nil: true` is accepted and says nothing new, so a `delegate` declaration swaps over unchanged unless it delegates a writer or a predicate. A name ending in `?` asks for a verdict, and no Option can serve as one, because every Option is truthy. So a predicate answers a bare `true` or `false` through `some_and?`: `delegate_optional :prolific?, to: :author` gives `book.prolific? # => false` for an unprolific author, and `false` again where there is no author, which branches an `if` the way Rails' `allow_nil: true` does. The one thing that costs is a `?` method that answers a value by design, which now answers `true`; declare it without the `?`, or write `some_and?` by hand. `delegate_optional :name=, to: :author` raises instead: an assignment through an absent target has nowhere to put the value, and dropping it silently is what the type is there to prevent. `allow_nil: false` asks for a reader that raises on absence, which this does not have, so it raises `ArgumentError` where it is written, as a declaration with no `to:` does.
407
407
 
408
408
  It is available on every model, converted or not, because it lifts both ends one layer. The target is lifted, so a plain record reads as `Some` and a `nil` as `None`. What the delegated call returns is lifted too, so a delegated reader that is itself an Option comes back as one Option rather than two.
409
409
 
data/doctest_helper.rb CHANGED
@@ -76,6 +76,10 @@ class Writer < ActiveRecord::Base
76
76
  def styled_name
77
77
  yield(name)
78
78
  end
79
+
80
+ def credited?
81
+ !bio.nil?
82
+ end
79
83
  end
80
84
 
81
85
  # A converted model reads its association as an Option.
@@ -86,6 +90,7 @@ class Article < ActiveRecord::Base
86
90
  delegate_optional :name, to: :writer, prefix: :author
87
91
  delegate_optional :bio, to: :writer
88
92
  delegate_optional :greeting, :styled_name, to: :writer, prefix: true
93
+ delegate_optional :credited?, to: :writer
89
94
  delegate_optional :table_name, to: :class
90
95
  end
91
96
 
@@ -27,6 +27,11 @@ module Errgonomic
27
27
  # @!method delegate_optional(*methods, to: nil, prefix: nil, private: nil, allow_nil: nil)
28
28
  # @!scope class
29
29
  # Delegates to an optional target, answering an Option: None where the target is absent.
30
+ # A name ending in `?` is the exception: it answers a bare boolean, false for an absent target.
31
+ # @example a predicate answers a verdict rather than an Option, since every Option is truthy
32
+ # Article.create!(title: 'Omelas', writer: Writer.create!(name: 'Ursula', bio: 'writes')).credited? # => true
33
+ # Article.create!(title: 'Omelas', writer: Writer.create!(name: 'Ursula')).credited? # => false
34
+ # Article.create!(title: 'Untitled').credited? # => false
30
35
  # @example prefix forms name the reader, as they do for Rails' delegate
31
36
  # article = Article.create!(title: 'Omelas', writer: Writer.create!(name: 'Ursula', bio: 'writes'))
32
37
  # article.writer_name # => Some('Ursula')
@@ -158,11 +163,25 @@ module Errgonomic
158
163
  def define_optional_delegation(receiver, method_name, reader, declared_at)
159
164
  class_eval <<-RUBY, declared_at.path, declared_at.lineno # rubocop:disable Style/EvalWithLocation
160
165
  def #{reader}(...)
161
- #{receiver}.to_option.and_then { |target| target.#{method_name}(...).to_option }
166
+ #{delegation_body(receiver, method_name)}
162
167
  end
163
168
  RUBY
164
169
  end
165
170
 
171
+ # A ? name asks for a verdict, and an Option is not usable as one:
172
+ # every Option is truthy, so Some(false) and None both take the true
173
+ # branch. A predicate therefore answers a bare boolean, reading an
174
+ # absent target as false, which branches the way nil does.
175
+ def delegation_body(receiver, method_name)
176
+ return "#{receiver}.to_option.some_and? { |target| target.#{method_name}(...) }" if predicate?(method_name)
177
+
178
+ "#{receiver}.to_option.and_then { |target| target.#{method_name}(...).to_option }"
179
+ end
180
+
181
+ def predicate?(method_name)
182
+ method_name.to_s.end_with?('?')
183
+ end
184
+
166
185
  # A target named for a Ruby keyword reads as the keyword in the body
167
186
  # it is written into, so it needs an explicit receiver. Rails answers
168
187
  # the same question for delegate, and answers it for the same names.
@@ -38,10 +38,12 @@ module Errgonomic
38
38
  # Validation unwraps at read_attribute_for_validation, the seam every
39
39
  # EachValidator fetches an attribute through; serialization at
40
40
  # read_attribute_for_serialization, the seam every attribute in a
41
- # payload is fetched through; and a form helper at ActionView's tag
42
- # value, the seam every field reads its record through. So a standard
43
- # validator weighs the value, a payload carries it and a form renders
44
- # it, rather than the wrapper. A singular association with nested
41
+ # payload is fetched through; a form helper at ActionView's tag
42
+ # value, the seam every field reads its record through; and a query
43
+ # method at query_attribute, the seam every generated attr? is cast
44
+ # through. So a standard validator weighs the value, a payload carries
45
+ # it, a form renders it and a query method answers for it, rather than
46
+ # for the wrapper. A singular association with nested
45
47
  # attributes goes further and keeps its plain reader: nested attributes
46
48
  # are assigned through the reader, and ActiveRecord asks whatever it
47
49
  # finds there whether it is a new record. So does a reader a framework
@@ -115,6 +117,29 @@ module Errgonomic
115
117
  errgonomic_omit_absent_keys(hash)
116
118
  end
117
119
 
120
+ # A query method reads the record through the public reader and matches
121
+ # what it finds against true, then false and nil, before falling through
122
+ # to blankness. An Option is none of those, and its blankness is its
123
+ # discriminant rather than its value, so an explicit false and a stored
124
+ # zero would answer true. Unwrapping the read is what keeps the answer
125
+ # the one an unconverted model gives.
126
+ #
127
+ # @example
128
+ # Note.new(pinned: false).pinned? # => false
129
+ # Note.new(rank: 0).rank? # => false
130
+ # Note.new(title: '').title? # => false
131
+ # Note.new(pinned: true).pinned? # => true
132
+ # Note.new(rank: 3).rank? # => true
133
+ def query_attribute(attr_name)
134
+ query_cast_attribute(attr_name, Errgonomic::Rails.unwrap_option(public_send(attr_name)))
135
+ end
136
+
137
+ # Rails copies query_attribute into attribute? at the alias, and the
138
+ # attr? methods it generates call that copy, so an override of the one
139
+ # never reaches them.
140
+ alias attribute? query_attribute
141
+ private :attribute?
142
+
118
143
  # YARD does not see through a concern's class_methods block, so the
119
144
  # method it documents is declared rather than read.
120
145
  #
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Errgonomic
4
- VERSION = '0.10.2'
4
+ VERSION = '0.11.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: errgonomic
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.2
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nick Zadrozny