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.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +8 -0
- data/.github/workflows/test.yml +7 -2
- data/.yardopts +5 -0
- data/CHANGELOG.md +107 -0
- data/CONCEPTS.ja.md +324 -0
- data/CONCEPTS.md +330 -0
- data/Migration-0.3.md +343 -0
- data/README.ja.md +168 -0
- data/README.md +102 -86
- data/Rakefile +14 -0
- data/computed_model.gemspec +8 -0
- data/lib/computed_model.rb +67 -310
- data/lib/computed_model/dep_graph.rb +245 -0
- data/lib/computed_model/model.rb +447 -0
- data/lib/computed_model/plan.rb +48 -0
- data/lib/computed_model/version.rb +1 -1
- metadata +94 -3
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
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
13
|
+
[日本語版README](README.ja.md)
|
|
11
14
|
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
+
However, it can lead to N+1 without care.
|
|
19
32
|
|
|
20
|
-
|
|
33
|
+
```ruby
|
|
34
|
+
# N+1 problem!
|
|
35
|
+
User.where(id: friend_ids).map(&:display_name)
|
|
36
|
+
```
|
|
21
37
|
|
|
22
|
-
|
|
38
|
+
To solve the N+1 problem, we need to enumerate dependencies of `#display_name` and preload them.
|
|
23
39
|
|
|
24
|
-
|
|
40
|
+
```ruby
|
|
41
|
+
User.where(id: friend_ids).preload(:preference, :profile).map(&:display_name)
|
|
42
|
+
# ^^^^^^^^^^^^^^^^^^^^^ breaks abstraction of display_name
|
|
43
|
+
```
|
|
25
44
|
|
|
26
|
-
|
|
45
|
+
This partially defeats the purpose of `#display_name`'s abstraction.
|
|
27
46
|
|
|
28
|
-
|
|
47
|
+
Computed solves the problem by connection the dependency-resolution to the batch loader.
|
|
29
48
|
|
|
30
49
|
```ruby
|
|
31
50
|
class User
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
51
|
+
define_primary_loader :raw_user do ... end
|
|
52
|
+
define_loader :preference do ... end
|
|
53
|
+
define_loader :profile do ... end
|
|
35
54
|
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
@id = raw_user.id
|
|
58
|
-
@raw_user = raw_user
|
|
59
|
-
end
|
|
67
|
+
gem 'computed_model', '~> 0.3.0'
|
|
68
|
+
```
|
|
60
69
|
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
+
Or install it yourself as:
|
|
73
75
|
|
|
74
|
-
|
|
76
|
+
$ gem install computed_model
|
|
75
77
|
|
|
76
|
-
|
|
77
|
-
requested data loaded to the primary attribute.
|
|
78
|
+
## Working example
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
```ruby
|
|
81
|
+
require 'computed_model'
|
|
81
82
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
+
attr_reader :id
|
|
91
|
+
def initialize(raw_user)
|
|
92
|
+
@id = raw_user.id
|
|
93
|
+
@raw_user = raw_user
|
|
94
|
+
end
|
|
92
95
|
|
|
93
|
-
|
|
94
|
-
|
|
96
|
+
def self.list(ids, with:)
|
|
97
|
+
bulk_load_and_compute(Array(with), ids: ids)
|
|
98
|
+
end
|
|
95
99
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
+
delegate_dependency :name, to: :raw_user
|
|
120
|
+
delegate_dependency :title, to: :raw_user
|
|
121
|
+
delegate_dependency :name_public, to: :preference
|
|
104
122
|
|
|
105
|
-
|
|
123
|
+
dependency :name, :name_public
|
|
124
|
+
computed def public_name
|
|
125
|
+
name_public ? name : "Anonymous"
|
|
126
|
+
end
|
|
106
127
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
148
|
+
## Next read
|
|
131
149
|
|
|
132
|
-
|
|
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
|
data/computed_model.gemspec
CHANGED
|
@@ -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
|
data/lib/computed_model.rb
CHANGED
|
@@ -1,326 +1,66 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "computed_model/version"
|
|
4
|
-
require
|
|
4
|
+
require "computed_model/plan"
|
|
5
|
+
require "computed_model/dep_graph"
|
|
6
|
+
require "computed_model/model"
|
|
5
7
|
|
|
6
|
-
#
|
|
8
|
+
# ComputedModel is a universal batch loader which comes with a dependency-resolution algorithm.
|
|
7
9
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
28
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
-
#
|
|
311
|
-
#
|
|
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
|
-
#
|
|
332
|
-
# from loading remaining attributes.
|
|
71
|
+
# Removes `nil`, `true` and `false` from the given array.
|
|
333
72
|
#
|
|
334
|
-
#
|
|
335
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|