attribute_guard 1.0.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: a485b9e0bc322539e0ac6b7014eb40824c4d94227fc216fab8add8a4f7a201fe
4
+ data.tar.gz: 792be937f2a9c6a611773d99c7e9f42c84546e3d1693046a4b7d3ab7b06d7097
5
+ SHA512:
6
+ metadata.gz: 36e1b736350dba0238b811205629d6dd4e3b15ef12a60084cb56f78104d90e0cf1b2df797ef555e352e023678d7f838302a583769e876f967a4e9fa5d5306452
7
+ data.tar.gz: d961953fc003bfcbada924c84a07b24aaacbca72d1d14c06d1d148ef76d5fb0926a4ecac6999758a63761c85873bdabcf5d76c1eebfda1285cd299ef3ecf6a69
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+ All notable changes to this project will be documented in this file.
3
+
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## 1.0.0 (unreleased)
8
+
9
+ ### Added
10
+ - Initial release.
data/MIT-LICENSE.txt ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2023 Brian Durand
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,164 @@
1
+ # Active Record Attribute Guard
2
+
3
+ [![Continuous Integration](https://github.com/bdurand/attribute_guard/actions/workflows/continuous_integration.yml/badge.svg)](https://github.com/bdurand/attribute_guard/actions/workflows/continuous_integration.yml)
4
+ [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/testdouble/standard)
5
+
6
+ This Ruby gem provides an extension for ActiveRecord allowing you to declare certain attributes in a model to be locked. Locked attributes cannot be changed once a record is created unless you explicitly allow changes.
7
+
8
+ This feature can be used for a couple of different purposes.
9
+
10
+ 1. Preventing changes to data that should be immutable.
11
+ 2. Prevent direct data updates that bypass required business logic.
12
+
13
+ ## Usage
14
+
15
+ ### Declaring Locked Attributes
16
+
17
+ To declare locked attributes you simply need to include the `AttributeGuard` module into your model and then list the attributes with the `lock_attributes` method.
18
+
19
+ ```ruby
20
+ class MyModel < ApplicationRecord
21
+ include AttributeGuard
22
+
23
+ lock_attributes :created_by, :created_at
24
+ end
25
+ ```
26
+
27
+ Once that is done, if you try to change a locked value on an existing record, you will get a validation error.
28
+
29
+ ```ruby
30
+ record = MyModel.last
31
+ record.created_at = Time.now
32
+ record.save! # => raises ActiveRecord::RecordInvalid
33
+ ```
34
+
35
+ You can customize the validation error message by setting the value of the `errors.messages.locked` value in your i18n localization files. You can also specify an error message with the optional `error` keyword argument.
36
+
37
+ ```ruby
38
+ class MyModel < ApplicationRecord
39
+ include AttributeGuard
40
+
41
+ lock_attributes :created_by, error: "cannot be changed except by an admin"
42
+ end
43
+ ```
44
+
45
+ ### Unlocking Attributes
46
+
47
+ You can allow changes to locked attributes with the `unlock_attributes` method.
48
+
49
+ ```ruby
50
+ record = MyModel.last
51
+ record.unlock_attributes(:created_at, :created_by)
52
+ record.update!(created_at: Time.now, created_by: nil) # Changes are persisted
53
+ ```
54
+
55
+ You can also supply a block to `unlock_attributes` which will clear any unlocked attributes when the block exits.
56
+
57
+ ```ruby
58
+ record = MyModel.last
59
+ record.unlock_attributes(:created_at) do
60
+ record.update!(created_at: Time.now) # Changes are persisted
61
+ end
62
+
63
+ record.update!(created_at: Time.now) # => raises ActiveRecord::RecordInvalid
64
+ ```
65
+
66
+ The `unlock_attributes` method will return the record itself, so you can chain other instance methods off of it.
67
+
68
+ ```ruby
69
+ record.unlock_attributes(:created_at).update!(created_at: Time.now)
70
+ ```
71
+
72
+ ### Using As A Guard
73
+
74
+ You can use locked attributes as a guard to prevent direct updates to certain attributes and force changes to go through specific methods instead.
75
+
76
+ For example, suppose we have some business logic that needs to execute whenever the `status` field is changed. You might wrap that logic up into a method or service object. For this example, suppose that we want to send some kind of alert any time the status is changed.
77
+
78
+ ```ruby
79
+ class MyModel
80
+ def update_status(new_status)
81
+ update!(status: new_status)
82
+ StatusAlert.new(self).send_status_changed_alert
83
+ end
84
+ end
85
+
86
+ record = MyModel.last
87
+ record.update_status("completed")
88
+ ```
89
+
90
+ This has the risk, though, that you can still make direct updates to the `status` which would bypass the additional business logic.
91
+
92
+ ```ruby
93
+ record = MyModel.last
94
+ record.update!(status: "canceled") # StatusAlert method is not called.
95
+ ```
96
+
97
+ You can prevent this by locking the `status` attribute and then unlocking it within the method that includes the required business logic.
98
+
99
+ ```ruby
100
+ class MyModel
101
+ include AttributeGuard
102
+
103
+ lock_attributes :status
104
+
105
+ def update_status(new_status)
106
+ unlock_attributes(:status) do
107
+ update!(status: new_status)
108
+ end
109
+ StatusAlert.new(self).send_status_changed_alert
110
+ end
111
+ end
112
+
113
+ record = MyModel.last
114
+ record.update_status("completed") # Status gets updated
115
+ record.update!(status: "canceled") # raises ActiveRecord::RecordInvalid error
116
+ ```
117
+
118
+ ### Modes
119
+
120
+ The default behavior when a locked attribute is changed is to add a validation error to the record. You can change this behavior with the `mode` option when locking attributes.
121
+
122
+ ```ruby
123
+ class MyModel
124
+ include AttributeGuard
125
+
126
+ lock_attributes :email, mode: :error
127
+ lock_attributes :name: mode: :warn
128
+ lock_attributes :created_at, mode: ->(record, attribute) { raise "Created timestamp cannot be changed" }
129
+ end
130
+ ```
131
+
132
+ * `:error` - Add a validation error to the record. This is the default.
133
+
134
+ * `:warn` - Log a warning that the record was changed. This mode is useful to allow you soft deploy locked attributes to production on a mature project and give you information about where you may need to update code to unlock attributes.
135
+
136
+ * `Proc` - If you provide a `Proc` object, it will be called with the record and the attribute name when a locked attribute is changed.
137
+
138
+ ## Installation
139
+
140
+ Add this line to your application's Gemfile:
141
+
142
+ ```ruby
143
+ gem "attribute_guard"
144
+ ```
145
+
146
+ Then execute:
147
+ ```bash
148
+ $ bundle
149
+ ```
150
+
151
+ Or install it yourself as:
152
+ ```bash
153
+ $ gem install attribute_guard
154
+ ```
155
+
156
+ ## Contributing
157
+
158
+ Open a pull request on [GitHub](https://github.com/bdurand/attribute_guard).
159
+
160
+ Please use the [standardrb](https://github.com/testdouble/standard) syntax and lint your code with `standardrb --fix` before submitting.
161
+
162
+ ## License
163
+
164
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 1.0.0
@@ -0,0 +1,37 @@
1
+ Gem::Specification.new do |spec|
2
+ spec.name = "attribute_guard"
3
+ spec.version = File.read(File.expand_path("../VERSION", __FILE__)).strip
4
+ spec.authors = ["Brian Durand"]
5
+ spec.email = ["bbdurand@gmail.com"]
6
+
7
+ spec.summary = "ActiveRecord extension that allows locking attributes to prevent unintended updates."
8
+
9
+ spec.homepage = "https://github.com/bdurand/attribute_guard"
10
+ spec.license = "MIT"
11
+
12
+ # Specify which files should be added to the gem when it is released.
13
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
14
+ ignore_files = %w[
15
+ .
16
+ Appraisals
17
+ Gemfile
18
+ Gemfile.lock
19
+ Rakefile
20
+ config.ru
21
+ assets/
22
+ bin/
23
+ gemfiles/
24
+ spec/
25
+ ]
26
+ spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do
27
+ `git ls-files -z`.split("\x0").reject { |f| ignore_files.any? { |path| f.start_with?(path) } }
28
+ end
29
+
30
+ spec.require_paths = ["lib"]
31
+
32
+ spec.required_ruby_version = ">= 2.5"
33
+
34
+ spec.add_dependency "activerecord", ">= 5.0"
35
+
36
+ spec.add_development_dependency "bundler"
37
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/lazy_load_hooks"
5
+ require "active_model/validator"
6
+
7
+ ActiveSupport.on_load(:i18n) do
8
+ I18n.load_path << File.expand_path("locale/en.yml", __dir__)
9
+ end
10
+
11
+ # Extension for ActiveRecord models that adds the capability to lock attributes to prevent direct
12
+ # changes to them. This is useful for attributes that should only be changed through specific methods.
13
+ #
14
+ # @example
15
+ # class User < ActiveRecord::Base
16
+ # include AttributeGuard
17
+ #
18
+ # lock_attributes :name, :email
19
+ # end
20
+ #
21
+ # user = User.create!(name: "Test", email: "test@example")
22
+ # user.name = "Test 2"
23
+ # user.save! # => raises ActiveRecord::RecordInvalid
24
+ #
25
+ # user.unlock_attributes(:name)
26
+ # user.name = "Test 2"
27
+ # user.save! # => saves successfully
28
+ #
29
+ # user.unlock_attributes(:name) do
30
+ # user.name = "Test 3"
31
+ # user.save! # => saves successfully
32
+ # end
33
+ #
34
+ # user.name = "Test 4"
35
+ # user.save! # => raises ActiveRecord::RecordInvalid
36
+ module AttributeGuard
37
+ extend ActiveSupport::Concern
38
+
39
+ included do
40
+ class_attribute :locked_attributes, default: {}, instance_accessor: false
41
+ private_class_method :locked_attributes=
42
+ private_class_method :locked_attributes
43
+
44
+ validates_with LockedAttributesValidator
45
+ end
46
+
47
+ # Validator that checks for changes to locked attributes.
48
+ class LockedAttributesValidator < ActiveModel::Validator
49
+ def validate(record)
50
+ return if record.new_record?
51
+
52
+ record.class.send(:locked_attributes).each do |attribute, params|
53
+ if record.changes.include?(attribute) && record.attribute_locked?(attribute)
54
+ message, mode = params
55
+ if mode == :warn
56
+ record&.logger&.warn("Changed locked attribute #{attribute} on #{record.class.name} with id #{record.id}")
57
+ elsif mode.is_a?(Proc)
58
+ mode.call(record, attribute)
59
+ else
60
+ record.errors.add(attribute, message)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ module ClassMethods
68
+ # Locks the given attributes so that they cannot be changed directly. Subclasses inherit
69
+ # the locked attributes from their parent classes.
70
+ #
71
+ # You can optionally specify a mode of what to do when a locked attribute is changed. The
72
+ # default is to add an error to the model, but you can also specify :warn to log a warning
73
+ # or a Proc to call with the record and attribute name.
74
+ #
75
+ # @param attributes [Array<Symbol, String>] the attributes to lock
76
+ # @param error [String, Symbol, Boolean] the error message to use in validate errors
77
+ # @param mode [Symbol, Proc] mode to use when a locked attribute is changed
78
+ # @return [void]
79
+ def lock_attributes(*attributes, error: :locked, mode: :error)
80
+ locked = locked_attributes.dup
81
+ error = error.dup.freeze if error.is_a?(String)
82
+
83
+ attributes.flatten.each do |attribute|
84
+ locked[attribute.to_s] = [error, mode]
85
+ end
86
+
87
+ self.locked_attributes = locked
88
+ end
89
+
90
+ # Returns the names of the locked attributes.
91
+ #
92
+ # @return [Array<String>] the names of the locked attributes.
93
+ def locked_attribute_names
94
+ locked_attributes.keys
95
+ end
96
+ end
97
+
98
+ # Unlocks the given attributes so that they can be changed. If a block is given, the attributes
99
+ # are unlocked only for the duration of the block.
100
+ #
101
+ # This method returns the object itself so that it can be chained.
102
+ #
103
+ # @example
104
+ # user.unlock_attributes(:email).update!(email: "user@example.com")
105
+ #
106
+ # @param attributes [Array<Symbol, String>] the attributes to unlock
107
+ # @return [ActiveRecord::Base] the object itself
108
+ def unlock_attributes(*attributes)
109
+ attributes = attributes.flatten.map(&:to_s)
110
+ return if attributes.empty?
111
+
112
+ @unlocked_attributes ||= Set.new
113
+ if block_given?
114
+ save_val = @unlocked_attributes
115
+ begin
116
+ @unlocked_attributes = @unlocked_attributes.dup.merge(attributes)
117
+ yield
118
+ ensure
119
+ @unlocked_attributes = save_val
120
+ clear_unlocked_attributes if @unlocked_attributes.empty?
121
+ end
122
+ else
123
+ @unlocked_attributes.merge(attributes)
124
+ end
125
+
126
+ self
127
+ end
128
+
129
+ # Returns true if the given attribute is currently locked.
130
+ #
131
+ # @param attribute [Symbol, String] the attribute to check
132
+ # @return [Boolean] whether the attribute is locked
133
+ def attribute_locked?(attribute)
134
+ return false if new_record?
135
+
136
+ attribute = attribute.to_s
137
+ return false unless self.class.send(:locked_attributes).include?(attribute)
138
+
139
+ if defined?(@unlocked_attributes)
140
+ !@unlocked_attributes.include?(attribute.to_s)
141
+ else
142
+ true
143
+ end
144
+ end
145
+
146
+ # Clears any unlocked attributes.
147
+ #
148
+ # @return [void]
149
+ def clear_unlocked_attributes
150
+ if defined?(@unlocked_attributes)
151
+ remove_instance_variable(:@unlocked_attributes)
152
+ end
153
+ end
154
+ end
data/lib/locale/en.yml ADDED
@@ -0,0 +1,4 @@
1
+ en:
2
+ errors:
3
+ messages:
4
+ locked: "is locked and cannot be changed"
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: attribute_guard
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Brian Durand
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-07-28 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ description:
42
+ email:
43
+ - bbdurand@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - CHANGELOG.md
49
+ - MIT-LICENSE.txt
50
+ - README.md
51
+ - VERSION
52
+ - attribute_guard.gemspec
53
+ - lib/attribute_guard.rb
54
+ - lib/locale/en.yml
55
+ homepage: https://github.com/bdurand/attribute_guard
56
+ licenses:
57
+ - MIT
58
+ metadata: {}
59
+ post_install_message:
60
+ rdoc_options: []
61
+ require_paths:
62
+ - lib
63
+ required_ruby_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '2.5'
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubygems_version: 3.4.12
75
+ signing_key:
76
+ specification_version: 4
77
+ summary: ActiveRecord extension that allows locking attributes to prevent unintended
78
+ updates.
79
+ test_files: []