errgonomic 0.8.0 → 0.8.1

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: 1b7d451a7e98b347baa9556bf72fd22d3825278bf03afddb9a4fdb0e7c325b03
4
- data.tar.gz: b336d798fc24356fc0a340ac24d5c562d10d242d5507a71444daa881831ecb27
3
+ metadata.gz: c579e542517b446e381c09ef98e24237e8ba5c02ca19633d762195d9bcab6a1d
4
+ data.tar.gz: aa72fb5647fbd9f335bde9b0d10e6a07b68359a7581c4f9152fbf59958d54f43
5
5
  SHA512:
6
- metadata.gz: 3967b76933831abb7750f253f345a8845c4bd00a172db427d99cd22c5fd3f41d9d18b36a7a44f4f915b04acca72009f52e4927ec8dfa60d98bea80eda82e1a2c
7
- data.tar.gz: 2a7ef8d3aa5dbd1e9777e8a653a48c5d3eafbf1b8514e26a483e3056b5139dce643ace35542062512da59b2fa0c9d4b5ab915754e83e747196dabe65f6af91f8
6
+ metadata.gz: 6423f7d1dbf8a9b0d0ee116f72f7dcd427da888f2213c5ea55ebb13fa7abcec83c63d1897f253a6f644094dabf194c9711f99565bedca9444126951ae715c39e
7
+ data.tar.gz: 4d3329007ccb658b05cbb4103395133a2c355e4224f5b58850e3da399338ede381bcb0b4db3a2dcb08db54868405d8886e168b1e1b67435e484e9a94e2832f94
data/README.md CHANGED
@@ -113,6 +113,8 @@ An unhandled Option refuses to leak into your output: `to_s` and `to_json` raise
113
113
 
114
114
  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.
115
115
 
116
+ 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?`.
117
+
116
118
  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 }`).
117
119
 
118
120
  ### Result
@@ -199,21 +201,31 @@ end
199
201
 
200
202
  When `Rails::Railtie` is defined, Errgonomic installs a Railtie with two opt-in integrations for ActiveRecord:
201
203
 
202
- - `include Errgonomic::Rails::ActiveRecordOptional` in a model makes its nullable attributes and `optional: true` associations return `Some(value)` or `None()` instead of a value-or-nil. This is all-or-nothing per model: every nullable column and optional association is wrapped, with no per-attribute opt-in.
204
+ - `include Errgonomic::Rails::ActiveRecordOptional` in a model makes its nullable attributes and `optional: true` associations return `Some(value)` or `None()` instead of a value-or-nil. Every nullable column and optional association is wrapped, with no per-attribute opt-in. Two kinds of attribute stay unwrapped: those declared with `encrypts`, whose surrounding machinery reads the raw value, and those named by `errgonomic_optional_except`, which must appear before the include.
205
+
206
+ ```ruby
207
+ class Credential < ApplicationRecord
208
+ errgonomic_optional_except :legacy_token
209
+ include Errgonomic::Rails::ActiveRecordOptional
210
+
211
+ encrypts :access_secret # also left unwrapped, declared either side of the include
212
+ end
213
+ ```
203
214
  - `delegate_optional :name, to: :association` (available on all models) delegates through an optional association, returning an Option instead of raising on nil.
204
215
 
205
216
  `Object#to_option` is also available in Rails to lift any value into an Option (`nil.to_option # => None()`).
206
217
 
207
218
  #### ActiveRecord compromises
208
219
 
209
- ActiveRecord assumes things about accessors that a strict Rust Option cannot satisfy, so the integration carries four deliberate compromises. Everywhere else, treat a departure from Rust's `Option` semantics as a bug; these four are intended:
220
+ 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:
210
221
 
