active_model_changeset 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 +7 -0
- data/.yardopts +13 -0
- data/CHANGELOG.md +37 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +308 -0
- data/Rakefile +27 -0
- data/lib/active_model_changeset/base.rb +319 -0
- data/lib/active_model_changeset/railtie.rb +11 -0
- data/lib/active_model_changeset/version.rb +5 -0
- data/lib/active_model_changeset.rb +19 -0
- data/sig/active_model_changeset.rbs +4 -0
- metadata +96 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: cefb407d3ed0ef238cfe72db0118edd34df60fe0e13867352e5ad026902fea99
|
|
4
|
+
data.tar.gz: a27f98c0cd8689b3113a5832202ae33cd5b7ce24bea46ae7d4f4c3ec425eca48
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c0d78b6ec0377ff24afd5d375eb3f8cb496a8e9846a444d7795d4a40ce36061c6b2b5badd56b2a8c5e6534e5af1756b5ea287afc6694d7ccfe931721c7303b55
|
|
7
|
+
data.tar.gz: 27dc75a6b380a90bc125666957e5c5e89009d5c42dfeb4be2ca987176acdb68ff66f619ad728c8361481ad0b5e933d5d6f375949f25d045507167f3e9b4340e7
|
data/.yardopts
ADDED
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
|
|
4
|
+
|
|
5
|
+
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.0.0/),
|
|
6
|
+
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-01-16
|
|
11
|
+
|
|
12
|
+
### Adicionado
|
|
13
|
+
|
|
14
|
+
- **Classe `ActiveModelChangeset::Base`** - Classe base para criar changesets
|
|
15
|
+
- **Type-casting** via `ActiveModel::Attributes`
|
|
16
|
+
- **Validações** via `ActiveModel::Validations`
|
|
17
|
+
- **Normalização declarativa** com opção `normalize:` no atributo
|
|
18
|
+
- `:strip` - Remove espaços no início e fim
|
|
19
|
+
- `:squish` - Remove espaços extras
|
|
20
|
+
- `:downcase` - Converte para minúsculas
|
|
21
|
+
- `:upcase` - Converte para maiúsculas
|
|
22
|
+
- `:blank_to_nil` - Converte strings vazias para nil
|
|
23
|
+
- **Whitelist automática** - Apenas atributos declarados são aceitos
|
|
24
|
+
- **Detecção de mudanças**
|
|
25
|
+
- `#changed?` - Verifica se há mudanças
|
|
26
|
+
- `#changes` - Retorna hash com mudanças `{ attr: [old, new] }`
|
|
27
|
+
- `#attributes_for_update` - Retorna hash com atributos alterados
|
|
28
|
+
- **Aplicação de mudanças**
|
|
29
|
+
- `#apply` - Aplica mudanças se válido, retorna boolean
|
|
30
|
+
- `#apply!` - Aplica mudanças ou levanta exceção
|
|
31
|
+
- **Suporte a ActionController::Parameters** via `to_unsafe_h`
|
|
32
|
+
- **Compatível com POROs** (Plain Old Ruby Objects)
|
|
33
|
+
- **Alias `Changeset`** para retrocompatibilidade
|
|
34
|
+
- **Documentação YARD** completa
|
|
35
|
+
- **Suite de testes** com 59 exemplos e 93%+ de cobertura
|
|
36
|
+
- **GitHub Actions** para CI/CD
|
|
37
|
+
- **SimpleCov** para relatórios de cobertura
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"active_model_changeset" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
|
|
4
|
+
|
|
5
|
+
* Participants will be tolerant of opposing views.
|
|
6
|
+
* Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
|
|
7
|
+
* When interpreting the words and actions of others, participants should always assume good intentions.
|
|
8
|
+
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
9
|
+
|
|
10
|
+
If you have any concerns about behaviour within this project, please contact us at ["alef.oliveira@siedos.com.br"](mailto:"alef.oliveira@siedos.com.br").
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alef ojeda de Oliveira
|
|
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,308 @@
|
|
|
1
|
+
# ActiveModelChangeset
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/rb/active_model_changeset)
|
|
4
|
+
[](https://github.com/nemuba/active_model_changeset/actions)
|
|
5
|
+
[](https://github.com/nemuba/active_model_changeset)
|
|
6
|
+
|
|
7
|
+
Uma gem utilitária para Ruby on Rails que fornece **changesets tipados, validados e com semântica de patch** para operações de criação e atualização de modelos.
|
|
8
|
+
|
|
9
|
+
## O Problema
|
|
10
|
+
|
|
11
|
+
Em aplicações Rails, é comum enfrentar desafios ao lidar com parâmetros de entrada:
|
|
12
|
+
|
|
13
|
+
- Receber parâmetros brutos de controllers ou APIs
|
|
14
|
+
- Aplicar type-casting e normalização de forma consistente
|
|
15
|
+
- Validar dados antes de persistir
|
|
16
|
+
- Calcular apenas os atributos que realmente mudaram
|
|
17
|
+
- Aplicar mudanças ao modelo de forma segura e previsível
|
|
18
|
+
|
|
19
|
+
O `ActiveModelChangeset` resolve todos esses problemas com uma abstração única e testável.
|
|
20
|
+
|
|
21
|
+
## Principais Características
|
|
22
|
+
|
|
23
|
+
| Característica | Descrição |
|
|
24
|
+
|----------------|-----------|
|
|
25
|
+
| 🔄 **Type-casting consistente** | Utiliza `ActiveModel::Attributes` para conversão de tipos |
|
|
26
|
+
| 🛡️ **Whitelist automática** | Apenas atributos declarados são aceitos |
|
|
27
|
+
| ✨ **Normalização declarativa** | Suporte a `strip`, `squish`, `downcase`, etc. |
|
|
28
|
+
| 📊 **Cálculo de diff** | Compara estado atual com novo estado |
|
|
29
|
+
| 🎯 **Patch semantics** | Gera hash somente com atributos alterados |
|
|
30
|
+
| ✅ **Validações integradas** | Compatível com `ActiveModel::Validations` |
|
|
31
|
+
| 📦 **Independente de ActiveRecord** | Funciona com POROs (Plain Old Ruby Objects) |
|
|
32
|
+
|
|
33
|
+
## Instalação
|
|
34
|
+
|
|
35
|
+
Adicione ao seu Gemfile:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
gem 'active_model_changeset'
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
E execute:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
bundle install
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Ou instale diretamente:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
gem install active_model_changeset
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Como Usar
|
|
54
|
+
|
|
55
|
+
### Exemplo Básico
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
class UserChangeset < ActiveModelChangeset::Base
|
|
59
|
+
attribute :name, :string, normalize: :squish
|
|
60
|
+
attribute :email, :string, normalize: [:strip, :downcase]
|
|
61
|
+
attribute :age, :integer
|
|
62
|
+
|
|
63
|
+
# Validações
|
|
64
|
+
validates :name, presence: true
|
|
65
|
+
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
66
|
+
validates :age, numericality: { greater_than: 0 }, allow_nil: true
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Criação de Registros
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
changeset = UserChangeset.new(User.new, {
|
|
74
|
+
name: " João Silva ",
|
|
75
|
+
email: "JOAO@EXAMPLE.COM",
|
|
76
|
+
age: "30"
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
if changeset.valid?
|
|
80
|
+
user = User.create!(changeset.attributes_for_update)
|
|
81
|
+
# => { name: "João Silva", email: "joao@example.com", age: 30 }
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Atualização de Registros (Patch Semantics)
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
user = User.find(1)
|
|
89
|
+
# => #<User name: "João Silva", email: "joao@example.com", age: 30>
|
|
90
|
+
|
|
91
|
+
changeset = UserChangeset.new(user, { name: "João Santos", age: "30" })
|
|
92
|
+
|
|
93
|
+
changeset.changed?
|
|
94
|
+
# => true
|
|
95
|
+
|
|
96
|
+
changeset.changes
|
|
97
|
+
# => { name: ["João Silva", "João Santos"] } # age não mudou, então não está incluído
|
|
98
|
+
|
|
99
|
+
if changeset.valid?
|
|
100
|
+
user.update!(changeset.attributes_for_update)
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Verificando Mudanças
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
changeset = UserChangeset.new(user, { name: "Novo Nome" })
|
|
108
|
+
|
|
109
|
+
changeset.changed? # => true
|
|
110
|
+
|
|
111
|
+
changeset.changes # => { name: ["Nome Antigo", "Novo Nome"] }
|
|
112
|
+
changeset.attributes_for_update # => { name: "Novo Nome" }
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Normalizadores Disponíveis
|
|
116
|
+
|
|
117
|
+
| Normalizador | Descrição |
|
|
118
|
+
|--------------|----------|
|
|
119
|
+
| `:strip` | Remove espaços no início e fim da string |
|
|
120
|
+
| `:squish` | Remove espaços extras internos e externos |
|
|
121
|
+
| `:downcase` | Converte para minúsculas |
|
|
122
|
+
| `:upcase` | Converte para maiúsculas |
|
|
123
|
+
| `:blank_to_nil` | Converte strings vazias ou com apenas espaços para `nil` |
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
class ProductChangeset < ActiveModelChangeset::Base
|
|
127
|
+
attribute :name, :string, normalize: [:strip, :squish]
|
|
128
|
+
attribute :sku, :string, normalize: :upcase
|
|
129
|
+
attribute :description, :string, normalize: :blank_to_nil
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Métodos `#apply` e `#apply!`
|
|
134
|
+
|
|
135
|
+
Para simplificar o fluxo de atualização:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
changeset = UserChangeset.new(user, params)
|
|
139
|
+
|
|
140
|
+
# Retorna true/false
|
|
141
|
+
if changeset.apply
|
|
142
|
+
redirect_to user_path(user)
|
|
143
|
+
else
|
|
144
|
+
render :edit
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Ou levanta exceção
|
|
148
|
+
begin
|
|
149
|
+
changeset.apply!
|
|
150
|
+
rescue ActiveModel::ValidationError => e
|
|
151
|
+
# Tratar erro de validação do changeset
|
|
152
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
153
|
+
# Tratar erro de validação do modelo
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## API Reference
|
|
158
|
+
|
|
159
|
+
### Métodos de Classe
|
|
160
|
+
|
|
161
|
+
| Método | Descrição |
|
|
162
|
+
|--------|----------|
|
|
163
|
+
| `.model(klass)` | Define a classe do modelo associada |
|
|
164
|
+
| `.attribute(name, type, normalize:)` | Declara um atributo com tipo e normalização opcional |
|
|
165
|
+
| `.normalizers` | Retorna hash de normalizadores configurados |
|
|
166
|
+
| `.declared_attribute_names` | Retorna array de nomes de atributos declarados |
|
|
167
|
+
|
|
168
|
+
### Métodos de Instância
|
|
169
|
+
|
|
170
|
+
| Método | Descrição |
|
|
171
|
+
|--------|----------|
|
|
172
|
+
| `#record` | Retorna o registro/modelo sendo modificado |
|
|
173
|
+
| `#raw_input` | Retorna os parâmetros de entrada originais (frozen) |
|
|
174
|
+
| `#changed?` | Retorna `true` se houver atributos alterados |
|
|
175
|
+
| `#changes` | Retorna hash `{ attr: [old, new] }` com mudanças |
|
|
176
|
+
| `#attributes_for_update(include_nil:)` | Retorna hash com atributos alterados |
|
|
177
|
+
| `#apply` | Aplica mudanças se válido, retorna `true/false` |
|
|
178
|
+
| `#apply!` | Aplica mudanças ou levanta exceção |
|
|
179
|
+
|
|
180
|
+
## Casos de Uso
|
|
181
|
+
|
|
182
|
+
### Em Controllers
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
class UsersController < ApplicationController
|
|
186
|
+
def create
|
|
187
|
+
changeset = UserChangeset.new(User.new, user_params)
|
|
188
|
+
|
|
189
|
+
if changeset.valid?
|
|
190
|
+
@user = User.create!(changeset.attributes_for_update)
|
|
191
|
+
render json: @user, status: :created
|
|
192
|
+
else
|
|
193
|
+
render json: { errors: changeset.errors }, status: :unprocessable_entity
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def update
|
|
198
|
+
@user = User.find(params[:id])
|
|
199
|
+
changeset = UserChangeset.new(@user, user_params)
|
|
200
|
+
|
|
201
|
+
if changeset.valid? && changeset.changed?
|
|
202
|
+
@user.update!(changeset.attributes_for_update)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
render json: @user
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
def user_params
|
|
211
|
+
params.require(:user).permit(:name, :email, :age)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Em Service Objects
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
class UpdateUserService
|
|
220
|
+
def initialize(user, params)
|
|
221
|
+
@user = user
|
|
222
|
+
@changeset = UserChangeset.new(user, params)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def call
|
|
226
|
+
return failure(@changeset.errors) unless @changeset.valid?
|
|
227
|
+
return success(@user) unless @changeset.changed?
|
|
228
|
+
|
|
229
|
+
@user.update!(@changeset.attributes_for_update)
|
|
230
|
+
success(@user)
|
|
231
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
232
|
+
failure(e.record.errors)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
private
|
|
236
|
+
|
|
237
|
+
def success(user) = { success: true, user: user }
|
|
238
|
+
def failure(errors) = { success: false, errors: errors }
|
|
239
|
+
end
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Desenvolvimento
|
|
243
|
+
|
|
244
|
+
Após clonar o repositório, execute:
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
bin/setup
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Para rodar os testes:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
bundle exec rspec
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Para rodar os testes com cobertura:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
bundle exec rspec
|
|
260
|
+
open coverage/index.html
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Para rodar o RuboCop:
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
bundle exec rubocop
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Para gerar a documentação:
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
bundle exec yard doc
|
|
273
|
+
bundle exec yard server --reload
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Para abrir um console interativo:
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
bin/console
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### Publicação
|
|
283
|
+
|
|
284
|
+
1. Atualize o número da versão em `lib/active_model_changeset/version.rb`
|
|
285
|
+
2. Execute `bundle exec rake release` para:
|
|
286
|
+
- Criar uma tag git para a versão
|
|
287
|
+
- Fazer push dos commits e da tag
|
|
288
|
+
- Publicar o arquivo `.gem` no [rubygems.org](https://rubygems.org)
|
|
289
|
+
|
|
290
|
+
## Contribuindo
|
|
291
|
+
|
|
292
|
+
Contribuições são bem-vindas! Por favor:
|
|
293
|
+
|
|
294
|
+
1. Faça um fork do projeto
|
|
295
|
+
2. Crie sua feature branch (`git checkout -b feature/minha-feature`)
|
|
296
|
+
3. Commit suas mudanças (`git commit -am 'Adiciona nova feature'`)
|
|
297
|
+
4. Faça push para a branch (`git push origin feature/minha-feature`)
|
|
298
|
+
5. Abra um Pull Request
|
|
299
|
+
|
|
300
|
+
Este projeto segue o [Código de Conduta](CODE_OF_CONDUCT.md). Ao participar, espera-se que você siga estas diretrizes.
|
|
301
|
+
|
|
302
|
+
## Licença
|
|
303
|
+
|
|
304
|
+
Esta gem está disponível como código aberto sob os termos da [Licença MIT](LICENSE.txt).
|
|
305
|
+
|
|
306
|
+
## Código de Conduta
|
|
307
|
+
|
|
308
|
+
Todos os participantes do projeto ActiveModelChangeset devem seguir o [Código de Conduta](CODE_OF_CONDUCT.md).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rspec/core/rake_task"
|
|
5
|
+
|
|
6
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
7
|
+
|
|
8
|
+
require "rubocop/rake_task"
|
|
9
|
+
|
|
10
|
+
RuboCop::RakeTask.new
|
|
11
|
+
|
|
12
|
+
# YARD Documentation
|
|
13
|
+
begin
|
|
14
|
+
require "yard"
|
|
15
|
+
|
|
16
|
+
YARD::Rake::YardocTask.new do |t|
|
|
17
|
+
t.files = ["lib/**/*.rb"]
|
|
18
|
+
t.options = ["--output-dir", "doc", "--readme", "README.md"]
|
|
19
|
+
end
|
|
20
|
+
rescue LoadError
|
|
21
|
+
desc "YARD não disponível"
|
|
22
|
+
task :yard do
|
|
23
|
+
puts "YARD não está instalado. Execute: bundle install"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
task default: %i[spec rubocop]
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveModelChangeset
|
|
4
|
+
# Base é a classe principal que encapsula a lógica de validação, normalização
|
|
5
|
+
# e cálculo de diferenças para operações de criação e atualização de modelos.
|
|
6
|
+
#
|
|
7
|
+
# Ela combina type-casting via ActiveModel::Attributes, normalização declarativa,
|
|
8
|
+
# validações e semântica de patch em uma única classe reutilizável.
|
|
9
|
+
#
|
|
10
|
+
# @example Definindo um changeset
|
|
11
|
+
# class UserChangeset < ActiveModelChangeset::Base
|
|
12
|
+
# model User
|
|
13
|
+
#
|
|
14
|
+
# attribute :name, :string, normalize: [:strip, :squish]
|
|
15
|
+
# attribute :email, :string, normalize: [:strip, :downcase]
|
|
16
|
+
# attribute :age, :integer
|
|
17
|
+
#
|
|
18
|
+
# validates :name, presence: true
|
|
19
|
+
# validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# @example Usando para atualização
|
|
23
|
+
# user = User.find(1)
|
|
24
|
+
# changeset = UserChangeset.new(user, params)
|
|
25
|
+
#
|
|
26
|
+
# if changeset.valid? && changeset.changes.any?
|
|
27
|
+
# user.update!(changeset.attributes_for_update)
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# @see https://api.rubyonrails.org/classes/ActiveModel/Attributes.html
|
|
31
|
+
# @see https://api.rubyonrails.org/classes/ActiveModel/Validations.html
|
|
32
|
+
# :rubocop:disable Metrics/ClassLength
|
|
33
|
+
class Base
|
|
34
|
+
include ActiveModel::Model
|
|
35
|
+
include ActiveModel::Attributes
|
|
36
|
+
include ActiveModel::Validations
|
|
37
|
+
|
|
38
|
+
# Funções de normalização disponíveis para uso com a opção `normalize:`
|
|
39
|
+
#
|
|
40
|
+
# @example
|
|
41
|
+
# attribute :name, :string, normalize: [:strip, :squish]
|
|
42
|
+
#
|
|
43
|
+
NORMALIZER_FUNCS = {
|
|
44
|
+
strip: ->(v) { v.is_a?(String) ? v.strip : v },
|
|
45
|
+
squish: ->(v) { v.is_a?(String) ? v.squish : v },
|
|
46
|
+
downcase: ->(v) { v.is_a?(String) ? v.downcase : v },
|
|
47
|
+
upcase: ->(v) { v.is_a?(String) ? v.upcase : v },
|
|
48
|
+
blank_to_nil: ->(v) { v.respond_to?(:blank?) && v.blank? ? nil : v }
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
class << self
|
|
52
|
+
# Define ou retorna a classe do modelo associada ao changeset.
|
|
53
|
+
#
|
|
54
|
+
# @param klass [Class, nil] a classe do modelo (ex: User, Post)
|
|
55
|
+
# @return [Class, nil] a classe do modelo configurada
|
|
56
|
+
#
|
|
57
|
+
# @example
|
|
58
|
+
# class UserChangeset < ActiveModelChangeset::Base
|
|
59
|
+
# model User
|
|
60
|
+
# end
|
|
61
|
+
#
|
|
62
|
+
def model(klass = nil)
|
|
63
|
+
if klass
|
|
64
|
+
@model_class = klass
|
|
65
|
+
else
|
|
66
|
+
@model_class
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Declara um atributo no changeset com suporte a normalização.
|
|
71
|
+
#
|
|
72
|
+
# Estende o método `attribute` do ActiveModel::Attributes para
|
|
73
|
+
# suportar a opção `normalize:` que aplica transformações ao valor.
|
|
74
|
+
#
|
|
75
|
+
# @param name [Symbol] nome do atributo
|
|
76
|
+
# @param type [Symbol] tipo do atributo (:string, :integer, :boolean, etc.)
|
|
77
|
+
# @param options [Hash] opções adicionais
|
|
78
|
+
# @option options [Symbol, Array<Symbol>] :normalize normalizadores a aplicar
|
|
79
|
+
#
|
|
80
|
+
# @example
|
|
81
|
+
# attribute :email, :string, normalize: [:strip, :downcase]
|
|
82
|
+
# attribute :name, :string, normalize: :squish
|
|
83
|
+
#
|
|
84
|
+
# @return [void]
|
|
85
|
+
#
|
|
86
|
+
def attribute(name, type = :string, **options)
|
|
87
|
+
normalize = options.delete(:normalize)
|
|
88
|
+
super
|
|
89
|
+
normalizers[name.to_sym] = Array(normalize).compact.map(&:to_sym) if normalize
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Retorna o hash de normalizadores configurados por atributo.
|
|
93
|
+
#
|
|
94
|
+
# @return [Hash{Symbol => Array<Symbol>}] mapa de atributo para lista de normalizadores
|
|
95
|
+
#
|
|
96
|
+
def normalizers
|
|
97
|
+
@normalizers ||= {}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Retorna os nomes dos atributos declarados no changeset.
|
|
101
|
+
#
|
|
102
|
+
# @return [Array<Symbol>] lista de nomes de atributos
|
|
103
|
+
#
|
|
104
|
+
def declared_attribute_names
|
|
105
|
+
attribute_types.keys.map(&:to_sym)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @return [Object] o registro/modelo sendo modificado
|
|
110
|
+
attr_reader :record
|
|
111
|
+
|
|
112
|
+
# @return [Hash] os parâmetros de entrada originais (antes de processamento)
|
|
113
|
+
attr_reader :raw_input
|
|
114
|
+
|
|
115
|
+
# Inicializa um novo changeset.
|
|
116
|
+
#
|
|
117
|
+
# @param record [Object] o registro existente para comparação (pode ser nil para criação)
|
|
118
|
+
# @param input [Hash, ActionController::Parameters] os parâmetros de entrada
|
|
119
|
+
#
|
|
120
|
+
# @example Atualização
|
|
121
|
+
# user = User.find(1)
|
|
122
|
+
# changeset = UserChangeset.new(user, { name: "Novo Nome" })
|
|
123
|
+
#
|
|
124
|
+
# @example Criação (record vazio)
|
|
125
|
+
# changeset = UserChangeset.new(User.new, params)
|
|
126
|
+
#
|
|
127
|
+
def initialize(record, input = {})
|
|
128
|
+
@record = record
|
|
129
|
+
@raw_input = input.dup.freeze
|
|
130
|
+
super(extract_declared_attributes(input))
|
|
131
|
+
normalize_attributes!
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Retorna os atributos que foram alterados, prontos para update.
|
|
135
|
+
#
|
|
136
|
+
# Apenas atributos que diferem do registro original são incluídos.
|
|
137
|
+
# Por padrão, valores nil são excluídos do resultado.
|
|
138
|
+
#
|
|
139
|
+
# @param include_nil [Boolean] se true, inclui atributos com valor nil
|
|
140
|
+
# @return [Hash{Symbol => Object}] hash com atributos alterados
|
|
141
|
+
#
|
|
142
|
+
# @example
|
|
143
|
+
# changeset.attributes_for_update
|
|
144
|
+
# # => { name: "João Santos" }
|
|
145
|
+
#
|
|
146
|
+
# changeset.attributes_for_update(include_nil: true)
|
|
147
|
+
# # => { name: "João Santos", bio: nil }
|
|
148
|
+
#
|
|
149
|
+
def attributes_for_update(include_nil: false)
|
|
150
|
+
self.class.declared_attribute_names.each_with_object({}) do |name, hash|
|
|
151
|
+
next unless changed_attribute?(name)
|
|
152
|
+
|
|
153
|
+
value = public_send(name)
|
|
154
|
+
next if !include_nil && value.nil?
|
|
155
|
+
|
|
156
|
+
hash[name] = value
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Retorna um hash com as mudanças no formato { atributo: [valor_antigo, valor_novo] }.
|
|
161
|
+
#
|
|
162
|
+
# Similar ao `ActiveModel::Dirty#changes`, mas calcula a diferença
|
|
163
|
+
# entre o changeset e o registro original.
|
|
164
|
+
#
|
|
165
|
+
# @return [Hash{Symbol => Array}] hash de mudanças
|
|
166
|
+
#
|
|
167
|
+
# @example
|
|
168
|
+
# changeset.changes
|
|
169
|
+
# # => { name: ["João Silva", "João Santos"] }
|
|
170
|
+
#
|
|
171
|
+
def changes
|
|
172
|
+
self.class.declared_attribute_names.each_with_object({}) do |name, hash|
|
|
173
|
+
next unless changed_attribute?(name)
|
|
174
|
+
|
|
175
|
+
hash[name] = [record_value(name), public_send(name)]
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Verifica se há alguma mudança no changeset.
|
|
180
|
+
#
|
|
181
|
+
# @return [Boolean] true se houver pelo menos um atributo alterado
|
|
182
|
+
#
|
|
183
|
+
def changed?
|
|
184
|
+
self.class.declared_attribute_names.any? { |name| changed_attribute?(name) }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Aplica as mudanças ao registro se o changeset for válido.
|
|
188
|
+
#
|
|
189
|
+
# @return [Boolean] true se a atualização foi bem-sucedida, false caso contrário
|
|
190
|
+
#
|
|
191
|
+
# @example
|
|
192
|
+
# if changeset.apply
|
|
193
|
+
# redirect_to user_path
|
|
194
|
+
# else
|
|
195
|
+
# render :edit
|
|
196
|
+
# end
|
|
197
|
+
#
|
|
198
|
+
def apply
|
|
199
|
+
return false unless valid?
|
|
200
|
+
|
|
201
|
+
record.update(attributes_for_update)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Aplica as mudanças ao registro, levantando exceção se inválido.
|
|
205
|
+
#
|
|
206
|
+
# @raise [ActiveModel::ValidationError] se o changeset for inválido
|
|
207
|
+
# @raise [ActiveRecord::RecordInvalid] se o update! falhar
|
|
208
|
+
# @return [Boolean] true se a atualização foi bem-sucedida
|
|
209
|
+
#
|
|
210
|
+
# @example
|
|
211
|
+
# begin
|
|
212
|
+
# changeset.apply!
|
|
213
|
+
# rescue ActiveModel::ValidationError => e
|
|
214
|
+
# # tratar erro de validação do changeset
|
|
215
|
+
# rescue ActiveRecord::RecordInvalid => e
|
|
216
|
+
# # tratar erro de validação do modelo
|
|
217
|
+
# end
|
|
218
|
+
#
|
|
219
|
+
def apply!
|
|
220
|
+
raise ActiveModel::ValidationError, self unless valid?
|
|
221
|
+
|
|
222
|
+
record.update!(attributes_for_update)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
|
|
227
|
+
# Aplica os normalizadores configurados a cada atributo.
|
|
228
|
+
#
|
|
229
|
+
# @return [void]
|
|
230
|
+
#
|
|
231
|
+
def normalize_attributes!
|
|
232
|
+
self.class.normalizers.each do |attr, normalizer_keys|
|
|
233
|
+
value = public_send(attr)
|
|
234
|
+
normalized_value = apply_normalizers(value, normalizer_keys)
|
|
235
|
+
public_send("#{attr}=", normalized_value)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Aplica uma lista de normalizadores a um valor.
|
|
240
|
+
#
|
|
241
|
+
# @param value [Object] o valor a ser normalizado
|
|
242
|
+
# @param normalizer_keys [Array<Symbol>] lista de chaves de normalizadores
|
|
243
|
+
# @return [Object] o valor normalizado
|
|
244
|
+
#
|
|
245
|
+
def apply_normalizers(value, normalizer_keys)
|
|
246
|
+
normalizer_keys.reduce(value) do |val, key|
|
|
247
|
+
fn = NORMALIZER_FUNCS[key]
|
|
248
|
+
fn ? fn.call(val) : val
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Extrai apenas os atributos declarados do input.
|
|
253
|
+
#
|
|
254
|
+
# Funciona como whitelist automática, aceitando apenas atributos
|
|
255
|
+
# explicitamente declarados no changeset.
|
|
256
|
+
#
|
|
257
|
+
# @param input [Hash, ActionController::Parameters] parâmetros de entrada
|
|
258
|
+
# @return [Hash{Symbol => Object}] hash filtrado com apenas atributos declarados
|
|
259
|
+
#
|
|
260
|
+
def extract_declared_attributes(input)
|
|
261
|
+
hash = convert_to_hash(input)
|
|
262
|
+
declared = self.class.declared_attribute_names
|
|
263
|
+
|
|
264
|
+
hash.each_with_object({}) do |(key, value), acc|
|
|
265
|
+
key_sym = safe_to_sym(key)
|
|
266
|
+
acc[key_sym] = value if key_sym && declared.include?(key_sym)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Converte o input para hash de forma segura.
|
|
271
|
+
#
|
|
272
|
+
# Suporta ActionController::Parameters (to_unsafe_h) e objetos
|
|
273
|
+
# que respondem a to_h.
|
|
274
|
+
#
|
|
275
|
+
# @param input [Object] o objeto a ser convertido
|
|
276
|
+
# @return [Hash] o hash resultante
|
|
277
|
+
#
|
|
278
|
+
def convert_to_hash(input)
|
|
279
|
+
if input.respond_to?(:to_unsafe_h)
|
|
280
|
+
input.to_unsafe_h
|
|
281
|
+
elsif input.respond_to?(:to_h)
|
|
282
|
+
input.to_h
|
|
283
|
+
else
|
|
284
|
+
{}
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Converte uma chave para Symbol de forma segura.
|
|
289
|
+
#
|
|
290
|
+
# @param key [Object] a chave a ser convertida
|
|
291
|
+
# @return [Symbol, nil] o symbol ou nil se a conversão falhar
|
|
292
|
+
#
|
|
293
|
+
def safe_to_sym(key)
|
|
294
|
+
key.to_sym
|
|
295
|
+
rescue StandardError
|
|
296
|
+
nil
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Verifica se um atributo específico foi alterado.
|
|
300
|
+
#
|
|
301
|
+
# @param name [Symbol] nome do atributo
|
|
302
|
+
# @return [Boolean] true se o valor no changeset difere do registro
|
|
303
|
+
#
|
|
304
|
+
def changed_attribute?(name)
|
|
305
|
+
record_value(name) != public_send(name)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Obtém o valor atual de um atributo no registro.
|
|
309
|
+
#
|
|
310
|
+
# @param name [Symbol] nome do atributo
|
|
311
|
+
# @return [Object, nil] o valor do atributo ou nil se não existir
|
|
312
|
+
#
|
|
313
|
+
def record_value(name)
|
|
314
|
+
return unless record.respond_to?(name)
|
|
315
|
+
|
|
316
|
+
record.public_send(name)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_model"
|
|
5
|
+
require_relative "active_model_changeset/version"
|
|
6
|
+
require_relative "active_model_changeset/base"
|
|
7
|
+
|
|
8
|
+
# Railtie opcional: só carregue se estiver em Rails
|
|
9
|
+
begin
|
|
10
|
+
require_relative "active_model_changeset/railtie"
|
|
11
|
+
rescue LoadError
|
|
12
|
+
# sem Rails, ok
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
module ActiveModelChangeset
|
|
16
|
+
# Alias para retrocompatibilidade
|
|
17
|
+
# @deprecated Use {ActiveModelChangeset::Base} ao invés
|
|
18
|
+
Changeset = Base
|
|
19
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: active_model_changeset
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Alef ojeda de Oliveira
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-01-16 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activemodel
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '6.1'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '6.1'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: activesupport
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '6.1'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '6.1'
|
|
41
|
+
description: |
|
|
42
|
+
ActiveModelChangeset provides a lightweight changeset abstraction for Ruby on Rails
|
|
43
|
+
applications. It combines type casting, attribute normalization, validation and
|
|
44
|
+
diff calculation into a single object, enabling safe and explicit create/update
|
|
45
|
+
operations with patch semantics.
|
|
46
|
+
|
|
47
|
+
The gem is designed for service objects and APIs, allowing developers to whitelist
|
|
48
|
+
attributes, apply transformations, validate input and update models using only
|
|
49
|
+
changed values, without relying on ActiveRecord callbacks or controllers.
|
|
50
|
+
email:
|
|
51
|
+
- nemubatubag@gmail.com
|
|
52
|
+
executables: []
|
|
53
|
+
extensions: []
|
|
54
|
+
extra_rdoc_files: []
|
|
55
|
+
files:
|
|
56
|
+
- ".yardopts"
|
|
57
|
+
- CHANGELOG.md
|
|
58
|
+
- CODE_OF_CONDUCT.md
|
|
59
|
+
- LICENSE.txt
|
|
60
|
+
- README.md
|
|
61
|
+
- Rakefile
|
|
62
|
+
- lib/active_model_changeset.rb
|
|
63
|
+
- lib/active_model_changeset/base.rb
|
|
64
|
+
- lib/active_model_changeset/railtie.rb
|
|
65
|
+
- lib/active_model_changeset/version.rb
|
|
66
|
+
- sig/active_model_changeset.rbs
|
|
67
|
+
homepage: https://github.com/nemuba/active_model_changeset
|
|
68
|
+
licenses:
|
|
69
|
+
- MIT
|
|
70
|
+
metadata:
|
|
71
|
+
homepage_uri: https://github.com/nemuba/active_model_changeset
|
|
72
|
+
source_code_uri: https://github.com/nemuba/active_model_changeset.git
|
|
73
|
+
changelog_uri: https://github.com/nemuba/active_model_changeset/CHANGELOG.md
|
|
74
|
+
documentation_uri: https://github.com/nemuba/active_model_changeset/doc/index.html
|
|
75
|
+
yard.run: yri, yard
|
|
76
|
+
rubygems_mfa_required: 'true'
|
|
77
|
+
post_install_message:
|
|
78
|
+
rdoc_options: []
|
|
79
|
+
require_paths:
|
|
80
|
+
- lib
|
|
81
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
82
|
+
requirements:
|
|
83
|
+
- - ">="
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: 3.2.0
|
|
86
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
87
|
+
requirements:
|
|
88
|
+
- - ">="
|
|
89
|
+
- !ruby/object:Gem::Version
|
|
90
|
+
version: '0'
|
|
91
|
+
requirements: []
|
|
92
|
+
rubygems_version: 3.4.10
|
|
93
|
+
signing_key:
|
|
94
|
+
specification_version: 4
|
|
95
|
+
summary: Typed, validated changesets for ActiveModel with patch semantics
|
|
96
|
+
test_files: []
|