computed_model 0.2.2 → 0.3.0

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.
data/README.ja.md ADDED
@@ -0,0 +1,168 @@
1
+ # ComputedModel
2
+
3
+ ComputedModelは依存解決アルゴリズムを備えた普遍的なバッチローダーです。
4
+
5
+ - 依存解決アルゴリズムの恩恵により、抽象化を損なわずに以下の3つを両立させることができます。
6
+ - ActiveRecord等から読み込んだデータを加工して提供する。
7
+ - ActiveRecord等からのデータの読み込みを一括で行うことでN+1問題を防ぐ。
8
+ - 必要なデータだけを読み込む。
9
+ - 複数のデータソースからの読み込みにも対応。
10
+ - データソースに依存しない普遍的な設計。HTTPで取得した情報とActiveRecordから取得した情報の両方を使う、といったこともできます。
11
+
12
+ [English version](README.md)
13
+
14
+ ## 解決したい問題
15
+
16
+ モデルが複雑化してくると、単にデータベースから取得した値を返すだけではなく、加工した値を返したくなることがあります。
17
+
18
+ ```ruby
19
+ class User < ApplicationRecord
20
+ has_one :preference
21
+ has_one :profile
22
+
23
+ def display_name
24
+ "#{preference.title} #{profile.name}"
25
+ end
26
+ end
27
+ ```
28
+
29
+ ところがこれをそのまま使うと N+1 問題が発生することがあります。
30
+
31
+ ```ruby
32
+ # N+1 問題!
33
+ User.where(id: friend_ids).map(&:display_name)
34
+ ```
35
+
36
+ N+1問題を解決するには、 `#display_name` が何に依存していたかを調べ、それをpreloadしておく必要があります。
37
+
38
+ ```ruby
39
+ User.where(id: friend_ids).preload(:preference, :profile).map(&:display_name)
40
+ # ^^^^^^^^^^^^^^^^^^^^^ display_name の抽象化が漏れてしまう
41
+ ```
42
+
43
+ これではせっかく `#display_name` を抽象化した意味が半減してしまいます。
44
+
45
+ ComputedModelは依存解決アルゴリズムをバッチローダーに接続することでこの問題を解消します。
46
+
47
+ ```ruby
48
+ class User
49
+ define_primary_loader :raw_user do ... end
50
+ define_loader :preference do ... end
51
+ define_loader :profile do ... end
52
+
53
+ dependency :preference, :profile
54
+ computed def display_name
55
+ "#{preference.title} #{profile.name}"
56
+ end
57
+ end
58
+ ```
59
+
60
+ ## インストール
61
+
62
+ Gemfileに以下の行を追加:
63
+
64
+ ```ruby
65
+ gem 'computed_model', '~> 0.3.0'
66
+ ```
67
+
68
+ その後、以下を実行:
69
+
70
+ $ bundle
71
+
72
+ または直接インストール:
73
+
74
+ $ gem install computed_model
75
+
76
+ ## 動かせるサンプルコード
77
+
78
+ ```ruby
79
+ require 'computed_model'
80
+
81
+ # この2つを外部から取得したデータとみなす (ActiveRecordやHTTPで取得したリソース)
82
+ RawUser = Struct.new(:id, :name, :title)
83
+ Preference = Struct.new(:user_id, :name_public)
84
+
85
+ class User
86
+ include ComputedModel::Model
87
+
88
+ attr_reader :id
89
+ def initialize(raw_user)
90
+ @id = raw_user.id
91
+ @raw_user = raw_user
92
+ end
93
+
94
+ def self.list(ids, with:)
95
+ bulk_load_and_compute(Array(with), ids: ids)
96
+ end
97
+
98
+ define_primary_loader :raw_user do |_subfields, ids:, **|
99
+ # ActiveRecordの場合:
100
+ # raw_users = RawUser.where(id: ids).to_a
101
+ raw_users = [
102
+ RawUser.new(1, "Tanaka Taro", "Mr. "),
103
+ RawUser.new(2, "Yamada Hanako", "Dr. "),
104
+ ].filter { |u| ids.include?(u.id) }
105
+ raw_users.map { |u| User.new(u) }
106
+ end
107
+
108
+ define_loader :preference, key: -> { id } do |user_ids, _subfields, **|
109
+ # ActiveRecordの場合:
110
+ # Preference.where(user_id: user_ids).index_by(&:user_id)
111
+ {
112
+ 1 => Preference.new(1, true),
113
+ 2 => Preference.new(2, false),
114
+ }.filter { |k, _v| user_ids.include?(k) }
115
+ end
116
+
117
+ delegate_dependency :name, to: :raw_user
118
+ delegate_dependency :title, to: :raw_user
119
+ delegate_dependency :name_public, to: :preference
120
+
121
+ dependency :name, :name_public
122
+ computed def public_name
123
+ name_public ? name : "Anonymous"
124
+ end
125
+
126
+ dependency :public_name, :title
127
+ computed def public_name_with_title
128
+ "#{title}#{public_name}"
129
+ end
130
+ end
131
+
132
+ # あらかじめ要求したフィールドにだけアクセス可能
133
+ users = User.list([1, 2], with: [:public_name_with_title])
134
+ users.map(&:public_name_with_title) # => ["Mr. Tanaka Taro", "Dr. Anonymous"]
135
+ users.map(&:public_name) # => error (ForbiddenDependency)
136
+
137
+ users = User.list([1, 2], with: [:public_name_with_title, :public_name])
138
+ users.map(&:public_name_with_title) # => ["Mr. Tanaka Taro", "Dr. Anonymous"]
139
+ users.map(&:public_name) # => ["Tanaka Taro", "Anonymous"]
140
+
141
+ # 次のような場合は preference は読み込まれない。
142
+ users = User.list([1, 2], with: [:title])
143
+ users.map(&:title) # => ["Mr. ", "Dr. "]
144
+ ```
145
+
146
+ ## 次に読むもの
147
+
148
+ - [基本概念と機能](CONCEPTS.ja.md)
149
+
150
+ ## License
151
+
152
+ This library is distributed under MIT license.
153
+
154
+ Copyright (c) 2020 Masaki Hara
155
+
156
+ Copyright (c) 2020 Masayuki Izumi
157
+
158
+ Copyright (c) 2020 Wantedly, Inc.
159
+
160
+ ## Development
161
+
162
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
163
+
164
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
165
+
166
+ ## Contributing
167
+
168
+ Bug reports and pull requests are welcome on GitHub at https://github.com/wantedly/computed_model.
data/README.md CHANGED
@@ -1,137 +1,153 @@
1
1
  # ComputedModel