211
222
  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`.
212
223
  2. `Some` delegates `persisted?`, `marked_for_destruction?`, and `touch_later` to its record, so a `Some` can stand in for its record during persistence.
213
- 3. Quoting is patched so an `Option` passed into `where`/`quote` is unwrapped at the SQL boundary.
224
+ 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.
214
225
  4. `SomeValidator` provides a presence-style validation for Option attributes.
226
+ 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.
215
227
 
216
- The set is closed. If a future integration appears to need a fifth compromise, that is a signal ActiveRecord is pushing back somewhere unmapped, and it warrants a design discussion rather than a quiet patch.
228
+ 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.
217
229
 
218
230
  ## Development
219
231
 
@@ -194,6 +194,114 @@ module Errgonomic
194
194
  none?
195
195
  end
196
196
 
197
+ # The presence helpers on Object keep their receiver; on an Option that
198
+ # would hand back the wrapper where the caller asked for a value. Here
199
+ # the present side unwraps instead, so a name that reads like an
200
+ # accessor behaves like one. The whole family is soft-deprecated on
201
+ # Options in favor of the combinators, so each call nudges via stderr,
202
+ # and the blank side, which has no working call sites to preserve,
203
+ # teaches rather than guesses at semantics.
204
+
205
+ # Returns the inner value of a Some, and raises on a None. Presence
206
+ # follows the discriminant, so Some(nil) yields nil.
207
+ #
208
+ # @param message [String] The error message to raise on a None.
209
+ # @return [Object] The inner value of a Some.
210
+ #
211
+ # @example
212
+ # Some("secret").present_or_raise!("no secret") # => "secret"
213
+ # Some(nil).present_or_raise!("no secret") # => nil
214
+ # None().present_or_raise!("no secret") # => raise Errgonomic::NotPresentError, "no secret"
215
+ def present_or_raise!(message)
216
+ presence_nudge('present_or_raise', 'expect!')
217
+ raise Errgonomic::NotPresentError, message if none?
218
+
219
+ value
220
+ end
221
+
222
+ alias present_or_raise present_or_raise!
223
+
224
+ # Returns the inner value of a Some, and the given default on a None.
225
+ # No pedantic type check on the default: this family is deprecated on
226
+ # Options, and unwrap_or, which the nudge points to, has none either.
227
+ #
228
+ # @param default [Object] The value to return on a None.
229
+ # @return [Object] The inner value of a Some, otherwise the default.
230
+ #
231
+ # @example
232
+ # Some("secret").present_or("fallback") # => "secret"
233
+ # None().present_or("fallback") # => "fallback"
234
+ def present_or(default)
235
+ presence_nudge('present_or', 'unwrap_or')
236
+ return default if none?
237
+
238
+ value
239
+ end
240
+
241
+ # Returns the inner value of a Some, and the result of the block on a
242
+ # None.
243
+ #
244
+ # @param block [Proc] The block to call on a None.
245
+ # @return [Object] The inner value of a Some, otherwise the block's value.
246
+ #
247
+ # @example
248
+ # Some("secret").present_or_else { "fallback" } # => "secret"
249
+ # None().present_or_else { "fallback" } # => "fallback"
250
+ def present_or_else(&block)
251
+ presence_nudge('present_or_else', 'unwrap_or_else')
252
+ return block.call if none?
253
+
254
+ value
255
+ end
256
+
257
+ # Returns the inner value of a Some, and nil on a None, so the Rails
258
+ # +presence || default+ idiom reaches the value rather than the wrapper.
259
+ #
260
+ # @return [Object, nil] The inner value of a Some, otherwise nil.
261
+ #
262
+ # @example
263
+ # Some("secret").presence # => "secret"
264
+ # None().presence # => nil
265
+ # None().presence || "fallback" # => "fallback"
266
+ def presence
267
+ presence_nudge('presence', 'unwrap_or(nil)')
268
+ return nil if none?
269
+
270
+ value
271
+ end
272
+
273
+ # @example the blank side of the presence family teaches the combinators
274
+ # begin
275
+ # None().blank_or("x")
276
+ # rescue NoMethodError => e
277
+ # e.class
278
+ # end # => Errgonomic::UnwrappedAccessError
279
+ def blank_or(_default)
280
+ raise_blank_side_teaching(:blank_or)
281
+ end
282
+
283
+ # @example
284
+ # begin
285
+ # Some(1).blank_or_else { :x }
286
+ # rescue NoMethodError => e
287
+ # e.class
288
+ # end # => Errgonomic::UnwrappedAccessError
289
+ def blank_or_else(&_block)
290
+ raise_blank_side_teaching(:blank_or_else)
291
+ end
292
+
293
+ # @example
294
+ # begin
295
+ # None().blank_or_raise!("msg")
296
+ # rescue NoMethodError => e
297
+ # e.class
298
+ # end # => Errgonomic::UnwrappedAccessError
299
+ def blank_or_raise!(_message)
300
+ raise_blank_side_teaching(:blank_or_raise!)
301
+ end
302
+
303
+ alias blank_or_raise blank_or_raise!
304
+
197
305
  # return an Array with the contained value, if any
198
306
  # @example
199
307
  # Some(1).to_a # => [1]
@@ -502,6 +610,21 @@ module Errgonomic
502
610
  None()
503
611
  end
504
612
 
613
+ private
614
+
615
+ def presence_nudge(from, to)
616
+ warn "Errgonomic: `#{from}` on an Option is soft-deprecated; prefer `#{to}`."
617
+ end
618
+
619
+ def raise_blank_side_teaching(name)
620
+ raise Errgonomic::UnwrappedAccessError.new(<<~MSG, name)
621
+ `#{name}` is not supported on an Option, whose blankness is its discriminant.
622
+ Test it with none?, or supply a fallback with unwrap_or / unwrap_or_else.
623
+ MSG
624
+ end
625
+
626
+ public
627
+
505
628
  # Rust's mutating combinators (insert, get_or_insert, take, replace)
