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.
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