2
2
 
3
- ComputedModel is a helper for building a read-only model (sometimes called a view)
4
- from multiple sources of models.
5
- It comes with batch loading and dependency resolution for better performance.
3
+ ComputedModel is a universal batch loader which comes with a dependency-resolution algorithm.
6
4
 
7
- It is designed to be universal. It's as easy as pie to pull data from both
8
- ActiveRecord and remote server (such as ActiveResource).
5
+ - Thanks to the dependency resolution, it allows you to the following trifecta at once, without breaking abstraction.
6
+ - Process information gathered from datasources (such as ActiveRecord) and return the derived one.
7
+ - Prevent N+1 problem via batch loading.
8
+ - Load only necessary data.
9
+ - Can load data from multiple datasources.
10
+ - Designed to be universal and datasource-independent.
11
+ For example, you can gather data from both HTTP and ActiveRecord and return the derived one.
9
12
 
10
- ## Installation
13
+ [日本語版README](README.ja.md)
11
14
 
12
- Add this line to your application's Gemfile:
15
+ ## Problems to solve
16
+
17
+ As models grow, they cannot simply return the database columns as-is.
18
+ Instead, we want to process information obtained from the database and return the derived value.
13
19
 
14
20
  ```ruby
15
- gem 'computed_model', '~> 0.2.2'
21
+ class User < ApplicationRecord
22
+ has_one :preference
23
+ has_one :profile
24
+
25
+ def display_name
26
+ "#{preference.title} #{profile.name}"
27
+ end
28
+ end
16
29
  ```
17
30
 
18
- And then execute:
31
+ However, it can lead to N+1 without care.
19
32
 
20
- $ bundle
33
+ ```ruby
34
+ # N+1 problem!
35
+ User.where(id: friend_ids).map(&:display_name)
36
+ ```
21
37
 
22
- Or install it yourself as:
38
+ To solve the N+1 problem, we need to enumerate dependencies of `#display_name` and preload them.
23
39
 
24
- $ gem install computed_model
40
+ ```ruby
41
+ User.where(id: friend_ids).preload(:preference, :profile).map(&:display_name)
42
+ # ^^^^^^^^^^^^^^^^^^^^^ breaks abstraction of display_name
43
+ ```
25
44
 
26
- ## Usage
45
+ This partially defeats the purpose of `#display_name`'s abstraction.
27
46
 
28
- Include `ComputedModel` in your model class. You may also need an `attr_reader` for the primary key.
47
+ Computed solves the problem by connection the dependency-resolution to the batch loader.
29
48
 
