errgonomic 0.8.0 → 0.8.2

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: 4e2e41e94d099dc04ef7a03ce86060c8d991fb8df5ac0f0faf864e25e49082dd
4
+ data.tar.gz: 0de70e5eebd4319f18579a8bfb4c64597af07f8963d64cdb9ba357c5aec751e8
5
5
  SHA512:
6
- metadata.gz: 3967b76933831abb7750f253f345a8845c4bd00a172db427d99cd22c5fd3f41d9d18b36a7a44f4f915b04acca72009f52e4927ec8dfa60d98bea80eda82e1a2c
7
- data.tar.gz: 2a7ef8d3aa5dbd1e9777e8a653a48c5d3eafbf1b8514e26a483e3056b5139dce643ace35542062512da59b2fa0c9d4b5ab915754e83e747196dabe65f6af91f8
6
+ metadata.gz: 2a46187771e5ec92b4229dd4f89de9467358e73b2302400cf54093d12e3db5b303333aea1f3d1a21ddc88e4bfbcc5d205ca5913f84799d16ed706222376f1c9c
7
+ data.tar.gz: 0d72126a463307e8d40c1d9f4a905fbc4b0f52bb3a791fca7a523c8c124a974f935e3ab0f296ffa59722c869b85f1335bfea81b127ae34131af5a84389bdad2c
data/.rubocop.yml CHANGED
@@ -18,6 +18,7 @@ Metrics/ClassLength:
18
18
  Exclude:
19
19
  - lib/errgonomic/option.rb
20
20
  - lib/errgonomic/result.rb
21
+ - test/**/*
21
22
 
22
23
  # core_ext vendors ActiveSupport's blank?/present? patches; the reopened core
23
24
  # classes there are explained by the file header, not per class. Test
@@ -26,3 +27,23 @@ Style/Documentation:
26
27
  Exclude:
27
28
  - lib/errgonomic/core_ext/**/*
28
29
  - test/**/*
30
+
31
+ # A test states one behavior end to end, and splitting one to satisfy a size
32
+ # metric hides the behavior it was written to name. A cop that names its own
33
+ # Exclude replaces this one, so Metrics/ClassLength repeats the path above.
34
+ Metrics:
35
+ Exclude:
36
+ - test/**/*
37
+
38
+ # The ActiveRecord hooks in the optional concern are one subject — where a
39
+ # reader may come from and what leaves it alone — and reading them together is
40
+ # the point. The generated reader is a heredoc, which the length cops count as
41
+ # if it were code.
42
+ Metrics/BlockLength:
43
+ Exclude:
44
+ - lib/errgonomic/rails/active_record_optional.rb
45
+ - test/**/*
46
+ Metrics/MethodLength:
47
+ Exclude:
48
+ - lib/errgonomic/rails/active_record_optional.rb
49
+ - test/**/*
data/README.md CHANGED
@@ -107,12 +107,14 @@ in Errgonomic::Option::None
107
107
  end
108
108
  ```
109
109
 
110
- An unhandled Option refuses to leak into your output: `to_s` and `to_json` raise `Errgonomic::SerializeError`, so you handle the inner value deliberately rather than shipping `#<Errgonomic::Option::Some...>` to a user.
110
+ 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 `#<Errgonomic::Option::Some...>` 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": ...}`.
111
111
 
112
112
  `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.
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
@@ -146,7 +148,7 @@ in Errgonomic::Result::Err, Exception => e
146
148
  end
147
149
  ```
148
150
 
149
- Like Options, unwrapped Results refuse `to_s` and `to_json`. And `Object#result?` / `Object#assert_result!` help enforce at runtime that a value is a Result.
151
+ Like Options, unwrapped Results refuse `to_s`, `to_json`, and `as_json`. And `Object#result?` / `Object#assert_result!` help enforce at runtime that a value is a Result.
150
152
 
151
153
  ### Optional collections
152
154
 
