active_model_persistence 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: 4358309182de30b6d706336fdebb8d8e4b803d35cfc75c86074cb2fe96230a92
4
+ data.tar.gz: 3003146bc077bacff57e3d62ee55fb4ced6a3cec443a6250bd27e1b59cb17285
5
+ SHA512:
6
+ metadata.gz: ec9030ea8eff12cf3d05bcaee715b846069cc4c7358c473c2688d198c73db949b79364089c10860a28b9a9ee83fe1a22f3d2bc049c4078026cfd2204d3b61082
7
+ data.tar.gz: 74a2bcd9bf79fbd3747f365cf8c7c7059fec276d61e2880d36870daba6c2c12cb9e85d359d6216edff526c5f3a39da5f7e655272e06c8dbe89eb3de6c6781193
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,20 @@
1
+ AllCops:
2
+ SuggestExtensions: false
3
+ NewCops: enable
4
+ # Output extra information for each offense to make it easier to diagnose:
5
+ DisplayCopNames: true
6
+ DisplayStyleGuide: true
7
+ ExtraDetails: true
8
+ # RuboCop enforces rules depending on the oldest version of Ruby which
9
+ # your project supports:
10
+ TargetRubyVersion: 2.7
11
+
12
+ # The default max line length is 80 characters
13
+ Layout/LineLength:
14
+ Max: 120
15
+
16
+ # The DSL for RSpec and the gemspec file make it very hard to limit block length:
17
+ Metrics/BlockLength:
18
+ Exclude:
19
+ - "spec/**/*_spec.rb"
20
+ - "*.gemspec"
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in active_model_persistence.gemspec
6
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2022 James Couball
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # active_model_persistence
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/active_model_persistence.svg)](https://badge.fury.io/rb/active_model_persistence)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Documentation](https://img.shields.io/badge/Documentation-OK-green.svg)](https://jcouball.github.io/github_pages_rake_tasks/)
6
+
7
+ A gem to add in-memory persistence to Models built with ActiveModel.
8
+
9
+ The goals of this gem are:
10
+
11
+ * Add ActiveRecord-like meta-programming to configure Models used for ETI (Extract,
12
+ Transform, Load) work where a lot of data is loaded from desparate sources, transformed
13
+ in memory, and then written to other sources.
14
+ * Make creation of these model objects consistent across several different teams,
15
+ * Make it so easy to create model objects that teams will use these models instead
16
+ of using hashs
17
+ * Encourage a separation from the models from the business logic of reading, transforming,
18
+ and writing the data
19
+ * Make it easy to use FactoryBot to generate test data instead of having to maintain a
20
+ bunch of fixture files.
21
+
22
+ An example model built with this gem might look like this:
23
+
24
+ ```ruby
25
+ require 'active_model_persistence'
26
+
27
+ class Employee
28
+ include ActiveModelPersistence::Persistence
29
+
30
+ # Use ActiveModel attributes and validations to define the model's state
31
+
32
+ attribute :id, :string
33
+ attribute :name, :string
34
+ attribute :manager_id, :string
35
+
36
+ validates :id, presence: true
37
+ validates :name, presence: true
38
+
39
+ # A unique index is automatically created on the primary key attribute (which is :id by default)
40
+ # index :id, unique: true
41
+
42
+ # You can set the primary key attribute if you are using a different attribute for
43
+ # the primary key using the statement `self.primary_key = attribute_name`
44
+
45
+ # Indexes are non-unique by default and create a `find_by_{index_name}` method on
46
+ # the model class.
47
+ index :manager_id
48
+ end
49
+ ```
50
+
51
+ Use the employee model like you would an ActiveRecord model:
52
+
53
+ ```ruby
54
+ e1 = Enmployee.create(id: 'jdoe1', name: 'John Doe', manager_id: 'boss')
55
+ e2 = Enmployee.create(id: 'jdoe2', name: 'Bob Doe', manager_id: 'boss')
56
+ e3 = Enmployee.create(id: 'boss', name: 'Boss Person')
57
+
58
+ # The `find` method looks up objects by the primary key and returns a single object or nil
59
+ Employee.find('jdoe1') #=> [e1]
60
+
61
+ # The `find_by_*` methods return a (possibly empty) object array based on the indexes
62
+ # declared in the model class.
63
+ Employee.find_by_manager_id('boss') #=> [e1, e2]
64
+
65
+ # etc.
66
+ ```
67
+
68
+ See [the full API documentation](https://jcouball.github.io/active_record_persistence/) for more details.
69
+
70
+ ## Installation
71
+
72
+ Add this line to your application's Gemfile (or equivalent comamnd in the project's gemspec):
73
+
74
+ ```ruby
75
+ gem 'active_model_persistence'
76
+ ```
77
+
78
+ And then execute:
79
+
80
+ ```shell
81
+ bundle install
82
+ ```
83
+
84
+ Or install it manually using the `gem` command:
85
+
86
+ ```shell
87
+ gem install active_model_persistence
88
+ ```
89
+
90
+ ## Development
91
+
92
+ 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.
93
+
94
+ 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 the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
95
+
96
+ ## Contributing
97
+
98
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/active_model_persistence.
99
+
100
+ ## License
101
+
102
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The default task
4
+
5
+ desc 'Run the same tasks that the CI build will run'
6
+ task default: %w[spec rubocop yard yard:coverage yard:audit bundle:audit build]
7
+
8
+ # Bundler Audit
9
+
10
+ require 'bundler/audit/task'
11
+ Bundler::Audit::Task.new
12
+
13
+ # Bundler Gem Build
14
+
15
+ require 'bundler'
16
+ require 'bundler/gem_tasks'
17
+
18
+ begin
19
+ Bundler.setup(:default, :development)
20
+ rescue Bundler::BundlerError => e
21
+ warn e.message
22
+ warn 'Run `bundle install` to install missing gems'
23
+ exit e.status_code
24
+ end
25
+
26
+ CLEAN << 'pkg'
27
+ CLOBBER << 'Gemfile.lock'
28
+
29
+ # Bump
30
+
31
+ require 'bump/tasks'
32
+
33
+ # RSpec
34
+
35
+ require 'rspec/core/rake_task'
36
+
37
+ RSpec::Core::RakeTask.new
38
+
39
+ CLEAN << 'coverage'
40
+ CLEAN << '.rspec_status'
41
+ CLEAN << 'rspec-report.xml'
42
+
43
+ # Rubocop
44
+
45
+ require 'rubocop/rake_task'
46
+
47
+ RuboCop::RakeTask.new do |t|
48
+ t.options = %w[
49
+ --format progress
50
+ --format json --out rubocop-report.json
51
+ ]
52
+ end
53
+
54
+ CLEAN << 'rubocop-report.json'
55
+
56
+ # YARD
57
+
58
+ require 'yard'
59
+
60
+ YARD::Rake::YardocTask.new do |t|
61
+ t.files = %w[lib/**/*.rb examples/**/*]
62
+ end
63
+
64
+ CLEAN << '.yardoc'
65
+ CLEAN << 'doc'
66
+
67
+ # Yardstick
68
+
69
+ desc 'Run yardstick to show missing YARD doc elements'
70
+ task :'yard:audit' do
71
+ sh "yardstick 'lib/**/*.rb'"
72
+ end
73
+
74
+ # Yardstick coverage
75
+
76
+ require 'yardstick/rake/verify'
77
+
78
+ Yardstick::Rake::Verify.new(:'yard:coverage') do |verify|
79
+ verify.threshold = 100
80
+ end
81
+
82
+ # Publish YARD documentation to GitHub
83
+
84
+ require 'github_pages_rake_tasks'
85
+
86
+ GithubPagesRakeTasks::PublishTask.new do |task|
87
+ # task.doc_dir = 'documentation'
88
+ task.verbose = true
89
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/active_model_persistence/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'active_model_persistence'
7
+ spec.version = ActiveModelPersistence::VERSION
8
+ spec.authors = ['James Couball']
9
+ spec.email = ['couballj@verizonmedia.com']
10
+
11
+ spec.summary = 'Adds in-memory persistence to ActiveModel models'
12
+ spec.description = <<~DESCRIPTION
13
+ Adds in-memory persistence to ActiveModel models
14
+ DESCRIPTION
15
+ spec.homepage = 'https://github.com/jcouball/active_model_persistence'
16
+ spec.license = 'MIT'
17
+ spec.required_ruby_version = '>= 2.7.0'
18
+
19
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
20
+ spec.metadata['rubygems_mfa_required'] = 'true'
21
+
22
+ spec.metadata['homepage_uri'] = spec.homepage
23
+ spec.metadata['source_code_uri'] = spec.homepage
24
+ spec.metadata['changelog_uri'] = "#{spec.metadata['source_code_uri']}/blob/main/CHANGELOG.md"
25
+
26
+ # Specify which files should be added to the gem when it is released.
27
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
28
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
29
+ `git ls-files -z`.split("\x0").reject do |f|
30
+ (f == __FILE__) || f.match(%r{\A(?:(?:test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
31
+ end
32
+ end
33
+ spec.bindir = 'exe'
34
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
35
+ spec.require_paths = ['lib']
36
+
37
+ # Runtime dependencies
38
+ spec.add_dependency 'activemodel', '~> 7.0'
39
+ spec.add_dependency 'activesupport', '~> 7.0'
40
+
41
+ # Development dependencies
42
+ spec.add_development_dependency 'bump', '~> 0.10'
43
+ spec.add_development_dependency 'bundler-audit', '~> 0.9'
44
+ spec.add_development_dependency 'github_pages_rake_tasks', '~> 0.1'
45
+ spec.add_development_dependency 'rake', '~> 13.0'
46
+ spec.add_development_dependency 'redcarpet', '~> 3.5'
47
+ spec.add_development_dependency 'rspec', '~> 3.10'
48
+ spec.add_development_dependency 'rubocop', '~> 1.24'
49
+ spec.add_development_dependency 'simplecov', '~> 0.21'
50
+ spec.add_development_dependency 'yard', '~> 0.9'
51
+ spec.add_development_dependency 'yardstick', '~> 0.9'
52
+ end
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'active_model_persistence'
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require 'irb'
15
+ 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,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveModelPersistence
4
+ # An Index keeps a map from a key to zero of more objects
5
+ # @api public
6
+ #
7
+ class Index
8
+ # The name of the index
9
+ #
10
+ # @example
11
+ # i = Index.new(name: 'id', key_source: :id, unique: true)
12
+ # i.name # => 'id'
13
+ #
14
+ # @return [String] The name of the index
15
+ #
16
+ attr_reader :name
17
+
18
+ # Defines how the object's key value is calculated
19
+ #
20
+ # If a proc is provided, it will be called with the object as an argument to get the key value.
21
+ #
22
+ # If a symbol is provided, it will identify the method to call on the object to get the key value.
23
+ #
24
+ # @example
25
+ # i = Index.new(name: 'id', key_value_source: :id, unique: true)
26
+ # i.key_value_source # => :id
27
+ #
28
+ # @return [Symbol, Proc] the method name or proc used to calculate the index key
29
+ #
30
+ attr_reader :key_value_source
31
+
32
+ # Determines if a key value can index more than one object
33
+ #
34
+ # The default value is false.
35
+ #
36
+ # If true, if two objects have the same key, a UniqueContraintError will be raised
37
+ # when trying to add the second object.
38
+ #
39
+ # @example
40
+ # i = Index.new(name: 'id', key_value_source: :id, unique: true)
41
+ # i.unique? # => true
42
+ #
43
+ # @return [Boolean] true if the index is unique
44
+ #
45
+ attr_reader :unique
46
+
47
+ alias unique? unique
48
+
49
+ # Create an Index
50
+ #
51
+ # @example An object that can be indexed must include Indexable (which includes PrimaryKey)
52
+ # Employee = Struct.new(:id, :name, keyword_init: true)
53
+ # include ActiveModelPersistence::Indexable
54
+ # end
55
+ # e1 = Employee.new(id: 1, name: 'James')
56
+ # e2 = Employee.new(id: 2, name: 'Frank')
57
+ # e3 = Employee.new(id: 1, name: 'Margaret') # Note e1.id == e3.id
58
+ #
59
+ # @example
60
+ # i = Index.new(name: 'id', key_source: :id, unique: true)
61
+ # i.name # => 'id'
62
+ # i.key_source # => :id -- get the key value by calling the 'id' method on object
63
+ # i.unique # => true -- each key can only have one object associated with it
64
+ #
65
+ # i.objects(e1.id) # => []
66
+ # i.add(e1)
67
+ # i.add(e2)
68
+ # i.objects(e1.id) # => [e1]
69
+ # i.objects(e2.id) # => [e2]
70
+ # i.add(e3) # => raises a UniqueContraintError since e1.id == e3.id
71
+ # i.add(e1) # => raises an ObjectAlreadyInIndexError
72
+ #
73
+ # @param name [String] the name of the index
74
+ # @param key_value_source [Symbol, Proc] the attribute name or proc used to calculate the index key
75
+ # @param unique [Boolean] when true the index will only allow one object per key
76
+ #
77
+ def initialize(name:, key_value_source: nil, unique: false)
78
+ @name = name.to_s
79
+ @key_value_source = determine_key_value_source(name, key_value_source)
80
+ @unique = unique
81
+ @key_to_objects_map = {}
82
+ end
83
+
84
+ # Returns the objects that match the key
85
+ #
86
+ # A unique index will return an Array containing zero or one objects. A non-unique
87
+ # index will return an array containing zero or more objects.
88
+ #
89
+ # @example
90
+ # i = Index.new(name: 'id', key_source: :id, unique: true)
91
+ # e = Employee.new(id: 1, name: 'James')
92
+ # i.object(e.id) # => []
93
+ # i.add(e.id, e)
94
+ # i.object(e.id) # => [e]
95
+ #
96
+ # @param key [Object] the key to search for
97
+ #
98
+ # @return [Array<Object>] the objects that match the key
99
+ #
100
+ def objects(key)
101
+ key_to_objects_map[key] || []
102
+ end
103
+
104
+ # Returns true if the index contains an object with the given key
105
+ #
106
+ # @example
107
+ # i = Index.new(name: 'id', key_source: :id, unique: true)
108
+ # e = Employee.new(id: 1, name: 'James')
109
+ # i.include?(e.id) # => false
110
+ # i << e
111
+ # i.include?(e.id) # => true
112
+ #
113
+ # @param key [Object] the key to search for
114
+ #
115
+ # @return [Boolean] true if the index contains an object with the given key
116
+ #
117
+ def include?(key)
118
+ key_to_objects_map.key?(key)
119
+ end
120
+
121
+ # Adds an object to the index
122
+ #
123
+ # If the object was already in the index using a different key, remote the object
124
+ # from the index using the previous key before adding it again.
125
+ #
126
+ # @example
127
+ # i = Index.new(name: 'id', key_source: :id, unique: true)
128
+ # e = Employee.new(id: 1, name: 'James')
129
+ # i << e
130
+ # i.objects(1) # => [e]
131
+ # e.id = 2
132
+ # i << e
133
+ # i.objects(1) # => []
134
+ # i.objects(2) # => [e]
135
+ #
136
+ # @param object [Object] the object to add to the index
137
+ #
138
+ # @return [Index] self so calls can be chained
139
+ #
140
+ # @raise [UniqueConstraintError] if the index is unique and there is already an index
141
+ # entry for the same key
142
+ #
143
+ def add_or_update(object)
144
+ previous_key = object.previous_index_key(name)
145
+ key = key_value_for(object)
146
+
147
+ return if previous_key == key
148
+
149
+ remove(object, previous_key) unless previous_key.nil?
150
+
151
+ add(object, key) unless key.nil?
152
+
153
+ self
154
+ end
155
+
156
+ alias << add_or_update
157
+
158
+ # Removes an object from the index
159
+ #
160
+ # @example
161
+ # i = Index.new(name: 'id', key_source: :id, unique: true)
162
+ # e = Employee.new(id: 1, name: 'James')
163
+ # i << e
164
+ # i.objects(1) # => [e]
165
+ # i.remove(e)
166
+ # i.objects(1) # => []
167
+ #
168
+ # @param object [Object] the object to remove from the index
169
+ # @param key [Object] the object's key in the index
170
+ # If nil (the default), call the object.previous_index_key to get the key.
171
+ # `previous_index_key` is implemented by the Indexable concern.
172
+ #
173
+ # @return [void]
174
+ #
175
+ # @raise [ObjectNotInIndexError] if the object is not in the index
176
+ #
177
+ def remove(object, key = nil)
178
+ key ||= object.previous_index_key(name)
179
+
180
+ raise ActiveModelPersistence::ObjectNotInIndexError if key.nil?
181
+
182
+ remove_object_from_index(object, key)
183
+
184
+ nil
185
+ end
186
+
187
+ # Removes all objects from the index
188
+ #
189
+ # @example
190
+ # i = Index.new(name: 'id', key_source: :id, unique: true)
191
+ # e1 = Employee.new(id: 1, name: 'James')
192
+ # e2 = Employee.new(id: 2, name: 'Frank')
193
+ # i << e1 << e2
194
+ # i.objects(1) # => [e1]
195
+ # i.objects(2) # => [e2]
196
+ # i.remove_all
197
+ # i.objects(1) # => []
198
+ # i.objects(2) # => []
199
+ #
200
+ # @return [void]
201
+ #
202
+ def remove_all
203
+ @key_to_objects_map.each_pair do |_key, objects|
204
+ objects.each do |object|
205
+ object.clear_index_key(name)
206
+ end
207
+ end
208
+ @key_to_objects_map = {}
209
+ nil
210
+ end
211
+
212
+ protected
213
+
214
+ # The map of keys to objects
215
+ #
216
+ # @return [Hash<Object, Array<Object>>] the map from key to an array objects added for that key
217
+ #
218
+ # @api private
219
+ #
220
+ attr_reader :key_to_objects_map
221
+
222
+ private
223
+
224
+ # Remove an object from the index with no additional checks
225
+ #
226
+ # @return [void]
227
+ #
228
+ # @api private
229
+ #
230
+ def remove_object_from_index(object, key)
231
+ key_to_objects_map[key].delete_if { |o| o.primary_key == object.primary_key }
232
+ key_to_objects_map.delete(key) if key_to_objects_map[key].empty?
233
+ object.clear_index_key(name)
234
+ end
235
+
236
+ # Adds an object to the index
237
+ #
238
+ # @param object [Object] the object to add to the index
239
+ # @param key [Object] the object's key in the index
240
+ #
241
+ # @return [void]
242
+ #
243
+ # @raise [UniqueConstraintError] if the index is unique and there is already an index
244
+ # entry for the same key
245
+ #
246
+ # @api private
247
+ #
248
+ def add(object, key)
249
+ raise UniqueConstraintError if unique? && include?(key)
250
+
251
+ key_to_objects_map[key] = [] unless key_to_objects_map.include?(key)
252
+ key_to_objects_map[key] << object
253
+ object.save_index_key(name, key)
254
+ end
255
+
256
+ # Uses the `key_value_source` to calculate the key value for the given object
257
+ #
258
+ # @param object [Object] the object to calculate the key value for
259
+ #
260
+ # @return [Object] the key value
261
+ #
262
+ # @api private
263
+ #
264
+ def key_value_for(object)
265
+ if key_value_source.is_a?(Proc)
266
+ key_value_source.call(object)
267
+ else
268
+ object.send(key_value_source)
269
+ end
270
+ end
271
+
272
+ # Determine the value for key_value_source
273
+ #
274
+ # @return [Symbol, Proc] the value for key_value_source
275
+ #
276
+ # @api private
277
+ #
278
+ def determine_key_value_source(name, key_value_source)
279
+ @key_value_source =
280
+ case key_value_source
281
+ when nil
282
+ name.to_sym
283
+ when Proc
284
+ key_value_source
285
+ else
286
+ key_value_source.to_sym
287
+ end
288
+ end
289
+ end
290
+ end