30
49
  ```ruby
31
50
  class User
32
- attr_reader :id
33
-
34
- include ComputedModel
51
+ define_primary_loader :raw_user do ... end
52
+ define_loader :preference do ... end
53
+ define_loader :profile do ... end
35
54
 
36
- def initialize(id)
37
- @id = id
55
+ dependency :preference, :profile
56
+ computed def display_name
57
+ "#{preference.title} #{profile.name}"
38
58
  end
39
59
  end
40
60
  ```
41
61
 
42
- They your model class will be able to define the two kinds of special attributes:
43
-
44
- - **Loaded attributes** for external data. You define batch loading strategies for loaded attributes.
45
- - Among them, there is a special **primary model** for listing up the models from certain criteria.
46
- - **Computed attributes** for data derived from loaded attributes or other computed attributes.
47
- You define a usual `def` with special dependency annotations.
48
-
49
- ## Loaded attributes
62
+ ## Installation
50
63
 
51
- Use `ComputedModel::ClassMethods#define_primary_loader`
52
- or `ComputedModel::ClassMethods#define_loader` to define loaded attributes.
64
+ Add this line to your application's Gemfile:
53
65
 
54
66
  ```ruby
55
- # Create a User instance
56
- def initialize(raw_user)
57
- @id = raw_user.id
58
- @raw_user = raw_user
59
- end
67
+ gem 'computed_model', '~> 0.3.0'
68
+ ```
60
69
 
61
- # Example: pulling data from ActiveRecord
62
- define_primary_loader :raw_user do |subdeps, ids:, **options|
63
- RawUser.where(id: ids).preload(subdeps).map { |raw_user| User.new(raw_user) }
64
- end
70
+ And then execute:
65
71
 
66
- # Example: pulling auxiliary data from ActiveRecord
67
- define_loader :user_aux_data, key: -> { id } do |user_ids, subdeps, **options|
68
- UserAuxData.where(user_id: user_ids).preload(subdeps).group_by(&:id)
69
- end
70
- ```
72
+ $ bundle
71
73
 
72
- ### `define_primary_loader`
74
+ Or install it yourself as:
73
75
 
74
- At most one primary loader can be defined on a model class.
76
+ $ gem install computed_model
75
77
 
76
- The primary loader's job is to list up models from user-defined criteria, along with
77
- requested data loaded to the primary attribute.
78
+ ## Working example
78
79
 
79
- Search criteria can be passed as a keyword argument to `bulk_load_and_compute`
80
- and it will be passed to the loader as-is.
80
+ ```ruby
81
+ require 'computed_model'
81
82
 
82
- Most typically you receive `ids`, an array of integers, and use it like
83
- `.where(id: ids)`. Instead you may want to accept a non-primary-key criterion
84
- such as `group_ids` and `.where(group_id: group_ids)`.
83
+ # Consider them external sources (ActiveRecord or resources obtained via HTTP)
84
+ RawUser = Struct.new(:id, :name, :title)
85
+ Preference = Struct.new(:user_id, :name_public)
85
86
 
86
- The loader must return an array of instances of the model being defined.
87
- Each instance must have the primary attribute assigned at that time.
88
- In the example above, the block for `define_primary_loader :raw_user`
89
- must return an array of `User`s, each of which already have `@raw_user`.
87
+ class User
88
+ include ComputedModel::Model
90
89
 
91
- ### `define_loader`
90
+ attr_reader :id
91
+ def initialize(raw_user)
92
+ @id = raw_user.id
93
+ @raw_user = raw_user
94
+ end
92
95
 
93
- The first argument to the block is an array of the model instances.
94
- The loader's job is to assign something to the corresponding field of each instance.
96
+ def self.list(ids, with:)
97
+ bulk_load_and_compute(Array(with), ids: ids)
98
+ end
95
99
 