@@ -199,21 +201,69 @@ 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`.
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
+ ```
214
+
215
+ **Where the include goes.** A model that includes the concern converts itself, and only itself. The include may sit at the top of the model with the other concerns, which is where Rails convention puts one. An `optional: true` association declared below it is wrapped as it is declared, rather than only the associations the class happened to declare above it.
216
+
217
+ ```ruby
218
+ class Book < ApplicationRecord
219
+ include Errgonomic::Rails::ActiveRecordOptional
220
+
221
+ belongs_to :author, optional: true # Some(author) or None()
222
+ end
223
+ ```
224
+
225
+ On an application's own base class, the same include reaches every model below it, and no model mentions errgonomic again:
226
+
227
+ ```ruby
228
+ class ApplicationRecord < ActiveRecord::Base
229
+ primary_abstract_class
230
+ include Errgonomic::Rails::ActiveRecordOptional
231
+ end
232
+ ```
233
+
234
+ Converting one model or all of them is therefore where the include goes, not a setting to choose. The association macros wrap as each model declares them, and a model's nullable columns are wrapped when ActiveRecord loads its schema, so no class body needs a database while it loads.
235
+
236
+ An application's own base class is the useful place for it. Engine and gem models such as `ActiveStorage::Blob` and `PaperTrail::Version` descend straight from `ActiveRecord::Base`, and their own code reads their attributes knowing nothing about an Option. Including it on `ActiveRecord::Base` reaches those too, which is rarely what anyone wants.
237
+
238
+ Two ways out, both readable in a model with no include of its own to point at:
239
+
240
+ ```ruby
241
+ class LegacyImport < ApplicationRecord
242
+ errgonomic_optional_off # this model keeps value-or-nil throughout
243
+ end
244
+
245
+ class Credential < ApplicationRecord
246
+ errgonomic_optional_except :legacy_token # this attribute does
247
+ end
248
+ ```
249
+
250
+ `Model.errgonomic_optionals` reports which readers a model wrapped, which is how to check that a conversion did what it meant to.
251
+
203
252
  - `delegate_optional :name, to: :association` (available on all models) delegates through an optional association, returning an Option instead of raising on nil.
204
253
 
205
254
  `Object#to_option` is also available in Rails to lift any value into an Option (`nil.to_option # => None()`).
206
255
 
207
256
  #### ActiveRecord compromises
208
257
 
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:
258
+ 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
259
 
211
260
  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
261
  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.
262
+ 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
263
  4. `SomeValidator` provides a presence-style validation for Option attributes.
264
+ 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
265
 
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.
266
+ 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
267
 
218
268
  ## Development
219
269
 
@@ -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]
@@ -448,6 +556,15 @@ module Errgonomic
448
556
  raise Errgonomic::SerializeError, 'cannot serialize an unwrapped Option'
449
557
  end
450
558
 
559
+ # ActiveSupport's Hash#as_json and Array#as_json recurse through their
560
+ # members with as_json rather than to_json, so an Option nested in a
561
+ # payload reaches Object#as_json and serializes as its instance
562
+ # variables. Refuse there too, and the guard holds wherever an Option
563
+ # travels.
564
+ def as_json(*_args)
565
+ raise Errgonomic::SerializeError, 'cannot serialize an unwrapped Option'
566
+ end
567
+
451
568
  # pp uses its own object dump unless told otherwise; keep it consistent
452
569
  # with inspect.
453
570
  def pretty_print(pp)
@@ -502,6 +619,21 @@ module Errgonomic
502
619
  None()
503
620
  end
504
621
 
622
+ private
623
+
624
+ def presence_nudge(from, to)
625
+ warn "Errgonomic: `#{from}` on an Option is soft-deprecated; prefer `#{to}`."
626
+ end
627
+
628
+ def raise_blank_side_teaching(name)
629
+ raise Errgonomic::UnwrappedAccessError.new(<<~MSG, name)
630
+ `#{name}` is not supported on an Option, whose blankness is its discriminant.
631
+ Test it with none?, or supply a fallback with unwrap_or / unwrap_or_else.
632
+ MSG
633
+ end
634
+
635
+ public
636
+
505
637
  # Rust's mutating combinators (insert, get_or_insert, take, replace)
506
638
  # are deliberately omitted: an Option here is a value, not a slot.
507
639
  end
@@ -9,6 +9,22 @@ module Errgonomic
9
9
  extend ActiveSupport::Concern
10
10
 
11
11
  class_methods do
12
+ # Names attributes that ActiveRecordOptional must leave alone. It has to
13
+ # be callable before the include, which is what starts the wrapping for
14
+ # a model that converts itself, so it lives here rather than in the
15
+ # concern. Where the concern is included on a base class there is no
16
+ # before, so it also takes back a reader already wrapped.
17
+ def errgonomic_optional_except(*names)
18
+ @errgonomic_optional_exceptions = errgonomic_optional_exceptions + names.map(&:to_s)
19
+ errgonomic_unwrap_optionals(*names) if respond_to?(:errgonomic_unwrap_optionals)
20
+ @errgonomic_optional_exceptions
21
+ end
22
+
23
+ def errgonomic_optional_exceptions
24
+ @errgonomic_optional_exceptions ||=
25
+ superclass.respond_to?(:errgonomic_optional_exceptions) ? superclass.errgonomic_optional_exceptions.dup : []
26
+ end
27
+
12
28
  def delegate_optional(*methods, to: nil, prefix: nil, private: nil)
