Module: ComputedModel::Model::ClassMethods

Defined in:
lib/computed_model/model.rb

Overview

A set of class methods for ComputedModel::Model. Automatically included to the singleton class when you include ComputedModel::Model.

See ComputedModel::Model for examples.

Instance Method Summary collapse

Instance Method Details

#bulk_load_and_compute(deps, **options) ⇒ Array<Object>

The core routine for batch-loading.

Each model class is expected to provide its own wrapper of this method. See CONCEPTS.md for examples.

Parameters:

Returns:

  • (Array<Object>)

    The array of record objects, with requested fields filled in.

Raises:

  • (ComputedModel::CyclicDependency)

    if the graph has a cycle

  • (ArgumentError)

    if the graph lacks a primary field

  • (RuntimeError)

    if the graph has multiple primary fields

  • (RuntimeError)

    if the graph has a dangling dependency (reference to an undefined field)



349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
# File 'lib/computed_model/model.rb', line 349

def bulk_load_and_compute(deps, **options)
  objs = nil
  sorted = __computed_model_sorted_graph
  plan = sorted.plan(deps)
  plan.load_order.each do |node|
    case sorted.original[node.name].type
    when :primary
      loader_name = :"__computed_model_enumerate_#{node.name}"
      objs = send(loader_name, ComputedModel.filter_subfields(node.subfields), **options)
      dummy_toplevel_node = ComputedModel::Plan::Node.new(nil, plan.toplevel, nil)
      objs.each do |obj|
        obj.instance_variable_set(:@__computed_model_plan, plan)
        obj.instance_variable_set(:@__computed_model_stack, [dummy_toplevel_node])
      end
    when :loaded
      loader_name = :"__computed_model_load_#{node.name}"
      objs.each do |obj|
        obj.instance_variable_get(:@__computed_model_stack) << node
      end
      begin
        send(loader_name, objs, ComputedModel.filter_subfields(node.subfields), **options)
      ensure
        objs.each do |obj|
          obj.instance_variable_get(:@__computed_model_stack).pop
        end
      end
    else # when :computed
      objs.each do |obj|
        obj.send(:"compute_#{node.name}")
      end
    end
  end

  objs
end

#computed(meth_name) ⇒ Symbol

Declares a computed field. Normally it follows a call to #dependency.

Examples:

define a field which is calculated from other fields

dependency :user, :user_external_resource
computed def something
  # Use user and user_external_resource ...
end

Parameters:

  • meth_name (Symbol)

    a method name to promote to a computed field. Typically used in the form of computed def ....

Returns:

  • (Symbol)

    the first argument as-is.



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
# File 'lib/computed_model/model.rb', line 138

def computed(meth_name)
  var_name = :"@#{meth_name}"
  meth_name_orig = :"#{meth_name}_orig"
  compute_meth_name = :"compute_#{meth_name}"

  __computed_model_graph << ComputedModel::DepGraph::Node.new(:computed, meth_name, @__computed_model_next_dependency)
  remove_instance_variable(:@__computed_model_next_dependency) if defined?(@__computed_model_next_dependency)

  alias_method meth_name_orig, meth_name
  define_method(meth_name) do
    raise ComputedModel::NotLoaded, "the field #{meth_name} is not loaded" unless instance_variable_defined?(var_name)

    __computed_model_check_availability(meth_name)
    instance_variable_get(var_name)
  end
  define_method(compute_meth_name) do
    @__computed_model_stack << @__computed_model_plan[meth_name]
    begin
      instance_variable_set(var_name, send(meth_name_orig))
    ensure
      @__computed_model_stack.pop
    end
  end
  if public_method_defined?(meth_name_orig)
    public meth_name
  elsif protected_method_defined?(meth_name_orig)
    protected meth_name
  else # elsif private_method_defined?(meth_name_orig)
    private meth_name
  end

  meth_name
end

#define_loader(meth_name, key:) {|keys, subfields, **options| ... } ⇒ void

This method returns an undefined value.

Declares a loaded field. See #dependency and #define_primary_loader too.

define_loader :foo do ... end generates a reader foo and a writer foo=. The writer only exists for historical reasons.