96
- The second argument to the block is called a "sub-dependency".
97
- The value of `subdeps` is an array, but further details are up to you
98
- (it's just a verbatim copy of what you pass to `ComputedModel::ClassMethods#dependency`).
99
- It's customary to take something ActiveRecord's `preload` accepts.
100
+ define_primary_loader :raw_user do |_subfields, ids:, **|
101
+ # In ActiveRecord:
102
+ # raw_users = RawUser.where(id: ids).to_a
103
+ raw_users = [
104
+ RawUser.new(1, "Tanaka Taro", "Mr. "),
105
+ RawUser.new(2, "Yamada Hanako", "Dr. "),
106
+ ].filter { |u| ids.include?(u.id) }
107
+ raw_users.map { |u| User.new(u) }
108
+ end
100
109
 
101
- The keyword arguments are also a verbatim copy of what you pass to `ComputedModel::ClassMethods#bulk_load_and_compute`.
110
+ define_loader :preference, key: -> { id } do |user_ids, _subfields, **|
111
+ # In ActiveRecord:
112
+ # Preference.where(user_id: user_ids).index_by(&:user_id)
113
+ {
114
+ 1 => Preference.new(1, true),
115
+ 2 => Preference.new(2, false),
116
+ }.filter { |k, _v| user_ids.include?(k) }
117
+ end
102
118
 
103
- ## Computed attributes
119
+ delegate_dependency :name, to: :raw_user
120
+ delegate_dependency :title, to: :raw_user
121
+ delegate_dependency :name_public, to: :preference
104
122
 
105
- Use `ComputedModel::ClassMethods#computed` and `#dependency` to define computed attributes.
123
+ dependency :name, :name_public
124
+ computed def public_name
125
+ name_public ? name : "Anonymous"
126
+ end
106
127
 
107
- ```ruby
108
- dependency raw_user: [:name], user_music_info: [:latest]
109
- computed def name_with_playing_music
110
- if user_music_info.latest&.playing?
111
- "#{user.name} (Now playing: #{user_music_info.latest.name})"
112
- else
113
- user.name
128
+ dependency :public_name, :title
129
+ computed def public_name_with_title
130
+ "#{title}#{public_name}"
114
131
  end
115
132
  end
116
- ```
117
133
 
118
- ## Batch loading
134
+ # You can only access the field you requested ahead of time
135
+ users = User.list([1, 2], with: [:public_name_with_title])
136
+ users.map(&:public_name_with_title) # => ["Mr. Tanaka Taro", "Dr. Anonymous"]
137
+ users.map(&:public_name) # => error (ForbiddenDependency)
119
138
 
120
- Once you defined loaded and computed attributes, you can batch-load them using `ComputedModel::ClassMethods#bulk_load_and_compute`.
139
+ users = User.list([1, 2], with: [:public_name_with_title, :public_name])
140
+ users.map(&:public_name_with_title) # => ["Mr. Tanaka Taro", "Dr. Anonymous"]
141
+ users.map(&:public_name) # => ["Tanaka Taro", "Anonymous"]
121
142
 
122
- Typically you need to create a wrapper for the batch loader like:
123
-
124
- ```ruby
125
- def self.list(ids, with:)
126
- bulk_load_and_compute(Array(with), ids: ids)
127
- end
143
+ # In this case, preference will not be loaded.
144
+ users = User.list([1, 2], with: [:title])
145
+ users.map(&:title) # => ["Mr. ", "Dr. "]
128
146
  ```
129
147
 
130
- They you can retrieve users with only a specified attributes in a batch:
148
+ ## Next read
131
149
 
132
- ```ruby
133
- users = User.list([1, 2, 3], with: [:name, :name_with_playing_music, :premium_user])
134
- ```
150
+ - [Basic concepts and features](CONCEPTS.md)
135
151
 
136
152
  ## License
137
153
 
data/Rakefile CHANGED
@@ -4,3 +4,17 @@ require "rspec/core/rake_task"
4
4
  RSpec::Core::RakeTask.new(:spec)
5
5
 
6
6
  task :default => :spec
7
+
8
+ namespace :db do
9
+ namespace :schema do
10
+ task :dump do
11
+ $LOAD_PATH.push(File.join(__dir__, 'spec'))
12
+ require 'active_record'
13
+ require 'support/db/connection'
14
+ require 'support/db/schema'
15
+ File.open(File.join(__dir__, 'spec/support/db/schema.rb'), 'w:utf-8') do |file|
16
+ ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, file)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -35,7 +35,15 @@ Gem::Specification.new do |spec|
35
35
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
36
36
  spec.require_paths = ["lib"]
37
37
 
38
+ # For ActiveSupport::Concern
39
+ spec.add_development_dependency "activesupport"
40
+
38
41
  spec.add_development_dependency "bundler", "~> 2.0"
39
42
  spec.add_development_dependency "rake", "~> 13.0"
40
43
  spec.add_development_dependency "rspec", "~> 3.0"
44
+ spec.add_development_dependency "activerecord", "~> 6.1"
45
+ spec.add_development_dependency "sqlite3", "~> 1.4"
46
+ spec.add_development_dependency "factory_bot", "~> 6.1"
47
+ spec.add_development_dependency "simplecov", "~> 0.21.2"
48
+ spec.add_development_dependency "simplecov-lcov", "~> 0.8.0"
41
49
  end
