better_model 1.0.0 → 1.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.
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterModel
4
+ module Validatable
5
+ # Configurator per il DSL di Validatable
6
+ #
7
+ # Questo configurator permette di definire validazioni in modo dichiarativo
8
+ # all'interno del blocco `validatable do...end`.
9
+ #
10
+ # Esempio:
11
+ # validatable do
12
+ # # Validazioni base
13
+ # validate :title, :content, presence: true
14
+ # validate :email, format: { with: URI::MailTo::EMAIL_REGEXP }
15
+ #
16
+ # # Validazioni condizionali
17
+ # validate_if :is_published? do
18
+ # validate :published_at, presence: true
19
+ # validate :author_id, presence: true
20
+ # end
21
+ #
22
+ # # Validazioni condizionali negate
23
+ # validate_unless :is_draft? do
24
+ # validate :reviewer_id, presence: true
25
+ # end
26
+ #
27
+ # # Cross-field validations
28
+ # validate_order :starts_at, :before, :ends_at
29
+ # validate_order :min_price, :lteq, :max_price
30
+ #
31
+ # # Business rules
32
+ # validate_business_rule :valid_category
33
+ # validate_business_rule :author_has_permission, on: :create
34
+ #
35
+ # # Gruppi di validazioni
36
+ # validation_group :step1, [:email, :password]
37
+ # validation_group :step2, [:first_name, :last_name]
38
+ # end
39
+ #
40
+ class Configurator
41
+ attr_reader :groups
42
+
43
+ def initialize(model_class)
44
+ @model_class = model_class
45
+ @conditional_validations = []
46
+ @order_validations = []
47
+ @business_rules = []
48
+ @groups = {}
49
+ end
50
+
51
+ # Definisce validazioni standard sui campi
52
+ #
53
+ # @param fields [Array<Symbol>] Nomi dei campi
54
+ # @param options [Hash] Opzioni di validazione (presence, format, etc.)
55
+ #
56
+ # @example
57
+ # validate :title, :content, presence: true
58
+ # validate :email, format: { with: URI::MailTo::EMAIL_REGEXP }
59
+ # validate :age, numericality: { greater_than: 0 }
60
+ #
61
+ def validate(*fields, **options)
62
+ # Se siamo dentro un blocco condizionale, aggiungi alla condizione corrente
63
+ if @current_conditional
64
+ @current_conditional[:validations] << {
65
+ fields: fields,
66
+ options: options
67
+ }
68
+ else
69
+ # Altrimenti applica direttamente alla classe
70
+ # Questo viene fatto subito, non in apply_validatable_config
71
+ @model_class.validates(*fields, **options)
72
+ end
73
+ end
74
+
75
+ # Validazioni condizionali (se condizione è vera)
76
+ #
77
+ # @param condition [Symbol, Proc] Condizione da verificare
78
+ # @yield Blocco con validazioni da applicare se condizione è vera
79
+ #
80
+ # @example Con simbolo (metodo)
81
+ # validate_if :is_published? do
82
+ # validate :published_at, presence: true
83
+ # end
84
+ #
85
+ # @example Con lambda
86
+ # validate_if -> { status == "published" } do
87
+ # validate :published_at, presence: true
88
+ # end
89
+ #
90
+ def validate_if(condition, &block)
91
+ raise ArgumentError, "validate_if requires a block" unless block_given?
92
+
93
+ conditional = {
94
+ condition: condition,
95
+ negate: false,
96
+ validations: []
97
+ }
98
+
99
+ # Set current conditional per catturare le validate dentro il blocco
100
+ @current_conditional = conditional
101
+ instance_eval(&block)
102
+ @current_conditional = nil
103
+
104
+ @conditional_validations << conditional
105
+ end
106
+
107
+ # Validazioni condizionali negate (se condizione è falsa)
108
+ #
109
+ # @param condition [Symbol, Proc] Condizione da verificare
110
+ # @yield Blocco con validazioni da applicare se condizione è falsa
111
+ #
112
+ # @example
113
+ # validate_unless :is_draft? do
114
+ # validate :reviewer_id, presence: true
115
+ # end
116
+ #
117
+ def validate_unless(condition, &block)
118
+ raise ArgumentError, "validate_unless requires a block" unless block_given?
119
+
120
+ conditional = {
121
+ condition: condition,
122
+ negate: true,
123
+ validations: []
124
+ }
125
+
126
+ @current_conditional = conditional
127
+ instance_eval(&block)
128
+ @current_conditional = nil
129
+
130
+ @conditional_validations << conditional
131
+ end
132
+
133
+ # Validazione di ordine tra campi (cross-field)
134
+ #
135
+ # Verifica che un campo sia in una relazione d'ordine rispetto ad un altro.
136
+ #
137
+ # @param first_field [Symbol] Primo campo
138
+ # @param comparator [Symbol] Comparatore (:before, :after, :lteq, :gteq)
139
+ # @param second_field [Symbol] Secondo campo
140
+ # @param options [Hash] Opzioni aggiuntive (on, if, unless, message)
141
+ #
142
+ # @example Date validation
143
+ # validate_order :starts_at, :before, :ends_at
144
+ # validate_order :starts_at, :before, :ends_at, message: "must be before end date"
145
+ #
146
+ # @example Numeric validation
147
+ # validate_order :min_price, :lteq, :max_price
148
+ # validate_order :discount, :lteq, :price, on: :create
149
+ #
150
+ # Comparatori supportati:
151
+ # - :before - first < second (date/time)
152
+ # - :after - first > second (date/time)
153
+ # - :lteq - first <= second (numeric)
154
+ # - :gteq - first >= second (numeric)
155
+ # - :lt - first < second (numeric)
156
+ # - :gt - first > second (numeric)
157
+ #
158
+ def validate_order(first_field, comparator, second_field, **options)
159
+ valid_comparators = %i[before after lteq gteq lt gt]
160
+ unless valid_comparators.include?(comparator)
161
+ raise ArgumentError, "Invalid comparator: #{comparator}. Valid: #{valid_comparators.join(', ')}"
162
+ end
163
+
164
+ @order_validations << {
165
+ first_field: first_field,
166
+ comparator: comparator,
167
+ second_field: second_field,
168
+ options: options
169
+ }
170
+ end
171
+
172
+ # Definisce una business rule custom
173
+ #
174
+ # Le business rules sono metodi custom che implementano logica di validazione
175
+ # complessa che non può essere espressa con validatori standard.
176
+ #
177
+ # @param rule_name [Symbol] Nome del metodo che implementa la rule
178
+ # @param options [Hash] Opzioni (on, if, unless)
179
+ #
180
+ # @example
181
+ # # Nel configurator:
182
+ # validate_business_rule :valid_category
183
+ # validate_business_rule :author_has_permission, on: :create
184
+ #
185
+ # # Nel modello (implementazione):
186
+ # def valid_category
187
+ # unless Category.exists?(id: category_id)
188
+ # errors.add(:category_id, "must be a valid category")
189
+ # end
190
+ # end
191
+ #
192
+ # def author_has_permission
193
+ # unless author&.can_create_articles?
194
+ # errors.add(:author_id, "does not have permission")
195
+ # end
196
+ # end
197
+ #
198
+ def validate_business_rule(rule_name, **options)
199
+ @business_rules << {
200
+ name: rule_name,
201
+ options: options
202
+ }
203
+ end
204
+
205
+ # Definisce un gruppo di validazioni
206
+ #
207
+ # I gruppi permettono di validare solo un sottoinsieme di campi,
208
+ # utile per form multi-step o validazioni parziali.
209
+ #
210
+ # @param group_name [Symbol] Nome del gruppo
211
+ # @param fields [Array<Symbol>] Campi inclusi nel gruppo
212
+ #
213
+ # @example
214
+ # validation_group :step1, [:email, :password]
215
+ # validation_group :step2, [:first_name, :last_name]
216
+ # validation_group :step3, [:address, :city, :zip_code]
217
+ #
218
+ # # Utilizzo:
219
+ # user.valid?(:step1) # Valida solo email e password
220
+ # user.errors_for_group(:step1)
221
+ #
222
+ def validation_group(group_name, fields)
223
+ raise ArgumentError, "Group name must be a symbol" unless group_name.is_a?(Symbol)
224
+ raise ArgumentError, "Fields must be an array" unless fields.is_a?(Array)
225
+ raise ArgumentError, "Group already defined: #{group_name}" if @groups.key?(group_name)
226
+
227
+ @groups[group_name] = {
228
+ name: group_name,
229
+ fields: fields
230
+ }
231
+ end
232
+
233
+ # Restituisce la configurazione completa
234
+ #
235
+ # @return [Hash] Configurazione con tutte le validazioni
236
+ def to_h
237
+ {
238
+ conditional_validations: @conditional_validations,
239
+ order_validations: @order_validations,
240
+ business_rules: @business_rules
241
+ }
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterModel
4
+ module Validatable
5
+ # Validator per validazioni di ordine tra campi (cross-field)
6
+ #
7
+ # Verifica che un campo sia in una relazione d'ordine rispetto ad un altro campo.
8
+ # Supporta date/time (before/after) e numeri (lteq/gteq/lt/gt).
9
+ #
10
+ # Esempio:
11
+ # validates_with OrderValidator,
12
+ # attributes: [:starts_at],
13
+ # second_field: :ends_at,
14
+ # comparator: :before
15
+ #
16
+ class OrderValidator < ActiveModel::EachValidator
17
+ COMPARATORS = {
18
+ before: :<,
19
+ after: :>,
20
+ lteq: :<=,
21
+ gteq: :>=,
22
+ lt: :<,
23
+ gt: :>
24
+ }.freeze
25
+
26
+ def initialize(options)
27
+ super
28
+
29
+ @second_field = options[:second_field]
30
+ @comparator = options[:comparator]
31
+
32
+ unless @second_field
33
+ raise ArgumentError, "OrderValidator requires :second_field option"
34
+ end
35
+
36
+ unless COMPARATORS.key?(@comparator)
37
+ raise ArgumentError, "Invalid comparator: #{@comparator}. Valid: #{COMPARATORS.keys.join(', ')}"
38
+ end
39
+ end
40
+
41
+ def validate_each(record, attribute, value)
42
+ second_value = record.send(@second_field)
43
+
44
+ # Skip validation if either field is nil (use presence validation for that)
45
+ return if value.nil? || second_value.nil?
46
+
47
+ # Get the comparison operator
48
+ operator = COMPARATORS[@comparator]
49
+
50
+ # Perform comparison
51
+ unless value.send(operator, second_value)
52
+ record.errors.add(attribute, error_message(attribute, @comparator, @second_field))
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def error_message(first_field, comparator, second_field)
59
+ # Messaggi user-friendly basati sul comparatore
60
+ case comparator
61
+ when :before
62
+ "must be before #{second_field.to_s.humanize.downcase}"
63
+ when :after
64
+ "must be after #{second_field.to_s.humanize.downcase}"
65
+ when :lteq
66
+ "must be less than or equal to #{second_field.to_s.humanize.downcase}"
67
+ when :gteq
68
+ "must be greater than or equal to #{second_field.to_s.humanize.downcase}"
69
+ when :lt
70
+ "must be less than #{second_field.to_s.humanize.downcase}"
71
+ when :gt
72
+ "must be greater than #{second_field.to_s.humanize.downcase}"
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Validatable - Sistema di validazioni dichiarativo per modelli Rails
4
+ #
5
+ # Questo concern permette di definire validazioni in modo dichiarativo e leggibile,
6
+ # con supporto per validazioni condizionali, gruppi, cross-field e business rules.
7
+ #
8
+ # APPROCCIO OPT-IN: Le validazioni dichiarative non sono attive automaticamente.
9
+ # Devi chiamare esplicitamente `validatable do...end` nel tuo modello.
10
+ #
11
+ # Esempio di utilizzo:
12
+ # class Article < ApplicationRecord
13
+ # include BetterModel
14
+ #
15
+ # # Status (già esistente in Statusable)
16
+ # is :draft, -> { status == "draft" }
17
+ # is :published, -> { status == "published" }
18
+ #
19
+ # # Attiva validatable (opt-in)
20
+ # validatable do
21
+ # # Validazioni base
22
+ # validate :title, :content, presence: true
23
+ #
24
+ # # Validazioni condizionali
25
+ # validate_if :is_published? do
26
+ # validate :published_at, presence: true
27
+ # validate :author_id, presence: true
28
+ # end
29
+ #
30
+ # # Cross-field validations
31
+ # validate_order :starts_at, :before, :ends_at
32
+ #
33
+ # # Business rules
34
+ # validate_business_rule :valid_category
35
+ #
36
+ # # Gruppi di validazioni
37
+ # validation_group :step1, [:email, :password]
38
+ # validation_group :step2, [:first_name, :last_name]
39
+ # end
40
+ # end
41
+ #
42
+ # Utilizzo:
43
+ # article.valid? # Tutte le validazioni
44
+ # article.valid?(:step1) # Solo gruppo step1
45
+ #
46
+ module BetterModel
47
+ module Validatable
48
+ extend ActiveSupport::Concern
49
+
50
+ included do
51
+ # Validazione ActiveRecord
52
+ unless ancestors.include?(ActiveRecord::Base)
53
+ raise ArgumentError, "BetterModel::Validatable can only be included in ActiveRecord models"
54
+ end
55
+
56
+ # Configurazione validatable (opt-in)
57
+ class_attribute :validatable_enabled, default: false
58
+ class_attribute :validatable_config, default: {}.freeze
59
+ class_attribute :validatable_groups, default: {}.freeze
60
+ class_attribute :_validatable_setup_done, default: false
61
+ end
62
+
63
+ class_methods do
64
+ # DSL per attivare e configurare validatable (OPT-IN)
65
+ #
66
+ # @example Attivazione base
67
+ # validatable do
68
+ # validate :title, presence: true
69
+ # end
70
+ #
71
+ # @example Con validazioni condizionali
72
+ # validatable do
73
+ # validate_if :is_published? do
74
+ # validate :published_at, presence: true
75
+ # end
76
+ # end
77
+ #
78
+ def validatable(&block)
79
+ # Attiva validatable
80
+ self.validatable_enabled = true
81
+
82
+ # Configura se passato un blocco
83
+ if block_given?
84
+ configurator = Configurator.new(self)
85
+ configurator.instance_eval(&block)
86
+ self.validatable_config = configurator.to_h.freeze
87
+ self.validatable_groups = configurator.groups.freeze
88
+ end
89
+
90
+ # Setup validators only once
91
+ return if self._validatable_setup_done
92
+
93
+ self._validatable_setup_done = true
94
+
95
+ # Apply validators from configuration
96
+ apply_validatable_config
97
+ end
98
+
99
+ # Verifica se validatable è attivo
100
+ #
101
+ # @return [Boolean]
102
+ def validatable_enabled?
103
+ validatable_enabled == true
104
+ end
105
+
106
+ private
107
+
108
+ # Applica le configurazioni di validazione al modello
109
+ def apply_validatable_config
110
+ return unless validatable_config.present?
111
+
112
+ # Apply conditional validations
113
+ validatable_config[:conditional_validations]&.each do |conditional|
114
+ apply_conditional_validation(conditional)
115
+ end
116
+
117
+ # Apply order validations
118
+ validatable_config[:order_validations]&.each do |order_val|
119
+ apply_order_validation(order_val)
120
+ end
121
+
122
+ # Apply business rules
123
+ validatable_config[:business_rules]&.each do |rule|
124
+ apply_business_rule(rule)
125
+ end
126
+ end
127
+
128
+ # Applica una validazione condizionale
129
+ def apply_conditional_validation(conditional)
130
+ condition = conditional[:condition]
131
+ negate = conditional[:negate]
132
+ validations = conditional[:validations]
133
+
134
+ # Create a custom validator for this conditional block
135
+ validate do
136
+ condition_met = if condition.is_a?(Symbol)
137
+ send(condition)
138
+ elsif condition.is_a?(Proc)
139
+ instance_exec(&condition)
140
+ else
141
+ raise ArgumentError, "Condition must be a Symbol or Proc"
142
+ end
143
+
144
+ condition_met = !condition_met if negate
145
+
146
+ if condition_met
147
+ validations.each do |validation|
148
+ apply_validation_in_context(validation)
149
+ end
150
+ end
151
+ end
152
+ end
153
+
154
+ # Applica una validazione order (cross-field)
155
+ def apply_order_validation(order_val)
156
+ validates_with BetterModel::Validatable::OrderValidator,
157
+ attributes: [ order_val[:first_field] ],
158
+ second_field: order_val[:second_field],
159
+ comparator: order_val[:comparator],
160
+ **order_val[:options]
161
+ end
162
+
163
+ # Applica una business rule
164
+ def apply_business_rule(rule)
165
+ validates_with BetterModel::Validatable::BusinessRuleValidator,
166
+ rule_name: rule[:name],
167
+ **rule[:options]
168
+ end
169
+ end
170
+
171
+ # Metodi di istanza
172
+
173
+ # Apply a validation in the context of the current instance
174
+ def apply_validation_in_context(validation)
175
+ fields = validation[:fields]
176
+ options = validation[:options]
177
+
178
+ fields.each do |field|
179
+ options.each do |validator_type, validator_options|
180
+ # Prepare validator options
181
+ # If validator_options is true, convert to empty hash
182
+ # If it's a hash, use as-is
183
+ opts = validator_options.is_a?(Hash) ? validator_options : {}
184
+
185
+ validator = ActiveModel::Validations.const_get("#{validator_type.to_s.camelize}Validator").new(
186
+ attributes: [ field ],
187
+ **opts
188
+ )
189
+ validator.validate(self)
190
+ end
191
+ end
192
+ end
193
+
194
+ # Override valid? per supportare validation groups
195
+ #
196
+ # @param context [Symbol, nil] Context o gruppo di validazione
197
+ # @return [Boolean]
198
+ def valid?(context = nil)
199
+ if context && self.class.validatable_groups.key?(context)
200
+ # Valida solo il gruppo specificato
201
+ validate_group(context)
202
+ else
203
+ # Validazione standard Rails
204
+ super(context)
205
+ end
206
+ end
207
+
208
+ # Valida solo un gruppo specifico
209
+ #
210
+ # @param group_name [Symbol] Nome del gruppo
211
+ # @return [Boolean]
212
+ def validate_group(group_name)
213
+ raise ValidatableNotEnabledError unless self.class.validatable_enabled?
214
+
215
+ group = self.class.validatable_groups[group_name]
216
+ return false unless group
217
+
218
+ # Clear existing errors
219
+ errors.clear
220
+
221
+ # Run validations only for fields in this group
222
+ group[:fields].each do |field|
223
+ run_validations_for_field(field)
224
+ end
225
+
226
+ errors.empty?
227
+ end
228
+
229
+ # Ottieni gli errori per un gruppo specifico
230
+ #
231
+ # @param group_name [Symbol] Nome del gruppo
232
+ # @return [ActiveModel::Errors]
233
+ def errors_for_group(group_name)
234
+ raise ValidatableNotEnabledError unless self.class.validatable_enabled?
235
+
236
+ group = self.class.validatable_groups[group_name]
237
+ return errors unless group
238
+
239
+ # Filter errors to only include fields in this group
240
+ filtered_errors = ActiveModel::Errors.new(self)
241
+ group[:fields].each do |field|
242
+ errors[field].each do |error|
243
+ filtered_errors.add(field, error)
244
+ end
245
+ end
246
+
247
+ filtered_errors
248
+ end
249
+
250
+ private
251
+
252
+ # Run validations for a specific field
253
+ def run_validations_for_field(field)
254
+ # This is a simplified version - Rails validations are complex
255
+ # We'll leverage Rails' built-in validation framework
256
+ self.class.validators_on(field).each do |validator|
257
+ validator.validate(self)
258
+ end
259
+ end
260
+ end
261
+
262
+ # Errori custom
263
+ class ValidatableError < StandardError; end
264
+
265
+ class ValidatableNotEnabledError < ValidatableError
266
+ def initialize(msg = nil)
267
+ super(msg || "Validatable is not enabled. Add 'validatable do...end' to your model.")
268
+ end
269
+ end
270
+ end
@@ -1,3 +1,3 @@
1
1
  module BetterModel
