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 +7 -0
- data/CHANGELOG.md +2 -0
- data/LICENSE.md +22 -0
- data/README.md +379 -0
- data/lib/active_record_changesets.rb +311 -0
- metadata +178 -0
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
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: []
|