@@ -1,326 +1,66 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "computed_model/version"
4
- require 'set'
4
+ require "computed_model/plan"
5
+ require "computed_model/dep_graph"
6
+ require "computed_model/model"
5
7
 
6
- # A mixin for batch-loadable compound models.
8
+ # ComputedModel is a universal batch loader which comes with a dependency-resolution algorithm.
7
9
  #
8
- # @example typical structure of a computed model
9
- # class User
10
- # include ComputedModel
10
+ # - Thanks to the dependency resolution, it allows you to the following trifecta at once, without breaking abstraction.
11
+ # - Process information gathered from datasources (such as ActiveRecord) and return the derived one.
12
+ # - Prevent N+1 problem via batch loading.
13
+ # - Load only necessary data.
14
+ # - Can load data from multiple datasources.
15
+ # - Designed to be universal and datasource-independent.
16
+ # For example, you can gather data from both HTTP and ActiveRecord and return the derived one.
11
17
  #
12
- # attr_reader :id
13
- # def initialize(id)
14
- # @id = id
15
- # end
16
- #
17
- # define_loader do ... end
18
- #
19
- # dependency :foo, :bar
20
- # computed def something ... end
21
- # end
18
+ # See {ComputedModel::Model} for basic usage.
22
19
  module ComputedModel
23
20
  # An error raised when you tried to read from a loaded/computed attribute,
24
21
  # but that attribute isn't loaded by the batch loader.
25
22
  class NotLoaded < StandardError; end
26
23
 
