computed_model 0.1.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
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 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'
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
51
+ define_primary_loader :raw_user do ... end
52
+ define_loader :preference do ... end
53
+ define_loader :profile do ... end
33
54
 
34
- include ComputedModel
35
-
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
- - **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
- Use `ComputedModel::ClassMethods#define_loader` to define loaded attributes.
64
+ Add this line to your application's Gemfile:
51
65
 
52
66
  ```ruby
53
- # Example: pulling data from ActiveRecord
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
- The first argument to the block is an array of the model instances.
65
- The loader's job is to assign something to the corresponding field of each instance.
70
+ And then execute:
66
71
 
67
- The second argument to the block is called a "sub-dependency".
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
- The keyword arguments are also a verbatim copy of what you pass to `ComputedModel::ClassMethods#bulk_load_and_compute`.
74
+ Or install it yourself as:
73
75
 
74
- ## Computed attributes
76
+ $ gem install computed_model
75
77
 
76
- Use `ComputedModel::ClassMethods#computed` and `#dependency` to define computed attributes.
78
+ ## Working example
77
79
 
78
80
  ```ruby
79
- dependency raw_user: [:name], user_music_info: [:latest]
80
- computed def name_with_playing_music
81
- if user_music_info.latest&.playing?
82
- "#{user.name} (Now playing: #{user_music_info.latest.name})"
83
- else
84
- user.name
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
- ## Batch loading
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
- Once you defined loaded and computed attributes, you can batch-load them using `ComputedModel::ClassMethods#bulk_load_and_compute`.
119
+ delegate_dependency :name, to: :raw_user
120
+ delegate_dependency :title, to: :raw_user
121
+ delegate_dependency :name_public, to: :preference
92
122
 
93
- Typically you need to create a wrapper for the batch loader like:
123
+ dependency :name, :name_public
124
+ computed def public_name
125
+ name_public ? name : "Anonymous"
126
+ end
94
127
 
95
- ```ruby
96
- def self.list(ids, with:)
97
- # Create placeholder objects.
98
- objs = ids.map { |id| User.new(id) }
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
- They you can retrieve users with only a specified attributes in a batch:
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
- ```ruby
110
- users = User.list([1, 2, 3], with: [:name, :name_with_playing_music, :premium_user])
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/qnighy/computed_model.
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
@@ -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