module ComputedModel::Model::ClassMethods

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

See {ComputedModel::Model} for examples.

Public Instance Methods

bulk_load_and_compute(deps, **options) click to toggle source

The core routine for batch-loading.

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

@param deps [Array<Symbol, Hash{Symbol=>Array, Object}>] dependency list. Same format as {#dependency}.

See {ComputedModel.normalize_dependencies} too.

@param options [Hash] the batch-loading parameters.

Passed down as-is to loaders ({#define_loader}) and the primary loader ({#define_primary_loader}).

@return [Array<Object>] The array of record objects, with requested fields filled in. @raise [ComputedModel::CyclicDependency] if the graph has a cycle @raise [ArgumentError] if the graph lacks a primary field @raise [RuntimeError] if the graph has multiple primary fields @raise [RuntimeError] if the graph has a dangling dependency (reference to an undefined field)

# 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) click to toggle source

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

@param meth_name [Symbol] a method name to promote to a computed field.

Typically used in the form of `computed def ...`.

@return [Symbol] the first argument as-is.

@example define a field which is calculated from other fields

dependency :user, :user_external_resource
computed def something
  # Use user and user_external_resource ...
end
# 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:, &block) click to toggle source

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.

@param meth_name [Symbol] the name of the loaded field. @param key [Proc] The proc to collect keys. In the proc, `self` evaluates to the record instance.

Typically `-> { id }`.

@return [void] @raise [ArgumentError] if no block is given @yield [keys, subfields, **options] @yieldparam keys [Array] the array of keys. @yieldparam subfields [Array] subfield selectors @yieldparam options [Hash] the batch-loading parameters.

The keyword arguments to {#bulk_load_and_compute} will be passed down here as-is.

@yieldreturn [Hash] a hash containing field values.

@example 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
# 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, &block) click to toggle source

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.

@param meth_name [Symbol] the name of the loaded field. @return [Array] an array of record objects. @raise [ArgumentError] if no block is given @raise [ArgumentError] if it follows a {#dependency} declaration @yield [subfields, **options] @yieldparam subfields [Array] subfield selectors @yieldparam options [Hash] the batch-loading parameters.

The keyword arguments to {#bulk_load_and_compute} will be passed down here as-is.

@yieldreturn [void]

@example 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
# 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) click to toggle source

A shorthand for simple computed field.

Use {#computed} for more complex definition.

@param methods [Array<Symbol>] method names to delegate @param to [Symbol] which field to delegate the methods to.

This parameter is used for the dependency declaration too.

@param allow_nil [nil, Boolean] If `true`,

nil receivers are ignored, and nil is returned instead.

@param prefix [nil, Symbol] A prefix for the delegating method name. @param include_subfields [nil, Boolean] If `true`,

it includes meth_name as a subfield selector.

@return [void]

@example delegate name from raw_user

delegate_dependency :name, to: :raw_user

@example delegate name from raw_user, but expose as user_name

delegate_dependency :name, to: :raw_user, prefix: :user
# 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) click to toggle source

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

@param 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)
- `#call`able 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.

@return [void] @raise [RuntimeError] if the dependency list contains values other than Symbol or Hash

@example declaring dependencies

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

@example 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

@example declaring dynamic dependencies

dependency user: -> (subfields) { "..." }
computed def something
  # Use user ...
end
# File lib/computed_model/model.rb, line 122
def dependency(*deps)
  @__computed_model_next_dependency ||= []
  @__computed_model_next_dependency.push(*deps)
end
verify_dependencies() click to toggle source

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.

@return [void] @raise [ComputedModel::CyclicDependency] if the graph has a cycle @raise [ArgumentError] if the graph lacks a primary field @raise [RuntimeError] if the graph has multiple primary fields @raise [RuntimeError] if the graph has a dangling dependency (reference to an undefined field) @example

class User
  computed def foo
    # ...
  end

  # ...

  verify_dependencies
end
# File lib/computed_model/model.rb, line 405
def verify_dependencies
  __computed_model_sorted_graph
  nil
end

Private Instance Methods

__computed_model_graph() click to toggle source

@return [ComputedModel::DepGraph]

# File lib/computed_model/model.rb, line 422
        def __computed_model_graph
  @__computed_model_graph ||= ComputedModel::DepGraph.new
end
__computed_model_merged_graph() click to toggle source

@return [ComputedModel::DepGraph]

# File lib/computed_model/model.rb, line 416
        def __computed_model_merged_graph
  graphs = ancestors.reverse.map { |m| m.respond_to?(:__computed_model_graph, true) ? m.send(:__computed_model_graph) : nil }.compact
  ComputedModel::DepGraph.merge(graphs)
end
__computed_model_sorted_graph() click to toggle source

@return [ComputedModel::DepGraph::Sorted]

# File lib/computed_model/model.rb, line 411
        def __computed_model_sorted_graph
  @__computed_model_sorted_graph ||= __computed_model_merged_graph.tsort
end