27
- # A return value from {ComputedModel::ClassMethods#computing_plan}.
28
- Plan = Struct.new(:load_order, :subdeps_hash)
29
-
30
- # An object for storing procs for loaded attributes.
31
- Loader = Struct.new(:key_proc, :load_proc)
32
-
33
- private_constant :Loader
34
-
35
- # A set of class methods for {ComputedModel}. Automatically included to the
36
- # singleton class when you include {ComputedModel}.
37
- module ClassMethods
38
- # Declares the dependency of a computed attribute. See {#computed} too.
39
- #
40
- # @param deps [Array<Symbol, Hash{Symbol=>Array}>]
41
- # Dependency description. If a symbol `:foo` is given,
42
- # it's interpreted as `{ foo: [] }`.
43
- # When the same symbol occurs multiple times, the array is concatenated.
44
- # The contents of the array (called "sub-dependency") is treated opaquely
45
- # by the `computed_model` gem. It is up to the user to design the format
46
- # of sub-dependencies.
47
- # @return [void]
48
- #
49
- # @example declaring dependencies
50
- # dependency :user, :user_external_resource
51
- # computed def something
52
- # # Use user and user_external_resource ...
53
- # end
54
- #
55
- # @example declaring dependencies with sub-dependencies
56
- # dependency user: [:user_names, :premium], user_external_resource: [:received_stars]
57
- # computed def something
58
- # # Use user and user_external_resource ...
59
- # end
60
- def dependency(*deps)
61
- @__computed_model_next_dependency ||= []
62
- @__computed_model_next_dependency.push(*deps)
63
- end
64
-
65
- # Declares a computed attribute. See {#dependency} too.
66
- #
67
- # @param meth_name [Symbol] a method name to promote to a computed attribute.
68
- # Typically used in the form of `computed def ...`.
69
- # @return [Symbol] passes through the argument.
70
- #
71
- # @example define a field which is calculated from loaded models
72
- # dependency :user, :user_external_resource
73
- # computed def something
74
- # # Use user and user_external_resource ...
75
- # end
76
- def computed(meth_name)
77
- var_name = :"@#{meth_name}"
78
- meth_name_orig = :"#{meth_name}_orig"
79
- compute_meth_name = :"compute_#{meth_name}"
80
-
81
- @__computed_model_dependencies[meth_name] = ComputedModel.normalize_dependencies(@__computed_model_next_dependency)
82
- remove_instance_variable(:@__computed_model_next_dependency)
83
-
84
- alias_method meth_name_orig, meth_name
85
- define_method(meth_name) do
86
- raise NotLoaded, "the field #{meth_name} is not loaded" unless instance_variable_defined?(var_name)
87
- instance_variable_get(var_name)
88
- end
89
- define_method(compute_meth_name) do
90
- instance_variable_set(var_name, send(meth_name_orig))
91
- end
92
- if public_method_defined?(meth_name_orig)
93
- public meth_name
94
- elsif protected_method_defined?(meth_name_orig)
95
- protected meth_name
96
- elsif private_method_defined?(meth_name_orig)
97
- private meth_name
98
- end
99
-
100
- meth_name
101
- end
102
-
103
- # A shorthand for simple computed attributes.
104
- #
105
- # Use {#computed} for more complex definition.
106
- #
107
- # @param methods [Array<Symbol>] method names to delegate
108
- # @param to [Symbol] which attribute to delegate the methods to.
109
- # This parameter is used for the dependency declaration too.
110
- # @param allow_nil [nil, Boolean] If `true`,
111
- # nil receivers are is ignored, and nil is returned instead.
112
- # @param prefix [nil, Symbol] A prefix for the delegating method name.
113
- # @param include_subdeps [nil, Boolean] If `true`,
114
- # sub-dependencies are also included.
115
- # @return [void]
116
- #
117
- # @example delegate name from raw_user
118
- # delegate_dependency :name, to: :raw_user
119
- #
120
- # @example delegate name from raw_user, but expose as user_name
121
- # delegate_dependency :name, to: :raw_user, prefix: :user
122
- def delegate_dependency(*methods, to:, allow_nil: nil, prefix: nil, include_subdeps: nil)
123
- method_prefix = prefix ? "#{prefix}_" : ""
124
- methods.each do |meth_name|
125
- pmeth_name = :"#{method_prefix}#{meth_name}"
126
- if include_subdeps
127
- dependency to=>meth_name
128
- else
129
- dependency to
130
- end
131
- if allow_nil
132
- define_method(pmeth_name) do
133
- send(to)&.public_send(meth_name)
134
- end
135
- else
136
- define_method(pmeth_name) do
137
- send(to).public_send(meth_name)
138
- end
139
- end
140
- computed pmeth_name
141
- end
142
- end
143
-
144
- # Declares a loaded attribute. See {#dependency} and {#define_primary_loader} too.
145
- #
146
- # `define_loader :foo do ... end` generates a reader `foo` and a writer `foo=`.
147
- # The writer is only meant to be used in the loader.
148
- #
149
- # The responsibility of loader is to call `foo=` for all the given objects,
150
- # or set `computed_model_error` otherwise.
151
- #
152
- # @param meth_name [Symbol] the name of the loaded attribute.
153
- # @param key [Proc] The proc to collect keys.
154
- # @return [void]
155
- # @yield [keys, subdeps, **options]
156
- # @yieldparam objects [Array] The ids of the loaded attributes.
157
- # @yieldparam subdeps [Hash] sub-dependencies
158
- # @yieldparam options [Hash] A verbatim copy of what is passed to {#bulk_load_and_compute}.
159
- # @yieldreturn [Hash]
160
- #
161
- # @example define a loader for ActiveRecord-based models
162
- # define_loader :user_aux_data, key: -> { id } do |user_ids, subdeps, **options|
163
- # UserAuxData.where(user_id: user_ids).preload(subdeps).group_by(&:id)
164
- # end
165
- def define_loader(meth_name, key:, &block)
166
- raise ArgumentError, "No block given" unless block
167
-
168
- var_name = :"@#{meth_name}"
169
-
170
- @__computed_model_loaders[meth_name] = Loader.new(key, block)
171
-
172
- define_method(meth_name) do
173
- raise NotLoaded, "the field #{meth_name} is not loaded" unless instance_variable_defined?(var_name)
174
- instance_variable_get(var_name)
175
- end
176
- attr_writer meth_name
177
- end
178
-
179
- # Declares a primary attribute. See {#define_loader} and {#dependency} too.
180
- #
181
- # `define_primary_loader :foo do ... end` generates a reader `foo` and
182
- # a writer `foo=`.
183
- # The writer is only meant to be used in the loader.
184
- #
185
- # The responsibility of the primary loader is to list up all the relevant
186
- # primary models, and initialize instances of the subclass of ComputedModel
187
- # with `@foo` set to the primary model which is just being found.
188
- #
189
- # @param meth_name [Symbol] the name of the loaded attribute.
190
- # @return [void]
191
- # @yield [**options]
192
- # @yieldparam options [Hash] A verbatim copy of what is passed to {#bulk_load_and_compute}.
193
- # @yieldreturn [void]
194
- #
195
- # @example define a loader for ActiveRecord-based models
196
- # define_loader :raw_user do |users, subdeps, **options|
197
- # user_ids = users.map(&:id)
198
- # raw_users = RawUser.where(id: user_ids).preload(subdeps).index_by(&:id)
199
- # users.each do |user|
200
- # # Even if it doesn't exist, you must explicitly assign nil to the field.
201
- # user.raw_user = raw_users[user.id]
202
- # end
203
- # end
204
- def define_primary_loader(meth_name, &block)
205
- raise ArgumentError, "No block given" unless block
206
- raise ArgumentError, "Primary loader has already been defined" if @__computed_model_primary_attribute
207
-
208
- var_name = :"@#{meth_name}"
209
-
210
- @__computed_model_primary_loader = block
211
- @__computed_model_primary_attribute = meth_name
212
-
213
- define_method(meth_name) do
214
- raise NotLoaded, "the field #{meth_name} is not loaded" unless instance_variable_defined?(var_name)
215
- instance_variable_get(var_name)
216
- end
217
- attr_writer meth_name
218
- end
219
-
220
- # The core routine for batch-loading.
221
- #
222
- # @param deps [Array<Symbol, Hash{Symbol=>Array}>] A set of dependencies.
223
- # @param options [Hash] An arbitrary hash to pass to loaders
224
- # defined by {#define_loader}.
225
- # @return [Array<Object>] The array of the requested models.
226
- # Based on what the primary loader returns.
227
- def bulk_load_and_compute(deps, **options)
228
- unless @__computed_model_primary_attribute
229
- raise ArgumentError, "No primary loader defined"
230
- end
231
-
232
- objs = orig_objs = nil
233
- plan = computing_plan(deps)
234
- plan.load_order.each do |dep_name|
235
- if @__computed_model_primary_attribute == dep_name
236
- orig_objs = @__computed_model_primary_loader.call(plan.subdeps_hash[dep_name], **options)
237
- objs = orig_objs.dup
238
- elsif @__computed_model_dependencies.key?(dep_name)
239
- raise "Bug: objs is nil" if objs.nil?
240
-
241
- objs.each do |obj|
242
- obj.send(:"compute_#{dep_name}")
243
- end
244
- elsif @__computed_model_loaders.key?(dep_name)
245
- raise "Bug: objs is nil" if objs.nil?
246
-
247
- l = @__computed_model_loaders[dep_name]
248
- keys = objs.map { |o| o.instance_exec(&(l.key_proc)) }
249
- subobj_by_key = l.load_proc.call(keys, plan.subdeps_hash[dep_name], **options)
250
- objs.zip(keys) do |obj, key|
251
- obj.send(:"#{dep_name}=", subobj_by_key[key])
252
- end
253
- else
254
- raise "No dependency info for #{self}##{dep_name}"
255
- end
256
- objs.reject! { |obj| !obj.computed_model_error.nil? }
257
- end
258
-
259
- orig_objs
260
- end
261
-
262
- # @param deps [Array]
263
- # @return [Plan]
264
- def computing_plan(deps)
265
- normalized = ComputedModel.normalize_dependencies(deps)
266
- load_order = []
267
- subdeps_hash = {}
268
- visiting = Set[]
269
- visited = Set[]
270
- if @__computed_model_primary_attribute
271
- load_order << @__computed_model_primary_attribute
272
- visiting.add @__computed_model_primary_attribute
273
- visited.add @__computed_model_primary_attribute
274
- subdeps_hash[@__computed_model_primary_attribute] ||= []
275
- end
276
- normalized.each do |dep_name, dep_subdeps|
277
- computing_plan_dfs(dep_name, dep_subdeps, load_order, subdeps_hash, visiting, visited)
278
- end
279
-
280
- Plan.new(load_order, subdeps_hash)
281
- end
282
-
283
- # @param meth_name [Symbol]
284
- # @param meth_subdeps [Array]
285
- # @param load_order [Array<Symbol>]
286
- # @param subdeps_hash [Hash{Symbol=>Array}]
287
- # @param visiting [Set<Symbol>]
288
- # @param visited [Set<Symbol>]
289
- private def computing_plan_dfs(meth_name, meth_subdeps, load_order, subdeps_hash, visiting, visited)
290
- (subdeps_hash[meth_name] ||= []).push(*meth_subdeps)
291
- return if visited.include?(meth_name)
292
- raise "Cyclic dependency for #{self}##{meth_name}" if visiting.include?(meth_name)
293
- visiting.add(meth_name)
294
-
295
- if @__computed_model_dependencies.key?(meth_name)
296
- @__computed_model_dependencies[meth_name].each do |dep_name, dep_subdeps|
297
- computing_plan_dfs(dep_name, dep_subdeps, load_order, subdeps_hash, visiting, visited)
298
- end
299
- elsif @__computed_model_loaders.key?(meth_name)
300
- else
301
- raise "No dependency info for #{self}##{meth_name}"
302
- end
24
+ # An error raised when you tried to read from a loaded/computed attribute,
25
+ # but that attribute isn't listed in the dependencies list.
26
+ class ForbiddenDependency < StandardError; end
303
27
 