13
29
  return if to.nil?
14
30
 
@@ -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,22 +15,131 @@ 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
 
25
32
  included do
26
- # ::Rails.logger.debug('ActiveRecordOptional')
27
- optional_associations = reflect_on_all_associations(:belongs_to)
28
- .select { |r| r.options[:optional] }
29
- .map(&:name)
30
- optional_attributes = column_names
31
- .select { |n| column_for_attribute(n).null }
32
- @errgonomic_optionals = (optional_attributes + optional_associations)
33
- @errgonomic_optionals.each do |name|
33
+ reflect_on_all_associations(:belongs_to)
34
+ .select { |r| r.options[:optional] }
35
+ .each { |r| errgonomic_wrap_optional(r.name) }
36
+ end
37
+
38
+ class_methods do
39
+ # What a model wrapped is the signal that a conversion did what it
40
+ # meant to, and the columns are not wrapped until the schema loads, so
41
+ # asking loads it.
42
+ def errgonomic_optionals
43
+ load_schema
44
+ errgonomic_optional_names
45
+ end
46
+
47
+ # The set as it stands, for the wrapping itself: reaching for the
48
+ # schema from here would ask the schema to load while it is loading.
49
+ def errgonomic_optional_names
50
+ @errgonomic_optional_names ||= []
51
+ end
52
+
53
+ # Read when a reader is about to be wrapped rather than snapshotted at
54
+ # include time, so an exclusion works on either side of the include.
55
+ # That is what an include on a base class needs: there is no "before"
56
+ # for a model to declare anything in.
57
+ def errgonomic_optional_exclusions
58
+ inherited = if superclass.respond_to?(:errgonomic_optional_exclusions)
59
+ superclass.errgonomic_optional_exclusions
60
+ else
61
+ []
62
+ end
63
+
64
+ inherited | Array(encrypted_attributes).map(&:to_s) | Array(try(:errgonomic_optional_exceptions)).map(&:to_s)
65
+ end
66
+
67
+ # A model that keeps value-or-nil throughout, for whatever the
68
+ # application knows about it that the concern does not. Where the
69
+ # concern is included on a base class, this is how a model leaves.
70
+ def errgonomic_optional_off
71
+ @errgonomic_optional_off = true
72
+ errgonomic_unwrap_optionals(*errgonomic_optional_names.dup)
73
+ end
74
+
75
+ def errgonomic_optional_off?
76
+ return true if defined?(@errgonomic_optional_off) && @errgonomic_optional_off
77
+
78
+ superclass.respond_to?(:errgonomic_optional_off?) && superclass.errgonomic_optional_off?
79
+ end
80
+
81
+ # A reader wrapped by an ancestor is already an Option; a subclass
82
+ # that wrapped it again would nest it.
83
+ def errgonomic_optional?(name)
84
+ return true if errgonomic_optional_names.include?(name)
85
+
86
+ superclass.respond_to?(:errgonomic_optional?) && superclass.errgonomic_optional?(name)
87
+ end
88
+
89
+ # ActiveRecord defines its attribute methods the first time a model
90
+ # needs its schema, not when the class body runs. Wrapping nullable
91
+ # columns from the same seam keeps a database out of class loading.
92
+ def load_schema!
93
+ super
94
+ errgonomic_wrap_nullable_columns
95
+ end
96
+
97
+ # A subclass loads its own schema, so whichever of the two is touched
98
+ # first wraps the shared columns first, and a subclass that got there
99
+ # first would wrap its parent's readers a second time. Walk the chain
100
+ # from the top down instead, so an ancestor's readers always exist
101
+ # before a subclass considers the same name.
102
+ def errgonomic_wrap_nullable_columns
103
+ superclass.errgonomic_wrap_nullable_columns if superclass.respond_to?(:errgonomic_wrap_nullable_columns)
104
+ # An abstract class has no table, and asking one for its columns
105
+ # raises. The concern belongs on an abstract class all the same: that
106
+ # is where an application puts behaviour every model should have.
107
+ return if abstract_class? || table_name.nil?
108
+
109
+ column_names.each { |name| errgonomic_wrap_optional(name) if column_for_attribute(name).null }
110
+ end
111
+
112
+ # A concern belongs at the top of a model, above its associations, so
113
+ # an optional belongs_to is routinely declared after the include.
114
+ # Wrap it when it arrives, or the conversion is silently partial.
115
+ def belongs_to(name, scope = nil, **options)
116
+ super.tap { errgonomic_wrap_optional(name) if options[:optional] }
117
+ end
118
+
119
+ # Encryption surrounds an attribute with machinery that reads the raw
120
+ # value, including a length validator that calls to_s on it, so a
121
+ # wrapped encrypted attribute cannot be saved. Declaring encrypts
122
+ # after the include is the ordinary spelling, and the exclusion is read
123
+ # from ActiveRecord's own register when a reader is about to be
124
+ # wrapped, so this only has to take back a reader already wrapped.
125
+ def encrypts(*names, **options)
126
+ super.tap { errgonomic_unwrap_optionals(*names) }
127
+ end
128
+
129
+ def errgonomic_unwrap_optionals(*names)
130
+ names.map(&:to_s).each do |name|
131
+ next unless errgonomic_optional_names.delete(name)
132
+
133
+ remove_method(name)
134
+ end
135
+ end
136
+
137
+ def errgonomic_wrap_optional(name)
138
+ name = name.to_s
139
+ return if errgonomic_optional_off?
140
+ return if errgonomic_optional_exclusions.include?(name) || errgonomic_optional?(name)
141
+
142
+ errgonomic_optional_names << name
34
143
  class_eval <<-RUBY, __FILE__, __LINE__ + 1
