computed_model 0.1.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 +7 -0
- data/.gitignore +13 -0
- data/.rspec +3 -0
- data/.travis.yml +6 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +4 -0
- data/README.md +128 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/computed_model.gemspec +41 -0
- data/lib/computed_model.rb +177 -0
- data/lib/computed_model/version.rb +5 -0
- metadata +108 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 0d4c9e19dfb66454b769cf0cb92e95fb01de3aeace7276953182fcebf82fdcd2
|
4
|
+
data.tar.gz: 836d822953b14420ee7ed6f4dd3d17fba896b41e2caa638eb4fe9e1d59323df5
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: e1d34a7a00c560ea97d96927e643d40e02164f94181601e39bcde334cd86f9c5f618c7d6c62556adb5331f2f3f8e61bc015d295f82ee03237d75d4dc8c1d3240
|
7
|
+
data.tar.gz: 556daff44be9ae2408c9e5d11590fecd440d802067071b53563ee6646ca01296bafa01944df8e691bc770f9c7b613a66631cf60cffa5472abb9673b1ef82b95c
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,128 @@
|
|
1
|
+
# ComputedModel
|
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.
|
6
|
+
|
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).
|
9
|
+
|
10
|
+
## Installation
|
11
|
+
|
12
|
+
Add this line to your application's Gemfile:
|
13
|
+
|
14
|
+
```ruby
|
15
|
+
gem 'computed_model'
|
16
|
+
```
|
17
|
+
|
18
|
+
And then execute:
|
19
|
+
|
20
|
+
$ bundle
|
21
|
+
|
22
|
+
Or install it yourself as:
|
23
|
+
|
24
|
+
$ gem install computed_model
|
25
|
+
|
26
|
+
## Usage
|
27
|
+
|
28
|
+
Include `ComputedModel` in your model class. You may also need an `attr_reader` for the primary key.
|
29
|
+
|
30
|
+
```ruby
|
31
|
+
class User
|
32
|
+
attr_reader :id
|
33
|
+
|
34
|
+
include ComputedModel
|
35
|
+
|
36
|
+
def initialize(id)
|
37
|
+
@id = id
|
38
|
+
end
|
39
|
+
end
|
40
|
+
```
|
41
|
+
|
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
|
49
|
+
|
50
|
+
Use `ComputedModel::ClassMethods#define_loader` to define loaded attributes.
|
51
|
+
|
52
|
+
```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
|
62
|
+
```
|
63
|
+
|
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.
|
66
|
+
|
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.
|
71
|
+
|
72
|
+
The keyword arguments are also a verbatim copy of what you pass to `ComputedModel::ClassMethods#bulk_load_and_compute`.
|
73
|
+
|
74
|
+
## Computed attributes
|
75
|
+
|
76
|
+
Use `ComputedModel::ClassMethods#computed` and `#dependency` to define computed attributes.
|
77
|
+
|
78
|
+
```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
|
85
|
+
end
|
86
|
+
end
|
87
|
+
```
|
88
|
+
|
89
|
+
## Batch loading
|
90
|
+
|
91
|
+
Once you defined loaded and computed attributes, you can batch-load them using `ComputedModel::ClassMethods#bulk_load_and_compute`.
|
92
|
+
|
93
|
+
Typically you need to create a wrapper for the batch loader like:
|
94
|
+
|
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
|
104
|
+
end
|
105
|
+
```
|
106
|
+
|
107
|
+
They you can retrieve users with only a specified attributes in a batch:
|
108
|
+
|
109
|
+
```ruby
|
110
|
+
users = User.list([1, 2, 3], with: [:name, :name_with_playing_music, :premium_user])
|
111
|
+
```
|
112
|
+
|
113
|
+
## License
|
114
|
+
|
115
|
+
This library is distributed under MIT license.
|
116
|
+
|
117
|
+
Copyright (c) 2020 Masaki Hara
|
118
|
+
Copyright (c) 2020 Wantedly, Inc.
|
119
|
+
|
120
|
+
## Development
|
121
|
+
|
122
|
+
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.
|
123
|
+
|
124
|
+
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).
|
125
|
+
|
126
|
+
## Contributing
|
127
|
+
|
128
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/qnighy/computed_model.
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "computed_model"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
lib = File.expand_path("lib", __dir__)
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
5
|
+
require "computed_model/version"
|
6
|
+
|
7
|
+
Gem::Specification.new do |spec|
|
8
|
+
spec.name = "computed_model"
|
9
|
+
spec.version = ComputedModel::VERSION
|
10
|
+
spec.authors = ["Masaki Hara", "Wantedly, Inc."]
|
11
|
+
spec.email = ["ackie.h.gmai@gmail.com", "dev@wantedly.com"]
|
12
|
+
|
13
|
+
spec.summary = %q{Batch loader with dependency resolution and computed fields}
|
14
|
+
spec.description = <<~DSC
|
15
|
+
ComputedModel is a helper for building a read-only model (sometimes called a view)
|
16
|
+
from multiple sources of models.
|
17
|
+
It comes with batch loading and dependency resolution for better performance.
|
18
|
+
|
19
|
+
It is designed to be universal. It's as easy as pie to pull data from both
|
20
|
+
ActiveRecord and remote server (such as ActiveResource).
|
21
|
+
DSC
|
22
|
+
spec.homepage = "https://github.com/wantedly/computed_model"
|
23
|
+
spec.licenses = ['MIT']
|
24
|
+
|
25
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
26
|
+
spec.metadata["source_code_uri"] = "https://github.com/wantedly/computed_model"
|
27
|
+
spec.metadata["changelog_uri"] = "https://github.com/wantedly/computed_model/blob/master/CHANGELOG.md"
|
28
|
+
|
29
|
+
# Specify which files should be added to the gem when it is released.
|
30
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
31
|
+
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
|
32
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
33
|
+
end
|
34
|
+
spec.bindir = "exe"
|
35
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
36
|
+
spec.require_paths = ["lib"]
|
37
|
+
|
38
|
+
spec.add_development_dependency "bundler", "~> 2.0"
|
39
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
40
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
41
|
+
end
|
@@ -0,0 +1,177 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "computed_model/version"
|
4
|
+
require 'set'
|
5
|
+
|
6
|
+
module ComputedModel
|
7
|
+
class NotLoaded < StandardError; end
|
8
|
+
|
9
|
+
Plan = Struct.new(:load_order, :subdeps_hash)
|
10
|
+
|
11
|
+
module ClassMethods
|
12
|
+
# @param deps [Array]
|
13
|
+
def dependency(*deps)
|
14
|
+
@__computed_model_next_dependency ||= []
|
15
|
+
@__computed_model_next_dependency.push(*deps)
|
16
|
+
end
|
17
|
+
|
18
|
+
# @param meth_name [Symbol]
|
19
|
+
def computed(meth_name)
|
20
|
+
var_name = :"@#{meth_name}"
|
21
|
+
meth_name_orig = :"#{meth_name}_orig"
|
22
|
+
compute_meth_name = :"compute_#{meth_name}"
|
23
|
+
|
24
|
+
@__computed_model_dependencies[meth_name] = ComputedModel.normalize_dependencies(@__computed_model_next_dependency)
|
25
|
+
remove_instance_variable(:@__computed_model_next_dependency)
|
26
|
+
|
27
|
+
alias_method meth_name_orig, meth_name
|
28
|
+
define_method(meth_name) do
|
29
|
+
raise NotLoaded, "the field #{meth_name} is not loaded" unless instance_variable_defined?(var_name)
|
30
|
+
instance_variable_get(var_name)
|
31
|
+
end
|
32
|
+
define_method(compute_meth_name) do
|
33
|
+
instance_variable_set(var_name, send(meth_name_orig))
|
34
|
+
end
|
35
|
+
if public_method_defined?(meth_name_orig)
|
36
|
+
public meth_name
|
37
|
+
elsif protected_method_defined?(meth_name_orig)
|
38
|
+
protected meth_name
|
39
|
+
elsif private_method_defined?(meth_name_orig)
|
40
|
+
private meth_name
|
41
|
+
end
|
42
|
+
|
43
|
+
meth_name
|
44
|
+
end
|
45
|
+
|
46
|
+
# @param methods [Array<Symbol>]
|
47
|
+
# @param to [Symbol]
|
48
|
+
# @param allow_nil [nil, Boolean]
|
49
|
+
# @param prefix [nil, Symbol]
|
50
|
+
# @param include_subdeps [nil, Boolean]
|
51
|
+
# @return [void]
|
52
|
+
def delegate_dependency(*methods, to:, allow_nil: nil, prefix: nil, include_subdeps: nil)
|
53
|
+
method_prefix = prefix ? "#{prefix_}" : ""
|
54
|
+
methods.each do |meth_name|
|
55
|
+
pmeth_name = :"#{method_prefix}#{meth_name}"
|
56
|
+
if include_subdeps
|
57
|
+
dependency to=>meth_name
|
58
|
+
else
|
59
|
+
dependency to
|
60
|
+
end
|
61
|
+
if allow_nil
|
62
|
+
define_method(pmeth_name) do
|
63
|
+
send(to)&.public_send(meth_name)
|
64
|
+
end
|
65
|
+
else
|
66
|
+
define_method(pmeth_name) do
|
67
|
+
send(to).public_send(meth_name)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
computed pmeth_name
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# @param meth_name [Symbol]
|
75
|
+
# @yieldparam objects [Array]
|
76
|
+
# @yieldparam options [Hash]
|
77
|
+
# @yieldreturn [void]
|
78
|
+
def define_loader(meth_name, &block)
|
79
|
+
raise ArgumentError, "No block given" unless block
|
80
|
+
|
81
|
+
var_name = :"@#{meth_name}"
|
82
|
+
|
83
|
+
@__computed_model_loaders[meth_name] = block
|
84
|
+
|
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
|
+
attr_writer meth_name
|
90
|
+
end
|
91
|
+
|
92
|
+
# @param objs [Array]
|
93
|
+
# @param deps [Array]
|
94
|
+
def bulk_load_and_compute(objs, deps, **options)
|
95
|
+
plan = computing_plan(deps)
|
96
|
+
plan.load_order.each do |dep_name|
|
97
|
+
if @__computed_model_dependencies.key?(dep_name)
|
98
|
+
objs.each do |obj|
|
99
|
+
obj.send(:"compute_#{dep_name}")
|
100
|
+
end
|
101
|
+
elsif @__computed_model_loaders.key?(dep_name)
|
102
|
+
@__computed_model_loaders[dep_name].call(objs, plan.subdeps_hash[dep_name], **options)
|
103
|
+
else
|
104
|
+
raise "No dependency info for #{self}##{dep_name}"
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
# @param deps [Array]
|
110
|
+
# @return [Plan]
|
111
|
+
def computing_plan(deps)
|
112
|
+
normalized = ComputedModel.normalize_dependencies(deps)
|
113
|
+
load_order = []
|
114
|
+
subdeps_hash = {}
|
115
|
+
visiting = Set[]
|
116
|
+
visited = Set[]
|
117
|
+
normalized.each do |dep_name, dep_subdeps|
|
118
|
+
computing_plan_dfs(dep_name, dep_subdeps, load_order, subdeps_hash, visiting, visited)
|
119
|
+
end
|
120
|
+
|
121
|
+
Plan.new(load_order, subdeps_hash)
|
122
|
+
end
|
123
|
+
|
124
|
+
# @param meth_name [Symbol]
|
125
|
+
# @param meth_subdeps [Array]
|
126
|
+
# @param load_order [Array<Symbol>]
|
127
|
+
# @param subdeps_hash [Hash{Symbol=>Array}]
|
128
|
+
# @param visiting [Set<Symbol>]
|
129
|
+
# @param visited [Set<Symbol>]
|
130
|
+
private def computing_plan_dfs(meth_name, meth_subdeps, load_order, subdeps_hash, visiting, visited)
|
131
|
+
(subdeps_hash[meth_name] ||= []).push(*meth_subdeps)
|
132
|
+
return if visited.include?(meth_name)
|
133
|
+
raise "Cyclic dependency for #{self}##{meth_name}" if visiting.include?(meth_name)
|
134
|
+
visiting.add(meth_name)
|
135
|
+
|
136
|
+
if @__computed_model_dependencies.key?(meth_name)
|
137
|
+
@__computed_model_dependencies[meth_name].each do |dep_name, dep_subdeps|
|
138
|
+
computing_plan_dfs(dep_name, dep_subdeps, load_order, subdeps_hash, visiting, visited)
|
139
|
+
end
|
140
|
+
elsif @__computed_model_loaders.key?(meth_name)
|
141
|
+
else
|
142
|
+
raise "No dependency info for #{self}##{meth_name}"
|
143
|
+
end
|
144
|
+
|
145
|
+
load_order << meth_name
|
146
|
+
visiting.delete(meth_name)
|
147
|
+
visited.add(meth_name)
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
# @param deps [Array<Symbol, Hash>]
|
152
|
+
# @return [Hash{Symbol=>Array}]
|
153
|
+
def self.normalize_dependencies(deps)
|
154
|
+
normalized = {}
|
155
|
+
deps.each do |elem|
|
156
|
+
case elem
|
157
|
+
when Symbol
|
158
|
+
normalized[elem] ||= []
|
159
|
+
when Hash
|
160
|
+
elem.each do |k, v|
|
161
|
+
v = [v] if v.is_a?(Hash)
|
162
|
+
normalized[k] ||= []
|
163
|
+
normalized[k].push(*Array(v))
|
164
|
+
end
|
165
|
+
else; raise "Invalid dependency: #{elem.inspect}"
|
166
|
+
end
|
167
|
+
end
|
168
|
+
normalized
|
169
|
+
end
|
170
|
+
|
171
|
+
def self.included(klass)
|
172
|
+
super
|
173
|
+
klass.extend ClassMethods
|
174
|
+
klass.instance_variable_set(:@__computed_model_dependencies, {})
|
175
|
+
klass.instance_variable_set(:@__computed_model_loaders, {})
|
176
|
+
end
|
177
|
+
end
|
metadata
ADDED
@@ -0,0 +1,108 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: computed_model
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Masaki Hara
|
8
|
+
- Wantedly, Inc.
|
9
|
+
autorequire:
|
10
|
+
bindir: exe
|
11
|
+
cert_chain: []
|
12
|
+
date: 2020-03-16 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: bundler
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
requirements:
|
18
|
+
- - "~>"
|
19
|
+
- !ruby/object:Gem::Version
|
20
|
+
version: '2.0'
|
21
|
+
type: :development
|
22
|
+
prerelease: false
|
23
|
+
version_requirements: !ruby/object:Gem::Requirement
|
24
|
+
requirements:
|
25
|
+
- - "~>"
|
26
|
+
- !ruby/object:Gem::Version
|
27
|
+
version: '2.0'
|
28
|
+
- !ruby/object:Gem::Dependency
|
29
|
+
name: rake
|
30
|
+
requirement: !ruby/object:Gem::Requirement
|
31
|
+
requirements:
|
32
|
+
- - "~>"
|
33
|
+
- !ruby/object:Gem::Version
|
34
|
+
version: '13.0'
|
35
|
+
type: :development
|
36
|
+
prerelease: false
|
37
|
+
version_requirements: !ruby/object:Gem::Requirement
|
38
|
+
requirements:
|
39
|
+
- - "~>"
|
40
|
+
- !ruby/object:Gem::Version
|
41
|
+
version: '13.0'
|
42
|
+
- !ruby/object:Gem::Dependency
|
43
|
+
name: rspec
|
44
|
+
requirement: !ruby/object:Gem::Requirement
|
45
|
+
requirements:
|
46
|
+
- - "~>"
|
47
|
+
- !ruby/object:Gem::Version
|
48
|
+
version: '3.0'
|
49
|
+
type: :development
|
50
|
+
prerelease: false
|
51
|
+
version_requirements: !ruby/object:Gem::Requirement
|
52
|
+
requirements:
|
53
|
+
- - "~>"
|
54
|
+
- !ruby/object:Gem::Version
|
55
|
+
version: '3.0'
|
56
|
+
description: |
|
57
|
+
ComputedModel is a helper for building a read-only model (sometimes called a view)
|
58
|
+
from multiple sources of models.
|
59
|
+
It comes with batch loading and dependency resolution for better performance.
|
60
|
+
|
61
|
+
It is designed to be universal. It's as easy as pie to pull data from both
|
62
|
+
ActiveRecord and remote server (such as ActiveResource).
|
63
|
+
email:
|
64
|
+
- ackie.h.gmai@gmail.com
|
65
|
+
- dev@wantedly.com
|
66
|
+
executables: []
|
67
|
+
extensions: []
|
68
|
+
extra_rdoc_files: []
|
69
|
+
files:
|
70
|
+
- ".gitignore"
|
71
|
+
- ".rspec"
|
72
|
+
- ".travis.yml"
|
73
|
+
- CHANGELOG.md
|
74
|
+
- Gemfile
|
75
|
+
- README.md
|
76
|
+
- Rakefile
|
77
|
+
- bin/console
|
78
|
+
- bin/setup
|
79
|
+
- computed_model.gemspec
|
80
|
+
- lib/computed_model.rb
|
81
|
+
- lib/computed_model/version.rb
|
82
|
+
homepage: https://github.com/wantedly/computed_model
|
83
|
+
licenses:
|
84
|
+
- MIT
|
85
|
+
metadata:
|
86
|
+
homepage_uri: https://github.com/wantedly/computed_model
|
87
|
+
source_code_uri: https://github.com/wantedly/computed_model
|
88
|
+
changelog_uri: https://github.com/wantedly/computed_model/blob/master/CHANGELOG.md
|
89
|
+
post_install_message:
|
90
|
+
rdoc_options: []
|
91
|
+
require_paths:
|
92
|
+
- lib
|
93
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
94
|
+
requirements:
|
95
|
+
- - ">="
|
96
|
+
- !ruby/object:Gem::Version
|
97
|
+
version: '0'
|
98
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
99
|
+
requirements:
|
100
|
+
- - ">="
|
101
|
+
- !ruby/object:Gem::Version
|
102
|
+
version: '0'
|
103
|
+
requirements: []
|
104
|
+
rubygems_version: 3.0.3
|
105
|
+
signing_key:
|
106
|
+
specification_version: 4
|
107
|
+
summary: Batch loader with dependency resolution and computed fields
|
108
|
+
test_files: []
|