2
- VERSION = "1.0.0"
2
+ VERSION = "1.1.0"
3
3
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterModel
4
+ # Version model for tracking changes
5
+ # This is the base AR model for version history
6
+ # Actual table_name is set dynamically in subclasses
7
+ class Version < ActiveRecord::Base
8
+ self.abstract_class = true
9
+
10
+ # Polymorphic association to the tracked model
11
+ belongs_to :item, polymorphic: true, optional: true
12
+
13
+ # Optional: belongs_to user who made the change
14
+ # belongs_to :updated_by, class_name: "User", optional: true
15
+
16
+ # Serialize object_changes as JSON
17
+ # Rails handles this automatically for json/jsonb columns
18
+
19
+ # Validations
20
+ validates :item_type, :event, presence: true
21
+ validates :event, inclusion: { in: %w[created updated destroyed] }
22
+
23
+ # Scopes
24
+ scope :for_item, ->(item) { where(item_type: item.class.name, item_id: item.id) }
25
+ scope :created_events, -> { where(event: "created") }
26
+ scope :updated_events, -> { where(event: "updated") }
27
+ scope :destroyed_events, -> { where(event: "destroyed") }
28
+ scope :by_user, ->(user_id) { where(updated_by_id: user_id) }
29
+ scope :between, ->(start_time, end_time) { where(created_at: start_time..end_time) }
30
+ scope :recent, ->(limit = 10) { order(created_at: :desc).limit(limit) }
31
+
32
+ # Get the change for a specific field
33
+ #
34
+ # @param field_name [Symbol, String] Field name
35
+ # @return [Hash, nil] Hash with :before and :after keys
36
+ def change_for(field_name)
37
+ return nil unless object_changes
38
+
39
+ field = field_name.to_s
40
+ return nil unless object_changes.key?(field)
41
+
42
+ {
43
+ before: object_changes[field][0],
44
+ after: object_changes[field][1]
45
+ }
46
+ end
47
+
48
+ # Check if a specific field changed
49
+ #
50
+ # @param field_name [Symbol, String] Field name
51
+ # @return [Boolean]
52
+ def changed?(field_name)
53
+ object_changes&.key?(field_name.to_s) || false
54
+ end
55
+
56
+ # Get list of changed fields
57
+ #
58
+ # @return [Array<String>]
59
+ def changed_fields
60
+ object_changes&.keys || []
61
+ end
62
+ end
63
+ end
data/lib/better_model.rb CHANGED
@@ -6,6 +6,18 @@ require "better_model/sortable"
6
6
  require "better_model/predicable"
7
7
  require "better_model/searchable"
8
8
  require "better_model/archivable"
9
+ require "better_model/version_record"
10
+ require "better_model/traceable"
11
+ require "better_model/validatable"
12
+ require "better_model/validatable/configurator"
13
+ require "better_model/validatable/order_validator"
14
+ require "better_model/validatable/business_rule_validator"
15
+ require "better_model/state_transition"
16
+ require "better_model/stateable"
17
+ require "better_model/stateable/configurator"
18
+ require "better_model/stateable/errors"
19
+ require "better_model/stateable/guard"
20
+ require "better_model/stateable/transition"
9
21
 
10
22
  module BetterModel
11
23
  extend ActiveSupport::Concern
@@ -18,7 +30,8 @@ module BetterModel
18
30
  include BetterModel::Predicable
19
31
  include BetterModel::Searchable
20
32
  include BetterModel::Archivable
21
- # Future concerns will be added here:
22
- # include BetterModel::Validatable
33
+ include BetterModel::Traceable
34
+ include BetterModel::Validatable
35
+ include BetterModel::Stateable
23
36
  end
24
37
  end