304
- load_order << meth_name
305
- visiting.delete(meth_name)
306
- visited.add(meth_name)
307
- end
308
- end
28
+ # An error raised when the dependency graph contains a cycle.
29
+ class CyclicDependency < StandardError; end
309
30
 
310
- # @param deps [Array<(Symbol, Hash)>, Hash, Symbol]
311
- # @return [Hash{Symbol=>Array}]
31
+ # Normalizes dependency list as a hash.
32
+ #
33
+ # Normally you don't need to call it directly.
34
+ # {ComputedModel::Model::ClassMethods#dependency}, {ComputedModel::Model::ClassMethods#bulk_load_and_compute}, and
35
+ # {ComputedModel::NormalizableArray#normalized} will internally use this function.
36
+ #
37
+ # @param deps [Array<(Symbol, Hash)>, Hash, Symbol] dependency list
38
+ # @return [Hash{Symbol=>Array}] normalized dependency hash
39
+ # @raise [RuntimeError] if the dependency list contains values other than Symbol or Hash
40
+ # @example
41
+ # ComputedModel.normalize_dependencies([:foo, :bar])
42
+ # # => { foo: [true], bar: [true] }
43
+ #
44
+ # @example
45
+ # ComputedModel.normalize_dependencies([:foo, bar: :baz])
46
+ # # => { foo: [true], bar: [true, :baz] }
47
+ #
48
+ # @example
49
+ # ComputedModel.normalize_dependencies(foo: -> (subfields) { true })
50
+ # # => { foo: [#<Proc:...>] }
312
51
  def self.normalize_dependencies(deps)