506
629
  # are deliberately omitted: an Option here is a value, not a slot.
507
630
  end
@@ -9,6 +9,17 @@ module Errgonomic
9
9
  extend ActiveSupport::Concern
10
10
 
11
11
  class_methods do
12
+ # Names attributes that ActiveRecordOptional must leave alone. It has
13
+ # to be callable before the include, which is what computes the
14
+ # wrapped set, so it lives here rather than in the concern itself.
15
+ def errgonomic_optional_except(*names)
16
+ @errgonomic_optional_exceptions = errgonomic_optional_exceptions + names.map(&:to_s)
17
+ end
18
+
19
+ def errgonomic_optional_exceptions
20
+ @errgonomic_optional_exceptions ||= []
21
+ end
22
+
12
23
  def delegate_optional(*methods, to: nil, prefix: nil, private: nil)
13
24
  return if to.nil?
14
25
 
@@ -4,9 +4,9 @@ module Errgonomic
4
4
  module Rails
5
5
  # Concern to make ActiveRecord optional attributes and associations return an Option.
6
6
  #
7
- # Four pragmatic compromises below satisfy ActiveRecord's assumptions
7
+ # Five pragmatic compromises below satisfy ActiveRecord's assumptions
8
8
  # about how accessors behave. They are deliberate exceptions to "Option
9
- # behaves like Rust's Option", and the set is closed: a fifth would be a
9
+ # behaves like Rust's Option", and the set is closed: a sixth would be a
10
10
  # signal that ActiveRecord is pushing back somewhere unmapped, deserving
11
11
  # a design discussion rather than a quiet patch.
12
12
  #
@@ -15,10 +15,17 @@ module Errgonomic
15
15
  # None() == nil stays false.
16
16
  # 2. Some delegates persisted?, marked_for_destruction?, and touch_later
17
17
  # to its record, so a Some can stand in for it during persistence.
18
- # 3. Two quoting prepends unwrap Options at the SQL boundary, so an
19
- # Option can be passed to where/quote.
18
+ # 3. Quoting and predicate-building prepends unwrap Options at the SQL
19
+ # boundary, so an Option can be passed to where/quote.
20
20
  # 4. SomeValidator provides a presence-style validation for Option