The block passed to define_loader is called a loader. Loader should return a hash containing field values.

  • The keys of the hash must match record.instance_exec(&key).
  • The values of the hash represents the field values.

Examples:

define a loader for ActiveRecord-based models

define_loader :user_aux_data, key: -> { id } do |user_ids, subfields, **options|
  UserAuxData.where(user_id: user_ids).preload(subfields).group_by(&:id)
end

Parameters:

  • meth_name (Symbol)

    the name of the loaded field.

  • key (Proc)

    The proc to collect keys. In the proc, self evaluates to the record instance. Typically -> { id }.

Yields:

  • (keys, subfields, **options)

Yield Parameters:

  • keys (Array)

    the array of keys.

  • subfields (Array)

    subfield selectors

  • options (Hash)

    the batch-loading parameters. The keyword arguments to #bulk_load_and_compute will be passed down here as-is.

Yield Returns:

  • (Hash)

    a hash containing field values.

Raises:

  • (ArgumentError)

    if no block is given



240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
# File 'lib/computed_model/model.rb', line 240

def define_loader(meth_name, key:, &block)
  raise ArgumentError, "No block given" unless block

  var_name = :"@#{meth_name}"
  loader_name = :"__computed_model_load_#{meth_name}"
  writer_name = :"#{meth_name}="

  __computed_model_graph << ComputedModel::DepGraph::Node.new(:loaded, meth_name, @__computed_model_next_dependency)
  remove_instance_variable(:@__computed_model_next_dependency) if defined?(@__computed_model_next_dependency)
  define_singleton_method(loader_name) do |objs, subfields, **options|
    keys = objs.map { |o| o.instance_exec(&key) }
    field_values = block.call(keys, subfields, **options)
    objs.zip(keys) do |obj, key|
      obj.send(writer_name, field_values[key])
    end
  end

  define_method(meth_name) do
    raise ComputedModel::NotLoaded, "the field #{meth_name} is not loaded" unless instance_variable_defined?(var_name)

    __computed_model_check_availability(meth_name)
    instance_variable_get(var_name)
  end
  # TODO: remove writer?
  attr_writer meth_name
end

#define_primary_loader(meth_name) {|subfields, **options| ... } ⇒ Array

Declares a primary field. See #define_loader and #dependency too. ComputedModel should have exactly one primary field.

define_primary_loader :foo do ... end generates a reader foo and a writer foo=. The writer only exists for historical reasons.

The block passed to define_loader is called a primary loader. The primary loader's responsibility is batch loading + enumeration (search). In contrast to #define_loader, where a hash of field values are returned, the primary loader should return an array of record objects.

For example, if your class is User, the primary loader must return Array<User>.

Additionally, the primary loader must initialize all the record objects so that the same instance variable @#{meth_name} is set.

Examples:

define a primary loader for ActiveRecord-based models

class User
  include ComputedModel::Model

  def initialize(raw_user)
    # @raw_user must match the name of the primary loader
    @raw_user = raw_user
  end

  define_primary_loader :raw_user do |subfields, **options|
    raw_users = RawUser.where(id: user_ids).preload(subfields)
    # Create User instances
    raw_users.map { |raw_user| User.new(raw_user) }
  end
end

Parameters:

  • meth_name (Symbol)

    the name of the loaded field.

Yields:

  • (subfields, **options)

Yield Parameters:

  • subfields (Array)

    subfield selectors

  • options (Hash)

    the batch-loading parameters. The keyword arguments to #bulk_load_and_compute will be passed down here as-is.

Yield Returns:

  • (void)

Returns:

  • (Array)

    an array of record objects.

Raises:

  • (ArgumentError)

    if no block is given

  • (ArgumentError)

    if it follows a #dependency declaration



309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
# File 'lib/computed_model/model.rb', line 309

def define_primary_loader(meth_name, &block)
  # TODO: The current API requires the user to initialize a specific instance variable.
  # TODO: this design is a bit ugly.
  if defined?(@__computed_model_next_dependency)
    remove_instance_variable(:@__computed_model_next_dependency)
    raise ArgumentError, 'primary field cannot have a dependency'
  end
  raise ArgumentError, "No block given" unless block

  var_name = :"@#{meth_name}"
  loader_name = :"__computed_model_enumerate_#{meth_name}"

  __computed_model_graph << ComputedModel::DepGraph::Node.new(:primary, meth_name, {})
  define_singleton_method(loader_name) do |subfields, **options|
    block.call(subfields, **options)
  end

  define_method(meth_name) do
    raise ComputedModel::NotLoaded, "the field #{meth_name} is not loaded" unless instance_variable_defined?(var_name)

    __computed_model_check_availability(meth_name)
    instance_variable_get(var_name)
  end
  # TODO: remove writer?
  attr_writer meth_name