313
52
  normalized = {}
314
53
  deps = [deps] if deps.is_a?(Hash)
315
54
  Array(deps).each do |elem|
316
55
  case elem
317
56
  when Symbol
318
- normalized[elem] ||= []
57
+ normalized[elem] ||= [true]
319
58
  when Hash
320
59
  elem.each do |k, v|
321
60
  v = [v] if v.is_a?(Hash)
322
61
  normalized[k] ||= []
323
62
  normalized[k].push(*Array(v))
63
+ normalized[k].push(true) if v == []
324
64
  end
325
65
  else; raise "Invalid dependency: #{elem.inspect}"
326
66
  end
@@ -328,18 +68,35 @@ module ComputedModel
328
68
  normalized
329
69
  end
330
70
 
331
- # An error field to prevent {ComputedModel::ClassMethods#bulk_load_and_compute}
332
- # from loading remaining attributes.
71
+ # Removes `nil`, `true` and `false` from the given array.
333
72
  #
334
- # @return [StandardError]
335
- attr_accessor :computed_model_error
73
+ # Normally you don't need to call it directly.
74
+ # {ComputedModel::Model::ClassMethods#define_loader},
75
+ # {ComputedModel::Model::ClassMethods#define_primary_loader}, and
76
+ # {ComputedModel::NormalizableArray#normalized} will internally use this function.
77
+ #
78
+ # @param subfields [Array] subfield selector list
79
+ # @return [Array] the filtered one
80
+ # @example
81
+ # ComputedModel.filter_subfields([false, {}, true, nil, { foo: :bar }])
82
+ # # => [{}, { foo: :bar }]
83
+ def self.filter_subfields(subfields)
84
+ subfields.select { |x| x && x != true }
85
+ end
336
86
 
337
- def self.included(klass)
338
- super
339
- klass.extend ClassMethods
340
- klass.instance_variable_set(:@__computed_model_dependencies, {})
341
- klass.instance_variable_set(:@__computed_model_loaders, {})
342
- klass.instance_variable_set(:@__computed_model_primary_loader, nil)
343
- klass.instance_variable_set(:@__computed_model_primary_attribute, nil)
87
+ # Convenience class to easily access normalized version of dependencies.
88
+ #
89
+ # You don't need to directly use it.
90
+ #
91
+ # - {ComputedModel::Model#current_subfields} returns NormalizableArray.
92
+ # - Procs passed to {ComputedModel::Model::ClassMethods#dependency} will receive NormalizeArray.
93
+ class NormalizableArray < Array
94
+ # Returns the normalized hash of the dependencies.
95
+ # @return [Hash{Symbol=>Array}] the normalized hash of the dependencies
96
+ # @raise [RuntimeError] if the list isn't valid as a dependency list.
97
+ # See {ComputedModel.normalize_dependencies} for details.
98
+ def normalized
99
+ @normalized ||= ComputedModel.normalize_dependencies(ComputedModel.filter_subfields(self))
100
+ end
344
101
  end
345
102
  end