active_model_persistence 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: 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