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

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.

Examples:

a predicate answers a verdict rather than an Option, since every Option is truthy

Article.create!(title: 'Omelas', writer: Writer.create!(name: 'Ursula', bio: 'writes')).credited? # => true
Article.create!(title: 'Omelas', writer: Writer.create!(name: 'Ursula')).credited? # => false
Article.create!(title: 'Untitled').credited? # => false

prefix forms name the reader, as they do for Rails' delegate

article = Article.create!(title: 'Omelas', writer: Writer.create!(name: 'Ursula', bio: 'writes'))
article.writer_name # => Some('Ursula')
article.author_name # => Some('Ursula')
article.bio # => Some('writes')

a delegated call forwards what it was handed

article = Article.create!(title: 'Omelas', writer: Writer.create!(name: 'Ursula'))
article.writer_greeting('Hello') # => Some('Hello, Ursula.')
article.writer_greeting('Hi', punctuation: '!') # => Some('Hi, Ursula!')
article.writer_styled_name(&:upcase) # => Some('URSULA')

a target named for a Ruby keyword is reached through self

Article.create!(title: 'Omelas').table_name # => Some('articles')

the target is lifted, and an Option it hands back is not nested

draft = Draft.create!(title: 'Omelas', writer_id: Writer.create!(name: 'Ursula').id)
draft.writer_name # => Some('Ursula')
draft. # => Some('Ursula')
Draft.create!(title: 'Untitled').writer_name # => None()
Article.create!(title: 'Untitled').writer_name # => None()

a delegated reader points at the model that declared it

Article.instance_method(:writer_name).source_location.first.end_with?('doctest_helper.rb') # => true

an absent target is a value here, so allow_nil: true says nothing new

Reprint.create!(title: 'Untitled').writer_name # => None()
Reprint.create!(title: 'Untitled').respond_to?(:bio) # => false
begin
  Class.new(Reprint) { delegate_optional :name, to: :writer, allow_nil: false }
rescue ArgumentError => e
  e.message
end # => 'delegate_optional reads an absent target as None; allow_nil: false asks for something else'

a delegation needs a target

begin
  Class.new(Reprint) { delegate_optional :name }
rescue ArgumentError => e
  e.message
end.start_with?("Delegation needs a target. Supply a keyword argument 'to'") # => true

a writer is not delegated

begin
  Class.new(Reprint) { delegate_optional :name=, to: :writer }
rescue ArgumentError => e
  e.message
end # => 'delegate_optional does not delegate a writer; an absent target would drop the value assigned'

a module target has no name to prefix with

begin
  Class.new(Reprint) { delegate_optional :name, to: Errgonomic, prefix: true }
rescue ArgumentError => e
  e.message
end # => "prefix: true takes the target's own name, and a module target has none; name the prefix"

an automatic prefix needs a target it can name a method after

begin
  Class.new(Article) { delegate_optional :name, to: :@writer, prefix: true }
rescue ArgumentError => e
  e.message
end # => 'Can only automatically set the delegation prefix when delegating to a method.'


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