computed_model 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,13 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ /Gemfile.lock
11
+
12
+ # rspec failure tracking
13
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.travis.yml ADDED
@@ -0,0 +1,6 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.7
4
+ - 2.6
5
+ - 2.5
6
+ before_install: gem install bundler -v 2.1.4
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## Unreleased
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release.
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in computed_model.gemspec
4
+ gemspec
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
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
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,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ComputedModel
4
+ VERSION = "0.1.0"
5
+ 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: []