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 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: []