21
21
  # attributes.
22
+ # 5. Attributes declared with encrypts are never wrapped: ActiveRecord
23
+ # Encryption registers a length validator outside Model.validators
24
+ # that reads the raw value and cannot survive an Option.
25
+ #
26
+ # errgonomic_optional_except is not on the list: it is configuration, an
27
+ # escape hatch for whatever conflict shows up next, not a semantic
28
+ # exception.
22
29
  module ActiveRecordOptional
23
30
  extend ActiveSupport::Concern
24
31
 
@@ -27,8 +34,10 @@ module Errgonomic
27
34
  optional_associations = reflect_on_all_associations(:belongs_to)
28
35
  .select { |r| r.options[:optional] }
29
36
  .map(&:name)
37
+ excluded = Array(encrypted_attributes).map(&:to_s) + Array(try(:errgonomic_optional_exceptions))
30
38
  optional_attributes = column_names
31
39
  .select { |n| column_for_attribute(n).null }
40
+ .reject { |n| excluded.include?(n) }
32
41
  @errgonomic_optionals = (optional_attributes + optional_associations)
33
42
  @errgonomic_optionals.each do |name|
34
43
  class_eval <<-RUBY, __FILE__, __LINE__ + 1
@@ -56,6 +65,23 @@ module Errgonomic
56
65
  def errgonomic_optionals
57
66
  @errgonomic_optionals
58
67
  end
68
+
69
+ # Encryption surrounds an attribute with machinery that reads the raw
70
+ # value, including a length validator that calls to_s on it, so a
71
+ # wrapped encrypted attribute cannot be saved. Declaring encrypts
72
+ # after the include is the ordinary spelling, so catch it here too and
73
+ # give the attribute its plain reader back.
74
+ def encrypts(*names, **options)
75
+ super.tap { errgonomic_unwrap_optionals(*names) }
76
+ end
77
+
78
+ def errgonomic_unwrap_optionals(*names)
79
+ names.map(&:to_s).each do |name|
80
+ next unless @errgonomic_optionals&.delete(name)
81
+
82
+ remove_method(name)
83
+ end
84
+ end
59
85
  end
60
86
  end
61
87
  end
@@ -136,3 +162,33 @@ module Errgonomic
136
162
  end
137
163
 
138
164
  ActiveRecord::ConnectionAdapters::Quoting.prepend(Errgonomic::Rails::ActiveRecordQuoting)
165
+
166
+ module Errgonomic
167
+ module Rails
168
+ # A hash condition never reaches the quoting layer as its raw value: the
169
+ # predicate builder hands it to a bind attribute, which serializes it
170
+ # through the column type and casts an unrecognized object to nil. Unwrap
171
+ # one step earlier, where every hash condition passes, so a Some binds as
172
+ # its inner value and a None as nil, which Arel renders as IS NULL.
173
+ module ActiveRecordPredicateBuilder
174
+ def build(attribute, value, *args)
175
+ super(attribute, Errgonomic::Rails.unwrap_options(value), *args)
176
+ end
177
+ end
178
+
179
+ # Unwrap Options in a query condition, reaching one level into an array
180
+ # so a list of Options binds like a list of values.
181
+ def self.unwrap_options(value)
182
+ case value
183
+ when Errgonomic::Option::Any
184
+ value.unwrap_or(nil)
185
+ when Array
186
+ value.any? { |v| v.is_a?(Errgonomic::Option::Any) } ? value.map { |v| unwrap_options(v) } : value
187
+ else
188
+ value
189
+ end
190
+ end
191
+ end
192
+ end
193
+
194
+ ActiveRecord::PredicateBuilder.prepend(Errgonomic::Rails::ActiveRecordPredicateBuilder)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Errgonomic
4
- VERSION = '0.8.0'
4
+ VERSION = '0.8.1'
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: errgonomic
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nick Zadrozny
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 1980-01-01 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: concurrent-ruby