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 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
@@ -0,0 +1,13 @@
1
+ --readme README.md
2
+ --title "ActiveModelChangeset Documentation"
3
+ --charset utf-8
4
+ --markup markdown
5
+ --output-dir doc
6
+ --protected
7
+ --no-private
8
+ --hide-void-return
9
+ lib/**/*.rb
10
+ -
11
+ README.md
12
+ CHANGELOG.md
13
+ LICENSE.txt
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
@@ -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
+ [![Gem Version](https://badge.fury.io/rb/active_model_changeset.svg)](https://badge.fury.io/rb/active_model_changeset)
4
+ [![Ruby](https://github.com/nemuba/active_model_changeset/workflows/Ruby/badge.svg)](https://github.com/nemuba/active_model_changeset/actions)
5
+ [![Coverage](https://img.shields.io/badge/coverage-93%25-brightgreen)](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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module ActiveModelChangeset
6
+ class Railtie < Rails::Railtie
7
+ initializer "active_model_changeset.require" do
8
+ # Nada a fazer; manter vazio evita efeitos colaterais
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveModelChangeset
4
+ VERSION = "0.1.0"
5
+ 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
@@ -0,0 +1,4 @@
1
+ module ActiveModelChangeset
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ 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: []