Module: Errgonomic::Rails::ActiveRecordDelegateOptional
- Extended by:
- ActiveSupport::Concern
- Defined in:
- lib/errgonomic/rails/active_record_delegate_optional.rb
Overview
Adds a delegate_optional class method in the spirit of Rails'
delegate, returning an Option instead of nil or NoMethodError when
the delegation target is absent. The generated reader forwards with
... from inside a block, which Ruby 3.4, the version CI runs, accepts.
Class Method Summary collapse
-
.delegate_optional(*methods, to: nil, prefix: nil, private: nil, allow_nil: nil) ⇒ Object
Delegates to an optional target, answering an Option: None where the target is absent.
Class Method Details
.delegate_optional(*methods, to: nil, prefix: nil, private: nil, allow_nil: nil) ⇒ Object
Delegates to an optional target, answering an Option: None where the target is absent.
A name ending in ? is the exception: it answers a bare boolean, false for an absent target.
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 |
# File 'lib/errgonomic/rails/active_record_delegate_optional.rb', line 87 class_methods do # Names attributes that ActiveRecordOptional must leave alone. It has to # be callable before the include, which is what starts the wrapping for # a model that converts itself, so it lives here rather than in the # concern. Where the concern is included on a base class there is no # before, so it also takes back a reader already wrapped. def errgonomic_optional_except(*names) @errgonomic_optional_exceptions = errgonomic_optional_exceptions + names.map(&:to_s) errgonomic_unwrap_optionals(*names) if respond_to?(:errgonomic_unwrap_optionals) @errgonomic_optional_exceptions end def errgonomic_optional_exceptions @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, allow_nil: nil) declared_at = caller_locations(1, 1).first complaint = delegate_optional_complaint(methods, to, prefix, allow_nil) raise ::ArgumentError, complaint if complaint receiver = delegate_optional_receiver(to) methods.each do |method_name| reader = "#{delegate_optional_prefix(to, prefix)}#{method_name}" define_optional_delegation(receiver, method_name, reader, declared_at) private(reader) if private end end # Both ends lift exactly one layer, so a record, a nil and an Option # all delegate, and an Option the call returns is not wrapped twice. # The call is written out rather than sent, so the target's method is # reached on the same terms a caller would reach it on, and the reader # takes the declaration's file and line so a backtrace names the model. def define_optional_delegation(receiver, method_name, reader, declared_at) class_eval <<-RUBY, declared_at.path, declared_at.lineno # rubocop:disable Style/EvalWithLocation def #{reader}(...) #{delegation_body(receiver, method_name)} end RUBY end # A ? name asks for a verdict, and an Option is not usable as one: # every Option is truthy, so Some(false) and None both take the true # branch. A predicate therefore answers a bare boolean, reading an # absent target as false, which branches the way nil does. def delegation_body(receiver, method_name) return "#{receiver}.to_option.some_and? { |target| target.#{method_name}(...) }" if predicate?(method_name) "#{receiver}.to_option.and_then { |target| target.#{method_name}(...).to_option }" end def predicate?(method_name) method_name.to_s.end_with?('?') end # A target named for a Ruby keyword reads as the keyword in the body # it is written into, so it needs an explicit receiver. Rails answers # the same question for delegate, and answers it for the same names. def delegate_optional_receiver(to) return to.to_s unless ::ActiveSupport::Delegation::RESERVED_METHOD_NAMES.include?(to.to_s) "self.#{to}" end # true asks for the target's own name; any other prefix is the name. def delegate_optional_prefix(to, prefix) return '' unless prefix "#{prefix == true ? to : prefix}_" end # A mistake is worth more where the declaration is written than as a # method nothing can call. allow_nil: true is what a delegation does # here anyway, so a swap from delegate carries; its opposite does not. def delegate_optional_complaint(methods, to, prefix, allow_nil) return NO_TARGET if to.nil? return NO_WRITERS if methods.any? { |method_name| /\A\w+=\z/.match?(method_name.to_s) } return ALWAYS_NONE if allow_nil == false delegate_optional_prefix_complaint(to, prefix) end # An automatic prefix is the target's own name, so the target needs # one, and one that can start a method name. def delegate_optional_prefix_complaint(to, prefix) return unless prefix == true return NO_NAME_TO_PREFIX if to.is_a?(::Module) NO_METHOD_TO_PREFIX if /^[^a-z_]/.match?(to.to_s) end end |