end

#delegate_dependency(*methods, to:, allow_nil: nil, prefix: nil, include_subfields: nil) ⇒ void

This method returns an undefined value.

A shorthand for simple computed field.

Use #computed for more complex definition.

Examples:

delegate name from raw_user

delegate_dependency :name, to: :raw_user

delegate name from raw_user, but expose as user_name

delegate_dependency :name, to: :raw_user, prefix: :user

Parameters:

  • methods (Array<Symbol>)

    method names to delegate

  • to (Symbol)

    which field to delegate the methods to. This parameter is used for the dependency declaration too.

  • allow_nil (nil, Boolean) (defaults to: nil)

    If true, nil receivers are ignored, and nil is returned instead.

  • prefix (nil, Symbol) (defaults to: nil)

    A prefix for the delegating method name.

  • include_subfields (nil, Boolean) (defaults to: nil)

    If true, it includes meth_name as a subfield selector.



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/computed_model/model.rb', line 191

def delegate_dependency(*methods, to:, allow_nil: nil, prefix: nil, include_subfields: nil)
  method_prefix = prefix ? "#{prefix}_" : ""
  methods.each do |meth_name|
    pmeth_name = :"#{method_prefix}#{meth_name}"
    if include_subfields
      dependency to=>meth_name
    else
      dependency to
    end
    if allow_nil
      define_method(pmeth_name) do
        send(to)&.public_send(meth_name)
      end
    else
      define_method(pmeth_name) do
        send(to).public_send(meth_name)
      end
    end
    computed pmeth_name
  end
end

#dependency(*deps) ⇒ void

This method returns an undefined value.

Declares the dependency of a computed field. Normally a call to this method will be followed by a call to #computed (or #define_loader).

Examples:

declaring dependencies

dependency :user, :user_external_resource
computed def something
  # Use user and user_external_resource ...
end

declaring dependencies with subfield selectors

dependency user: [:user_names, :premium], user_external_resource: [:received_stars]
computed def something
  # Use user and user_external_resource ...
end

declaring dynamic dependencies

dependency user: -> (subfields) { "..." }
computed def something
  # Use user ...
end

Parameters:

  • deps (Array<Symbol, Hash{Symbol=>Array, Object}>)

    Dependency list. Most simply an array of Symbols (field names).

    It also accepts Hashes. In this case, the keys of the hashes are field names. The values are called subfield selectors.

    Subfield selector is one of the following:

    • nil, true, or false (constant condition)
    • #callable objects accepting one argument (dynamic selector)
    • other objects (static selector)

    Multiple subfield selectors can be specified at once as an array.

    See CONCEPTS.md for the more detailed description of dependency formats.

Raises:

  • (RuntimeError)

    if the dependency list contains values other than Symbol or Hash



122
123
124
125
# File 'lib/computed_model/model.rb', line 122

def dependency(*deps)
  @__computed_model_next_dependency ||= []
  @__computed_model_next_dependency.push(*deps)
end

#verify_dependenciesvoid

This method returns an undefined value.

Verifies the dependency graph for errors. Useful for early error detection. It also prevents concurrency issues.

Place it after all the relevant declarations. Otherwise a mysterious bug may occur.

Examples:

class User
  computed def foo
    # ...
  end

  # ...

  verify_dependencies
end

Raises:

  • (ComputedModel::CyclicDependency)

    if the graph has a cycle

  • (ArgumentError)

    if the graph lacks a primary field

  • (RuntimeError)

    if the graph has multiple primary fields

  • (RuntimeError)

    if the graph has a dangling dependency (reference to an undefined field)



405
406
407
408
# File 'lib/computed_model/model.rb', line 405

def verify_dependencies
  __computed_model_sorted_graph
  nil
end