activerecord-changesets 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: 2f8304362b500e3a957f2e4ec977fa975b29d924143a2ef07a0e15890226c65a
4
+ data.tar.gz: 7011898666788a431744395c17db50188aed98bd82ef313909fdd6ac8fe4811c
5
+ SHA512:
6
+ metadata.gz: 74ebdc8406de80a84d3c166ea2886e7dd19a28856e033fda8f0e959acda8cb822cadcfd7717e117a312fa27f380788206b50ad2032b4c8a06920d34bee92d0d7
7
+ data.tar.gz: 87fc9ddd42de7c8a2548863bfed928e5d9d8aac9dae053d0cfdd5b64e10548f49589f70f6b0e42cbe38b7baaa5df81294e991d749e0367728e607a1b577d9f05
data/CHANGELOG.md ADDED
@@ -0,0 +1,2 @@
1
+ ## 1.0.0 [Unreleased]
2
+ - Initial release
data/LICENSE.md ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2025 Simon J.
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,379 @@
1
+ # ActiveRecord Changesets
2
+
3
+ Make your model updates explicit and predictable.
4
+
5
+ Instead of scattering validations, strong parameters, and business rules across controllers and models, changesets give you one clear pipeline for handling data before it touches the database.
6
+
7
+ * 🔍 Make model operations clear and intentional
8
+ * 🔒 Scope attribute filtering and validation per operation
9
+ * ✨ Reduce coupling between controllers and models
10
+
11
+ ## Quick start
12
+
13
+ Install the gem:
14
+
15
+ ```shell
16
+ bundle add activerecord-changesets
17
+ ```
18
+
19
+ Use it in your model:
20
+
21
+ ```ruby
22
+ class User < ApplicationRecord
23
+ # Or include it in your ApplicationRecord class
24
+ include ActiveRecordChangesets
25
+
26
+ changeset :create_user do
27
+ # Only allow the email and password fields to be changed
28
+ expect :email, :password
29
+
30
+ # Run validation rules specifically for this changeset
31
+ validates :email, presence: true, uniqueness: true, format: {with: URI::MailTo::EMAIL_REGEXP}
32
+ validate :must_have_secure_password
33
+ end
34
+ end
35
+
36
+ User.create_user(email: "bob@example.com", password: "password1234")
37
+ ```
38
+
39
+ ## Why use changesets?
40
+
41
+ ### Reduce boilerplate and business logic in controllers
42
+
43
+ Validations are defined in our models, but we still need to use strong parameters in our controllers to filter incoming parameters. This approach leads to extra boilerplate and a tighter coupling between controllers and models. If you make a change to the model, you need to update the controller.
44
+
45
+ Changesets solve this problem by moving the behaviour of strong parameters to the model: each changeset defines which parameters are allowed to change. This means that controllers no longer need to know anything about model attributes - they can just focus on the HTTP request.
46
+
47
+ <details>
48
+ <summary>Show code example</summary>
49
+
50
+ ```ruby
51
+ # Model
52
+ class User < ApplicationRecord
53
+ changeset :create_user do
54
+ expect :name
55
+ validates :name, presence: true
56
+ end
57
+ end
58
+
59
+ # Controller
60
+ class UsersController < ApplicationController
61
+ def new
62
+ render :new, locals: { changeset: User.create_user }
63
+ end
64
+
65
+ def create
66
+ # Notice how the controller doesn't need to know about the model's attributes
67
+ changeset = User.create_user(params)
68
+
69
+ if changeset.save
70
+ redirect_to changeset
71
+ else
72
+ render :new, locals: { changeset: changeset }, status: :unprocessable_content
73
+ end
74
+ end
75
+ end
76
+
77
+ # View
78
+ <%= form_with changeset do |f| %>
79
+ <%= f.text_field :name %>
80
+ <%= f.submit %>
81
+ <% end %>
82
+ ```
83
+ </details>
84
+
85
+
86
+ ### Prevent validation changes from causing unintended consequences
87
+
88
+ If you ever need to change the validation logic in your model, you may end up with unintended consequences for your existing data. For example, if you start validating that all users have a phone number, if you're not careful, an existing record without a phone number may be marked invalid when they go to change their password.
89
+
90
+ Changesets let you define validations that are specific to each operation, so you can be sure that your validation logic is only applied when it makes sense.
91
+
92
+ Although this is possible using contexts in vanilla Rails, it can be difficult to see which validations apply to which operations.
93
+
94
+ <details>
95
+ <summary>Show code example</summary>
96
+
97
+ ```ruby
98
+ # Model
99
+ class User < ApplicationRecord
100
+ changeset :edit_name do
101
+ expect :name
102
+
103
+ validates_name
104
+ end
105
+
106
+ changeset :edit_phone do
107
+ expect :phone_number
108
+
109
+ validates_phone_number
110
+ end
111
+
112
+ def self.validates_name
113
+ validates :name, presence: true
114
+ end
115
+
116
+ def self.validates_phone_number
117
+ validates :phone_number, presence: true
118
+ end
119
+ end
120
+ ```
121
+
122
+ Because the validations are scoped to the changeset, you won't get an unexpected phone number error when you try to change your name.
123
+ </details>
124
+
125
+ ### Simplify nested attributes
126
+
127
+ Nested attributes are a common pattern in Rails, but it can be tricky to permit the right parameters using strong parameters. Nested changesets let you define a changeset for each association so that the changeset controls which parameters are allowed.
128
+
129
+ <details>
130
+ <summary>Show code example</summary>
131
+
132
+ ```ruby
133
+ class User < ApplicationRecord
134
+ has_many :accounts
135
+
136
+ changeset :edit_user do
137
+ expect :email
138
+ validates :email, presence: true
139
+
140
+ nested_changeset :accounts, :edit_account
141
+ end
142
+ end
143
+
144
+ class Account < ApplicationRecord
145
+ belongs_to :user
146
+
147
+ changeset :edit_account do
148
+ expect :name
149
+ validates :name, presence: true
150
+ end
151
+ end
152
+
153
+ def update
154
+ changeset = @user.edit_user(params)
155
+ changeset.save!
156
+ end
157
+ ```
158
+ </details>
159
+
160
+ ## Getting started
161
+
162
+ Install `activerecord-changesets` in your Rails project:
163
+
164
+ ```ruby
165
+ # Gemfile
166
+ gem "activerecord-changesets"
167
+ ```
168
+
169
+ Or using bundler:
170
+
171
+ ```shell
172
+ bundle add activerecord-changesets
173
+ ```
174
+
175
+ ## Usage
176
+
177
+ To start using changesets, add it to your model:
178
+
179
+ ```ruby
180
+ class ApplicationRecord < ActiveRecord::Base
181
+ include ActiveRecordChangesets
182
+ end
183
+ ```
184
+
185
+ ### Defining Changesets
186
+
187
+ Use the class-level `changeset` method to define a changeset:
188
+
189
+ ```ruby
190
+ class User < ApplicationRecord
191
+ # Changeset for user creation
192
+ changeset :create_user do
193
+ # Required parameters
194
+ expect :first_name, :last_name, :email, :password
195
+
196
+ # Validations specific to this changeset
197
+ validates :first_name, presence: true
198
+ validates :last_name, presence: true
199
+ validates :email, presence: true, uniqueness: true, format: {with: URI::MailTo::EMAIL_REGEXP}
200
+ validate :must_have_secure_password
201
+ end
202
+
203
+ # Changeset for updating user's name
204
+ changeset :edit_name do
205
+ # Optional parameter
206
+ permit :first_name
207
+ # Required parameter
208
+ expect :last_name
209
+
210
+ # Validations specific to this changeset
211
+ validates :first_name, presence: true
212
+ validates :last_name, presence: true
213
+ end
214
+
215
+ # Changeset for updating user's email
216
+ changeset :edit_email do
217
+ expect :email
218
+
219
+ validates :email, presence: true, uniqueness: true, format: {with: URI::MailTo::EMAIL_REGEXP}
220
+ end
221
+
222
+ private
223
+
224
+ def must_have_secure_password
225
+ errors.add(:password, "can't be blank") unless self.password.present? && self.password.is_a?(String)
226
+ errors.add(:password, "must be at least 10 characters") unless self.password.is_a?(String) && self.password.length >= 10
227
+ end
228
+ end
229
+ ```
230
+
231
+ ### Using Changesets
232
+
233
+ #### Creating a new record with a changeset
234
+
235
+ ```ruby
236
+ # Class-level method
237
+ changeset = User.create_user({
238
+ first_name: "Bob",
239
+ last_name: "Ross",
240
+ email: "bob@example.com",
241
+ password: "password1234"
242
+ })
243
+
244
+ # Save the changeset to create the record
245
+ changeset.save!
246
+ ```
247
+
248
+ #### Updating an existing record with a changeset
249
+
250
+ ```ruby
251
+ user = User.find(params[:id])
252
+
253
+ # Instance-level method
254
+ changeset = user.edit_name({
255
+ first_name: "Rob",
256
+ last_name: "Boss"
257
+ })
258
+
259
+ # Save the changeset to update the record
260
+ changeset.save!
261
+ ```
262
+
263
+ #### Automatical parameter unwrapping
264
+
265
+ If parameters are wrapped under the model parameter key (e.g., { user: { ... } }), they will be unwrapped automatically. The following two calls are equivalent:
266
+
267
+ ```ruby
268
+ user.edit_email({user: {email: "..."}})
269
+ user.edit_email(email: "...")
270
+ ```
271
+
272
+ #### Working with Rails Strong Parameters
273
+
274
+ Changesets work seamlessly with Rails' strong parameters:
275
+
276
+ ```ruby
277
+ # In a controller
278
+ def create
279
+ changeset = User.create_user(params)
280
+
281
+ if changeset.save
282
+ redirect_to user_path(changeset)
283
+ else
284
+ render :new
285
+ end
286
+ end
287
+ ```
288
+
289
+ ### API Reference
290
+
291
+ #### Changeset Definition
292
+
293
+ ```ruby
294
+ changeset :name do
295
+ # Changeset configuration
296
+ end
297
+ ```
298
+
299
+ #### Parameter Control
300
+
301
+ - `expect :param1, :param2, ...` - Define required parameters
302
+ - `permit :param1, :param2, ...` - Define optional parameters
303
+
304
+ #### Nested Changesets
305
+
306
+ For associations, you can define nested changesets:
307
+
308
+ ```ruby
309
+ changeset :create_post do
310
+ expect :title, :content
311
+
312
+ # Define a nested changeset for the comments association
313
+ # This will use the Comment model's :create_comment changeset
314
+ nested_changeset :comments, :create_comment, optional: true
315
+ end
316
+ ```
317
+
318
+ Note: `nested_changeset` forwards any additional options to ActiveRecord's `accepts_nested_attributes_for` (e.g., `:allow_destroy`, `:limit`, `:update_only`, `:reject_if`). The `:optional` flag controls whether `[association]_attributes` is expected (required) or merely permitted for this changeset.
319
+
320
+ #### Configuration
321
+
322
+ These global settings can be overridden in a Rails initializer:
323
+
324
+ ```ruby
325
+ # Whether to raise an error if unexpected parameters are passed to a changeset
326
+ # Defaults to true
327
+ ActiveRecordChangesets.strict_mode = false
328
+
329
+ # Parameter keys that are ignored when strict mode is enabled
330
+ # Defaults to [:authenticity_token, :_method]
331
+ ActiveRecordChangesets.ignored_attributes = [:authenticity_token, :_method, :utf8]
332
+ ```
333
+
334
+ These options can also be overridden on a per-changeset basis:
335
+
336
+ ```ruby
337
+ class User < ApplicationRecord
338
+ include ActiveRecordChangesets
339
+
340
+ changeset :register, strict: false, ignore: [:utf8, :commit] do
341
+ expect :email, :password
342
+ permit :name
343
+ end
344
+ end
345
+ ```
346
+
347
+ #### Error Handling
348
+
349
+ If required parameters are missing, an ActiveRecordChangesets::MissingParametersError will be raised:
350
+
351
+ ```ruby
352
+ begin
353
+ User.create_user({}) # Missing all required parameters
354
+ rescue ActiveRecordChangesets::MissingParametersError => e
355
+ puts e.message
356
+ # => "User::Changesets::CreateUser: Expected parameters were missing: first_name, last_name, email, password"
357
+ end
358
+ ```
359
+
360
+ If unexpected parameters are provided while strict mode is enabled (globally or for a specific changeset), an ActiveRecordChangesets::StrictParametersError will be raised:
361
+
362
+ ```ruby
363
+ begin
364
+ User.register(email: "a@b.com", password: "secret", extra: "nope")
365
+ rescue ActiveRecordChangesets::StrictParametersError => e
366
+ puts e.message
367
+ # => "User::Changesets::Register: Unexpected parameters passed to changeset: extra"
368
+ end
369
+ ```
370
+
371
+ If you reference a changeset that hasn't been defined, an ActiveRecordChangesets::UnknownChangeset will be raised:
372
+
373
+ ```ruby
374
+ begin
375
+ User.changeset_class(:does_not_exist)
376
+ rescue ActiveRecordChangesets::UnknownChangeset => e
377
+ puts e.message
378
+ end
379
+ ```
@@ -0,0 +1,311 @@
1
+ require "active_support/core_ext/module/attribute_accessors"
2
+
3
+ # ActiveRecordChangesets provides a lightweight DSL to build parameter-scoped
4
+ # "changeset" subclasses of your ActiveRecord models. These changeset classes
5
+ # restrict mass assignment to a declared set of attributes and can operate in a
6
+ # strict mode that rejects unexpected parameters. Changesets are built lazily
7
+ # and named under Model::Changesets::<Name> for clearer backtraces.
8
+ #
9
+ # Typical usage:
10
+ # class User < ApplicationRecord
11
+ # include ActiveRecordChangesets
12
+ #
13
+ # changeset :register, strict: true do
14
+ # expect :email, :password
15
+ # permit :name
16
+ # end
17
+ #
18
+ # changeset :update_profile do
19
+ # permit :name, :bio
20
+ # nested_changeset :profile, :update, optional: true
21
+ # end
22
+ # end
23
+ #
24
+ # user = User.register(email: "a@b.com", password: "secret")
25
+ # user.save
26
+ module ActiveRecordChangesets
27
+ # Base error for all gem-specific exceptions
28
+ class Error < StandardError; end
29
+
30
+ # Raised when required parameters are missing while building/assigning a changeset
31
+ class MissingParametersError < Error; end
32
+
33
+ # Raised when strict mode is enabled and unexpected parameters are provided
34
+ class StrictParametersError < Error; end
35
+
36
+ # Raised when requesting an undefined changeset
37
+ class UnknownChangeset < Error; end
38
+
39
+ # Global list of attribute keys that will be ignored when checking for extra
40
+ # attributes in strict mode. Defaults to Rails form helpers params.
41
+ # @return [Array<Symbol>]
42
+ mattr_accessor :ignored_attributes, default: [:authenticity_token, :_method]
43
+
44
+ # Global default for strict mode. If true, changesets will reject any
45
+ # parameters not explicitly expected/permitted. Can be overridden per changeset.
46
+ # @return [Boolean]
47
+ mattr_accessor :strict_mode, default: true
48
+
49
+ # Hook invoked when the module is included into an ActiveRecord model.
50
+ # It installs internal state used to register and build changesets and defines
51
+ # the Model::Changesets namespace for named anonymous classes.
52
+ # @param base [Class] the including model class
53
+ def self.included(base)
54
+ base.extend(ClassMethods)
55
+
56
+ base.class_attribute :_changesets, default: {}
57
+ base.private_class_method :_changesets, :_changesets=
58
+
59
+ base.class_attribute :_changeset_mutex, instance_accessor: false, default: Mutex.new
60
+ base.private_class_method :_changeset_mutex, :_changeset_mutex=
61
+
62
+ base.const_set(:Changesets, Module.new)
63
+ end
64
+
65
+ module ClassMethods
66
+ # @!group Changeset DSL (class-level)
67
+
68
+ # @!scope class
69
+ # @!method nested_changeset(association, changeset, optional: false, **options)
70
+ # Declare a nested changeset for an association and wire up nested attributes handling.
71
+ # This makes "<association>_attributes" permitted or expected depending on `optional`,
72
+ # and configures `accepts_nested_attributes_for` on the association.
73
+ # @param association [Symbol, String] The association name (e.g., :profile)
74
+ # @param changeset [Symbol, String] The changeset name on the associated model (e.g., :update_profile)
75
+ # @param optional [Boolean] If true, parameters are optional; otherwise required
76
+ # @param options [Hash] Options forwarded to `accepts_nested_attributes_for`
77
+ # @option options [Boolean] :allow_destroy Whether to allow destroying nested records
78
+ # @option options [Integer] :limit Max number of associated records
79
+ # @option options [Boolean] :update_only Only update existing records
80
+ # @option options [Proc,Symbol] :reject_if A Proc or a Symbol pointing to a method that checks whether a record should be built for a certain attribute hash
81
+ # @see ActiveRecord::NestedAttributes::ClassMethods#accepts_nested_attributes_for
82
+
83
+ # @!scope class
84
+ # @!method expect(*parameter_keys)
85
+ # Declare required parameters for a changeset. If any are missing, building/assigning
86
+ # will raise an error describing the missing keys.
87
+ # @param parameter_keys [Array<Symbol>] Keys that must be present in changeset parameters
88
+
89
+ # @!scope class
90
+ # @!method permit(*parameter_keys)
91
+ # Declare optional parameters for this changeset. These are allowed but not required.
92
+ # @param parameter_keys [Array<Symbol>] Keys that may be present in changeset parameters
93
+
94
+ # @!endgroup
95
+
96
+ # Define a changeset for this model.
97
+ #
98
+ # Registers a lazily-built anonymous subclass that filters mass-assignment to
99
+ # declared parameters. Also defines:
100
+ # - an instance method with the same name that returns a changeset instance
101
+ # seeded from the model and optionally assigned with params
102
+ # - a class method with the same name that instantiates a new model and
103
+ # returns its changeset
104
+ #
105
+ # @param name [Symbol, String] The changeset name (e.g., :register)
106
+ # @param options [Hash] Options controlling behavior
107
+ # @option options [Boolean] :strict Whether to enable strict parameter checking (defaults to ActiveRecordChangesets.strict_mode)
108
+ # @option options [Array<Symbol>] :ignore Attribute keys to ignore when checking for extra params (defaults to ActiveRecordChangesets.ignored_attributes)
109
+ # @yield DSL to declare expected/permitted attributes and nested changesets
110
+ # @yieldparam self [Class] the generated changeset class
111
+ # @return [void]
112
+ # @example
113
+ # changeset :register, strict: true do
114
+ # expect :email, :password
115
+ # permit :name
116
+ # end
117
+ def changeset(name, **options, &block)
118
+ key = name.to_sym
119
+
120
+ options.with_defaults!(strict: ActiveRecordChangesets.strict_mode, ignore: ActiveRecordChangesets.ignored_attributes)
121
+
122
+ # Defer building the class until methods in the parent model are available
123
+ _changesets[key] = {dsl_proc: block, options:}
124
+
125
+ # Define an instance method to convert an existing model into the changeset
126
+ #
127
+ # === Example
128
+ #
129
+ # user = User.find(params[:id])
130
+ # changeset = user.change_email
131
+ define_method(key) do |params = nil|
132
+ changeset = self.class.changeset_class(key).new
133
+ changeset.instance_variable_set(:@attributes, @attributes.deep_dup)
134
+ changeset.instance_variable_set(:@mutations_from_database, @mutations_from_database ||= nil)
135
+ changeset.instance_variable_set(:@new_record, new_record?)
136
+ changeset.instance_variable_set(:@destroyed, destroyed?)
137
+
138
+ changeset.assign_attributes(params) unless params.nil?
139
+ changeset.instance_variable_set(:@parent_model, self)
140
+
141
+ changeset
142
+ end
143
+
144
+ # Define a class-level convenience for the changeset
145
+ #
146
+ # === Example
147
+ #
148
+ # changeset = User.register_user
149
+ singleton_class.define_method(name) do |params = nil|
150
+ new.send(name, params)
151
+ end
152
+ end
153
+
154
+
155
+ # Resolve or build the concrete changeset class for the given name.
156
+ # The class is built lazily and cached. Thread-safe via a mutex.
157
+ # @param name [Symbol, String]
158
+ # @return [Class] the generated changeset subclass
159
+ # @raise [UnknownChangeset] if the name was never registered via `changeset`
160
+ def changeset_class(name)
161
+ raise UnknownChangeset, "Unknown changeset for #{self.name}: #{name}" unless _changesets.has_key?(name)
162
+
163
+ return _changesets[name][:class] if _changesets[name][:class].present?
164
+
165
+ _changeset_mutex.synchronize do
166
+ # Prevent race condition where two threads call changeset_class at the same time, both
167
+ # observing a Proc and building two distinct classes
168
+ return _changesets[name][:class] if _changesets[name][:class].present?
169
+
170
+ _changesets[name][:class] = build_changeset_class(name)
171
+ end
172
+ end
173
+
174
+ # Build the anonymous changeset subclass and evaluate its DSL.
175
+ # Gives the class a stable constant under Model::Changesets for better backtraces.
176
+ # @api private
177
+ # @param name [Symbol]
178
+ # @return [Class]
179
+ private def build_changeset_class(name)
180
+ changeset_class = Class.new(self) do
181
+ class_attribute :nested_changesets, instance_accessor: false, default: {}
182
+ private_class_method :nested_changesets=
183
+
184
+ class_attribute :permitted_attributes, instance_accessor: false, default: {}
185
+ private_class_method :permitted_attributes=
186
+
187
+ class_attribute :changeset_options, instance_accessor: false, default: {}
188
+
189
+ class << self
190
+ delegate :model_name, to: :superclass
191
+
192
+ # Declare required parameter keys for this changeset.
193
+ # @param keys [Array<Symbol,String>]
194
+ # @return [void]
195
+ def expect(*keys)
196
+ keys.each do |key|
197
+ permitted_attributes[key.to_sym] = {optional: false}
198
+ end
199
+ end
200
+
201
+ # Declare optional parameter keys for this changeset.
202
+ # @param keys [Array<Symbol,String>]
203
+ # @return [void]
204
+ def permit(*keys)
205
+ keys.each do |key|
206
+ permitted_attributes[key.to_sym] = {optional: true}
207
+ end
208
+ end
209
+
210
+ # Declare a nested changeset for an association. Also wires up
211
+ # accepts_nested_attributes_for and adds "<association>_attributes"
212
+ # to permitted or expected parameters based on `optional`.
213
+ # @param association [Symbol,String]
214
+ # @param changeset [Symbol,String]
215
+ # @param optional [Boolean]
216
+ # @param options [Hash] forwarded to accepts_nested_attributes_for
217
+ # @return [void]
218
+ def nested_changeset(association, changeset, optional: false, **options)
219
+ association_key = association.to_sym
220
+ changeset_key = changeset.to_sym
221
+
222
+ nested_changesets[association_key] = changeset
223
+ accepts_nested_attributes_for association_key, **options
224
+
225
+ if optional
226
+ permit :"#{association}_attributes"
227
+ else
228
+ expect :"#{association}_attributes"
229
+ end
230
+
231
+ # Change the association class to use the specified changeset class
232
+ reflection = _reflections[association_key]
233
+ changeset_class = reflection.klass.changeset_class(changeset_key)
234
+ reflection.instance_variable_set(:@klass, changeset_class)
235
+ end
236
+ end
237
+
238
+ # We overwrite assign_attributes to filter the attributes for all mass assignments
239
+ # Accepts both ActionController::Parameters and plain Hash. If params are wrapped
240
+ # under the model key (e.g., { user: {...} }), unwraps them. Applies strict/permit
241
+ # rules and raises helpful errors when something is wrong.
242
+ # @param new_attributes [Hash, ActionController::Parameters]
243
+ # @raise [ArgumentError] if new_attributes is not a hash-like object
244
+ # @raise [ActiveRecordChangesets::MissingParametersError] if required keys are missing
245
+ # @raise [ActiveRecordChangesets::StrictParametersError] if strict mode and unexpected keys
246
+ # @return [void]
247
+ def assign_attributes(new_attributes)
248
+ unless new_attributes.respond_to?(:each_pair)
249
+ raise ArgumentError, "When assigning attributes, you must pass a Hash as an argument, #{new_attributes.class} passed."
250
+ end
251
+
252
+ new_attributes = new_attributes.to_unsafe_h if new_attributes.respond_to?(:to_unsafe_h)
253
+ new_attributes = new_attributes.symbolize_keys if new_attributes.respond_to?(:symbolize_keys!)
254
+
255
+ # If the given Hash is wrapped in the model key, we extract it
256
+ model_key = model_name.param_key.to_sym
257
+ if new_attributes.has_key?(model_key) && new_attributes[model_key].respond_to?(:each_pair)
258
+ new_attributes = new_attributes[model_key]
259
+ end
260
+
261
+ _assign_attributes(filter_permitted_attributes(new_attributes))
262
+ end
263
+
264
+ # Internal helper to pick only expected/permitted attributes and validate presence
265
+ # of required keys. Optionally enforces strict mode rejecting unexpected keys.
266
+ # @param attributes [Hash]
267
+ # @return [Hash] a filtered attributes hash suitable for _assign_attributes
268
+ # @raise [MissingParametersError] when required keys are missing
269
+ # @raise [StrictParametersError] when extra keys are present in strict mode
270
+ def filter_permitted_attributes(attributes)
271
+ filtered_attributes = {}
272
+ missing_attributes = []
273
+
274
+ self.class.permitted_attributes.each do |key, config|
275
+ if attributes.has_key?(key)
276
+ filtered_attributes[key] = attributes[key]
277
+ elsif attributes.has_key?(key.to_s)
278
+ filtered_attributes[key] = attributes[key.to_s]
279
+ elsif !config[:optional]
280
+ missing_attributes << key
281
+ end
282
+ end
283
+
284
+ if missing_attributes.any?
285
+ raise ActiveRecordChangesets::MissingParametersError, "#{self.class.name}: Expected parameters were missing: #{missing_attributes.join(", ")}"
286
+ end
287
+
288
+ # If we have enabled strict mode, check for extra attributes. Perform a faster check
289
+ # using count first before comparing keys.
290
+ if self.class.changeset_options[:strict] && attributes.count > filtered_attributes.count
291
+ extra_attributes = attributes.keys - filtered_attributes.keys - self.class.changeset_options[:ignore]
292
+
293
+ if extra_attributes.any?
294
+ raise ActiveRecordChangesets::StrictParametersError, "#{self.class.name}: Unexpected parameters passed to changeset: #{extra_attributes.join(", ")}"
295
+ end
296
+ end
297
+
298
+ filtered_attributes
299
+ end
300
+ end
301
+ changeset_class.changeset_options = _changesets[name][:options]
302
+ changeset_class.class_eval(&_changesets[name][:dsl_proc])
303
+ _changesets[name].delete :dsl_proc
304
+
305
+ # Give the anonymous class a name for clearer backtraces (e.g. Model::Changesets::CreateModel)
306
+ const_get(:Changesets).const_set(name.to_s.camelcase, changeset_class)
307
+
308
+ changeset_class
309
+ end
310
+ end
311
+ end
metadata ADDED
@@ -0,0 +1,178 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activerecord-changesets
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Simon J
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-09-07 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: '8.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '8.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: temping
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '4.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '4.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: sqlite3
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: actionpack
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '8.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '8.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: minitest
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '5.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '5.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: minitest-reporters
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.1'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.1'
97
+ - !ruby/object:Gem::Dependency
98
+ name: standard
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1.49'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1.49'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rubocop
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '1.75'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '1.75'
125
+ - !ruby/object:Gem::Dependency
126
+ name: benchmark-ips
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '2.14'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '2.14'
139
+ description: Instead of scattering validations, strong parameters, and business rules
140
+ across controllers and models, changesets give you one clear pipeline for handling
141
+ data before it touches the database.
142
+ email: 2857218+mwnciau@users.noreply.github.com
143
+ executables: []
144
+ extensions: []
145
+ extra_rdoc_files: []
146
+ files:
147
+ - CHANGELOG.md
148
+ - LICENSE.md
149
+ - README.md
150
+ - lib/active_record_changesets.rb
151
+ homepage: https://rubygems.org/gems/dotkey
152
+ licenses:
153
+ - MIT
154
+ metadata:
155
+ source_code_uri: https://github.com/mwnciau/dotkey
156
+ changelog_uri: https://github.com/mwnciau/dotkey/blob/main/CHANGELOG.md
157
+ documentation_uri: https://github.com/mwnciau/dotkey
158
+ bug_tracker_uri: https://github.com/mwnciau/dotkey/issues
159
+ post_install_message:
160
+ rdoc_options: []
161
+ require_paths:
162
+ - lib
163
+ required_ruby_version: !ruby/object:Gem::Requirement
164
+ requirements:
165
+ - - ">="
166
+ - !ruby/object:Gem::Version
167
+ version: 2.0.0
168
+ required_rubygems_version: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - ">="
171
+ - !ruby/object:Gem::Version
172
+ version: '0'
173
+ requirements: []
174
+ rubygems_version: 3.4.20
175
+ signing_key:
176
+ specification_version: 4
177
+ summary: Make your model updates explicit and predictable using changesets
178
+ test_files: []