computed_model 0.1.0 → 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 +18 -0
- data/.github/workflows/test.yml +24 -0
- data/.yardopts +6 -0
- data/CHANGELOG.md +126 -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 +112 -70
- data/Rakefile +14 -0
- data/computed_model.gemspec +10 -2
- data/lib/computed_model.rb +78 -153
- 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 +102 -7
- data/.travis.yml +0 -6
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,120 +1,162 @@
|
|
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
|
-
|
51
|
+
define_primary_loader :raw_user do ... end
|
52
|
+
define_loader :preference do ... end
|
53
|
+
define_loader :profile do ... end
|
33
54
|
|
34
|
-
|
35
|
-
|
36
|
-
|
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
|
-
|
43
|
-
|
44
|
-
- **Loaded attributes** for external data. You define batch loading strategies for loaded attributes.
|
45
|
-
- **Computed attributes** for data derived from loaded attributes or other computed attributes.
|
46
|
-
You define a usual `def` with special dependency annotations.
|
47
|
-
|
48
|
-
## Loaded attributes
|
62
|
+
## Installation
|
49
63
|
|
50
|
-
|
64
|
+
Add this line to your application's Gemfile:
|
51
65
|
|
52
66
|
```ruby
|
53
|
-
|
54
|
-
define_loader :raw_user do |users, subdeps, **options|
|
55
|
-
user_ids = users.map(&:id)
|
56
|
-
raw_users = RawUser.where(id: user_ids).preload(subdeps).index_by(&:id)
|
57
|
-
users.each do |user|
|
58
|
-
# Even if it doesn't exist, you must explicitly assign nil to the field.
|
59
|
-
user.raw_user = raw_users[user.id]
|
60
|
-
end
|
61
|
-
end
|
67
|
+
gem 'computed_model', '~> 0.3.0'
|
62
68
|
```
|
63
69
|
|
64
|
-
|
65
|
-
The loader's job is to assign something to the corresponding field of each instance.
|
70
|
+
And then execute:
|
66
71
|
|
67
|
-
|
68
|
-
The value of `subdeps` is an array, but further details are up to you
|
69
|
-
(it's just a verbatim copy of what you pass to `ComputedModel::ClassMethods#dependency`).
|
70
|
-
It's customary to take something ActiveRecord's `preload` accepts.
|
72
|
+
$ bundle
|
71
73
|
|
72
|
-
|
74
|
+
Or install it yourself as:
|
73
75
|
|
74
|
-
|
76
|
+
$ gem install computed_model
|
75
77
|
|
76
|
-
|
78
|
+
## Working example
|
77
79
|
|
78
80
|
```ruby
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
81
|
+
require 'computed_model'
|
82
|
+
|
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)
|
86
|
+
|
87
|
+
class User
|
88
|
+
include ComputedModel::Model
|
89
|
+
|
90
|
+
attr_reader :id
|
91
|
+
def initialize(raw_user)
|
92
|
+
@id = raw_user.id
|
93
|
+
@raw_user = raw_user
|
94
|
+
end
|
95
|
+
|
96
|
+
def self.list(ids, with:)
|
97
|
+
bulk_load_and_compute(Array(with), ids: ids)
|
98
|
+
end
|
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) }
|
85
108
|
end
|
86
|
-
end
|
87
|
-
```
|
88
109
|
|
89
|
-
|
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
|
90
118
|
|
91
|
-
|
119
|
+
delegate_dependency :name, to: :raw_user
|
120
|
+
delegate_dependency :title, to: :raw_user
|
121
|
+
delegate_dependency :name_public, to: :preference
|
92
122
|
|
93
|
-
|
123
|
+
dependency :name, :name_public
|
124
|
+
computed def public_name
|
125
|
+
name_public ? name : "Anonymous"
|
126
|
+
end
|
94
127
|
|
95
|
-
|
96
|
-
def
|
97
|
-
|
98
|
-
|
99
|
-
# Batch-load attributes into the objects.
|
100
|
-
bulk_load_and_compute(objs, Array(with) + [:raw_user])
|
101
|
-
# Reject objects without primary model.
|
102
|
-
objs.reject! { |u| u.raw_user.nil? }
|
103
|
-
objs
|
128
|
+
dependency :public_name, :title
|
129
|
+
computed def public_name_with_title
|
130
|
+
"#{title}#{public_name}"
|
131
|
+
end
|
104
132
|
end
|
105
|
-
```
|
106
133
|
|
107
|
-
|
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)
|
108
138
|
|
109
|
-
|
110
|
-
users
|
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"]
|
142
|
+
|
143
|
+
# In this case, preference will not be loaded.
|
144
|
+
users = User.list([1, 2], with: [:title])
|
145
|
+
users.map(&:title) # => ["Mr. ", "Dr. "]
|
111
146
|
```
|
112
147
|
|
148
|
+
## Next read
|
149
|
+
|
150
|
+
- [Basic concepts and features](CONCEPTS.md)
|
151
|
+
|
113
152
|
## License
|
114
153
|
|
115
154
|
This library is distributed under MIT license.
|
116
155
|
|
117
156
|
Copyright (c) 2020 Masaki Hara
|
157
|
+
|
158
|
+
Copyright (c) 2020 Masayuki Izumi
|
159
|
+
|
118
160
|
Copyright (c) 2020 Wantedly, Inc.
|
119
161
|
|
120
162
|
## Development
|
@@ -125,4 +167,4 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
|
|
125
167
|
|
126
168
|
## Contributing
|
127
169
|
|
128
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/
|
170
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/wantedly/computed_model.
|
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
@@ -7,8 +7,8 @@ require "computed_model/version"
|
|
7
7
|
Gem::Specification.new do |spec|
|
8
8
|
spec.name = "computed_model"
|
9
9
|
spec.version = ComputedModel::VERSION
|
10
|
-
spec.authors = ["Masaki Hara", "Wantedly, Inc."]
|
11
|
-
spec.email = ["ackie.h.gmai@gmail.com", "dev@wantedly.com"]
|
10
|
+
spec.authors = ["Masaki Hara", "Masayuki Izumi", "Wantedly, Inc."]
|
11
|
+
spec.email = ["ackie.h.gmai@gmail.com", "m@izum.in", "dev@wantedly.com"]
|
12
12
|
|
13
13
|
spec.summary = %q{Batch loader with dependency resolution and computed fields}
|
14
14
|
spec.description = <<~DSC
|
@@ -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
|