35
144
  def #{name}
36
145
  reads = Thread.current[:errgonomic_optional_reads] ||= {}
@@ -51,12 +160,6 @@ module Errgonomic
51
160
  RUBY
52
161
  end
53
162
  end
54
-
55
- class_methods do
56
- def errgonomic_optionals
57
- @errgonomic_optionals
58
- end
59
- end
60
163
  end
61
164
  end
62
165
  end
@@ -136,3 +239,33 @@ module Errgonomic
136
239
  end
137
240
 
138
241
  ActiveRecord::ConnectionAdapters::Quoting.prepend(Errgonomic::Rails::ActiveRecordQuoting)
242
+
243
+ module Errgonomic
244
+ module Rails
245
+ # A hash condition never reaches the quoting layer as its raw value: the
246
+ # predicate builder hands it to a bind attribute, which serializes it
247
+ # through the column type and casts an unrecognized object to nil. Unwrap
248
+ # one step earlier, where every hash condition passes, so a Some binds as
249
+ # its inner value and a None as nil, which Arel renders as IS NULL.
250
+ module ActiveRecordPredicateBuilder
251
+ def build(attribute, value, *args)
252
+ super(attribute, Errgonomic::Rails.unwrap_options(value), *args)
253
+ end
254
+ end
255
+
256
+ # Unwrap Options in a query condition, reaching one level into an array
257
+ # so a list of Options binds like a list of values.
258
+ def self.unwrap_options(value)
259
+ case value
260
+ when Errgonomic::Option::Any
261
+ value.unwrap_or(nil)
262
+ when Array
263
+ value.any? { |v| v.is_a?(Errgonomic::Option::Any) } ? value.map { |v| unwrap_options(v) } : value
264
+ else
265
+ value
266
+ end
267
+ end
268
+ end
269
+ end
270
+
271
+ ActiveRecord::PredicateBuilder.prepend(Errgonomic::Rails::ActiveRecordPredicateBuilder)
@@ -369,6 +369,15 @@ module Errgonomic
369
369
  raise Errgonomic::SerializeError, 'cannot serialize an unwrapped Result'
370
370
  end
371
371
 
372
+ # ActiveSupport's Hash#as_json and Array#as_json recurse through their
373
+ # members with as_json rather than to_json, so a Result nested in a
374
+ # payload reaches Object#as_json and serializes as its instance
375
+ # variables. Refuse there too, and the guard holds wherever a Result
376
+ # travels.
377
+ def as_json(*_args)
378
+ raise Errgonomic::SerializeError, 'cannot serialize an unwrapped Result'
379
+ end
380
+
372
381
  # pp uses its own object dump unless told otherwise; keep it consistent
373
382
  # with inspect.
374
383
  def pretty_print(pp)
@@ -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.2'
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.2
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