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.
@@ -58,6 +58,9 @@ module BetterModel
58
58
  column = columns_hash[field_name.to_s]
59
59
  next unless column
60
60
 
61
+ # Base predicates available for all column types
62
+ define_base_predicates(field_name)
63
+
61
64
  case column.type
62
65
  when :string, :text
63
66
  define_string_predicates(field_name)
@@ -68,18 +71,14 @@ module BetterModel
68
71
  when :date, :datetime, :time, :timestamp
69
72
  define_date_predicates(field_name)
70
73
  when :jsonb, :json
71
- # JSONB/JSON: base predicates + PostgreSQL-specific
72
- define_base_predicates(field_name)
74
+ # JSONB/JSON: PostgreSQL-specific predicates
73
75
  define_postgresql_jsonb_predicates(field_name)
74
76
  else
75
77
  # Check for array columns (PostgreSQL)
76
78
  if column.respond_to?(:array?) && column.array?
77
- define_base_predicates(field_name)
78
79
  define_postgresql_array_predicates(field_name)
79
- else
80
- # Default: genera solo predicati base
81
- define_base_predicates(field_name)
82
80
  end
81
+ # Unknown types only get base predicates
83
82
  end
84
83
  end
85
84
  end
@@ -161,14 +160,15 @@ module BetterModel
161
160
  )
162
161
  end
163
162
 
164
- # Genera predicati per campi stringa (14 scope)
163
+ # Genera predicati per campi stringa (12 scope)
164
+ # Base predicates (_eq, _not_eq) are defined separately
165
+ # _present is redefined here to handle empty strings
165
166
  def define_string_predicates(field_name)
166
167
  table = arel_table
167
168
  field = table[field_name]
168
169
 
169
- # Comparison (2)
170
- scope :"#{field_name}_eq", ->(value) { where(field.eq(value)) }
171
- scope :"#{field_name}_not_eq", ->(value) { where(field.not_eq(value)) }
170
+ # String-specific presence check (overrides base _present)
171
+ scope :"#{field_name}_present", -> { where(field.not_eq(nil).and(field.not_eq(""))) }
172
172
 
173
173
  # Pattern matching (4)
174
174
  scope :"#{field_name}_matches", ->(pattern) { where(field.matches(pattern)) }
@@ -203,14 +203,11 @@ module BetterModel
203
203
  scope :"#{field_name}_in", ->(values) { where(field.in(Array(values))) }
204
204
  scope :"#{field_name}_not_in", ->(values) { where.not(field.in(Array(values))) }
205
205
 
206
- # Presence (3)
207
- scope :"#{field_name}_present", -> { where(field.not_eq(nil).and(field.not_eq(""))) }
206
+ # Presence (2) - _present is overridden above for string-specific behavior
208
207
  scope :"#{field_name}_blank", -> { where(field.eq(nil).or(field.eq(""))) }
209
208
  scope :"#{field_name}_null", -> { where(field.eq(nil)) }
210
209
 
211
210
  register_predicable_scopes(
212
- :"#{field_name}_eq",
213
- :"#{field_name}_not_eq",
214
211
  :"#{field_name}_matches",
215
212
  :"#{field_name}_start",
216
213
  :"#{field_name}_end",
@@ -226,14 +223,13 @@ module BetterModel
226
223
  )
227
224
  end
228
225
 
229
- # Genera predicati per campi numerici (11 scope)
226
+ # Genera predicati per campi numerici (8 scope)
227
+ # Base predicates (_eq, _not_eq, _present) are defined separately
230
228
  def define_numeric_predicates(field_name)
231
229
  table = arel_table
232
230
  field = table[field_name]
233
231
 
234
- # Comparison (6)
235
- scope :"#{field_name}_eq", ->(value) { where(field.eq(value)) }
236
- scope :"#{field_name}_not_eq", ->(value) { where(field.not_eq(value)) }
232
+ # Comparison (4)
237
233
  scope :"#{field_name}_lt", ->(value) { where(field.lt(value)) }
238
234
  scope :"#{field_name}_lteq", ->(value) { where(field.lteq(value)) }
239
235
  scope :"#{field_name}_gt", ->(value) { where(field.gt(value)) }
@@ -247,12 +243,7 @@ module BetterModel
247
243
  scope :"#{field_name}_in", ->(values) { where(field.in(Array(values))) }
248
244
  scope :"#{field_name}_not_in", ->(values) { where.not(field.in(Array(values))) }
249
245
 
250
- # Presence (1)
251
- scope :"#{field_name}_present", -> { where(field.not_eq(nil)) }
252
-
253
246
  register_predicable_scopes(
254
- :"#{field_name}_eq",
255
- :"#{field_name}_not_eq",
256
247
  :"#{field_name}_lt",
257
248
  :"#{field_name}_lteq",
258
249
  :"#{field_name}_gt",
@@ -265,28 +256,19 @@ module BetterModel
265
256
  )
266
257
  end
267
258
 
268
- # Genera predicati per campi booleani (5 scope)
259
+ # Genera predicati per campi booleani (2 scope)
260
+ # Base predicates (_eq, _not_eq, _present) are defined separately
269
261
  def define_boolean_predicates(field_name)
270
262
  table = arel_table
271
263
  field = table[field_name]
272
264
 
273
- # Comparison (2)
274
- scope :"#{field_name}_eq", ->(value) { where(field.eq(value)) }
275
- scope :"#{field_name}_not_eq", ->(value) { where(field.not_eq(value)) }
276
-
277
265
  # Boolean shortcuts (2)
278
266
  scope :"#{field_name}_true", -> { where(field.eq(true)) }
279
267
  scope :"#{field_name}_false", -> { where(field.eq(false)) }
280
268
 
281
- # Presence (1)
282
- scope :"#{field_name}_present", -> { where(field.not_eq(nil)) }
283
-
284
269
  register_predicable_scopes(
285
- :"#{field_name}_eq",
286
- :"#{field_name}_not_eq",
287
270
  :"#{field_name}_true",
288
- :"#{field_name}_false",
289
- :"#{field_name}_present"
271
+ :"#{field_name}_false"
290
272
  )
291
273
  end
292
274
 
@@ -391,14 +373,13 @@ module BetterModel
391
373
  )
392
374
  end
393
375
 
394
- # Genera predicati per campi data/datetime (22 scope)
376
+ # Genera predicati per campi data/datetime (17 scope)
377
+ # Base predicates (_eq, _not_eq, _present) are defined separately
395
378
  def define_date_predicates(field_name)
396
379
  table = arel_table
397
380
  field = table[field_name]
398
381
 
399
- # Comparison (6)
400
- scope :"#{field_name}_eq", ->(value) { where(field.eq(value)) }
401
- scope :"#{field_name}_not_eq", ->(value) { where(field.not_eq(value)) }
382
+ # Comparison (4)
402
383
  scope :"#{field_name}_lt", ->(value) { where(field.lt(value)) }
403
384
  scope :"#{field_name}_lteq", ->(value) { where(field.lteq(value)) }
404
385
  scope :"#{field_name}_gt", ->(value) { where(field.gt(value)) }
@@ -440,15 +421,12 @@ module BetterModel
440
421
  where(field.gteq(time_ago))
441
422
  }
442
423
 
443
- # Presence (4)
444
- scope :"#{field_name}_present", -> { where(field.not_eq(nil)) }
424
+ # Presence (3) - _present is defined in base predicates
445
425
  scope :"#{field_name}_blank", -> { where(field.eq(nil)) }
446
426
  scope :"#{field_name}_null", -> { where(field.eq(nil)) }
447
427
  scope :"#{field_name}_not_null", -> { where(field.not_eq(nil)) }
448
428
 
449
429
  register_predicable_scopes(
450
- :"#{field_name}_eq",
451
- :"#{field_name}_not_eq",
452
430
  :"#{field_name}_lt",
453
431
  :"#{field_name}_lteq",
454
432
  :"#{field_name}_gt",
@@ -465,7 +443,6 @@ module BetterModel
465
443
  :"#{field_name}_past",
466
444
  :"#{field_name}_future",
467
445
  :"#{field_name}_within",
468
- :"#{field_name}_present",
469
446
  :"#{field_name}_blank",
470
447
  :"#{field_name}_null",
471
448
  :"#{field_name}_not_null"
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterModel
4
+ # StateTransition - Base ActiveRecord model for state transition history
5
+ #
6
+ # Questo è un modello abstract. Le classi concrete vengono generate dinamicamente
7
+ # per ogni tabella (state_transitions, order_transitions, etc.).
8
+ #
9
+ # Schema della tabella:
10
+ # t.string :transitionable_type, null: false
11
+ # t.integer :transitionable_id, null: false
12
+ # t.string :event, null: false
13
+ # t.string :from_state, null: false
14
+ # t.string :to_state, null: false
15
+ # t.json :metadata
16
+ # t.datetime :created_at, null: false
17
+ #
18
+ # Utilizzo:
19
+ # # Tutte le transizioni di un modello
20
+ # order.state_transitions
21
+ #
22
+ # # Query globali (tramite classi dinamiche)
23
+ # BetterModel::StateTransitions.for_model(Order)
24
+ # BetterModel::OrderTransitions.by_event(:confirm)
25
+ #
26
+ class StateTransition < ActiveRecord::Base
27
+ # Default table name (can be overridden by dynamic subclasses)
28
+ self.table_name = "state_transitions"
29
+
30
+ # Polymorphic association
31
+ belongs_to :transitionable, polymorphic: true
32
+
33
+ # Validations
34
+ validates :event, :from_state, :to_state, presence: true
35
+
36
+ # Scopes
37
+
38
+ # Scope per modello specifico
39
+ #
40
+ # @param model_class [Class] Classe del modello
41
+ # @return [ActiveRecord::Relation]
42
+ #
43
+ scope :for_model, ->(model_class) {
44
+ where(transitionable_type: model_class.name)
45
+ }
46
+
47
+ # Scope per evento specifico
48
+ #
49
+ # @param event [Symbol, String] Nome dell'evento
50
+ # @return [ActiveRecord::Relation]
51
+ #
52
+ scope :by_event, ->(event) {
53
+ where(event: event.to_s)
54
+ }
55
+
56
+ # Scope per stato di partenza
57
+ #
58
+ # @param state [Symbol, String] Stato di partenza
59
+ # @return [ActiveRecord::Relation]
60
+ #
61
+ scope :from_state, ->(state) {
62
+ where(from_state: state.to_s)
63
+ }
64
+
65
+ # Scope per stato di arrivo
66
+ #
67
+ # @param state [Symbol, String] Stato di arrivo
68
+ # @return [ActiveRecord::Relation]
69
+ #
70
+ scope :to_state, ->(state) {
71
+ where(to_state: state.to_s)
72
+ }
73
+
74
+ # Scope per transizioni recenti
75
+ #
76
+ # @param duration [ActiveSupport::Duration] Durata (es. 7.days)
77
+ # @return [ActiveRecord::Relation]
78
+ #
79
+ scope :recent, ->(duration = 7.days) {
80
+ where("created_at >= ?", duration.ago)
81
+ }
82
+
83
+ # Scope per transizioni in un periodo
84
+ #
85
+ # @param start_time [Time, Date] Inizio periodo
86
+ # @param end_time [Time, Date] Fine periodo
87
+ # @return [ActiveRecord::Relation]
88
+ #
89
+ scope :between, ->(start_time, end_time) {
90
+ where(created_at: start_time..end_time)
91
+ }
92
+
93
+ # Metodi di istanza
94
+
95
+ # Formatted description della transizione
96
+ #
97
+ # @return [String]
98
+ #
99
+ def description
100
+ "#{transitionable_type}##{transitionable_id}: #{from_state} -> #{to_state} (#{event})"
101
+ end
102
+
103
+ # Alias per retrocompatibilità
104
+ alias_method :to_s, :description
105
+ end
106
+ end
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterModel
4
+ module Stateable
5
+ # Configurator per il DSL di Stateable
6
+ #
7
+ # Questo configurator permette di definire state machines in modo dichiarativo
8
+ # all'interno del blocco `stateable do...end`.
9
+ #
10
+ # Esempio:
11
+ # stateable do
12
+ # # Definisci stati
13
+ # state :pending, initial: true
14
+ # state :confirmed
15
+ # state :paid
16
+ #
17
+ # # Definisci transizioni
18
+ # transition :confirm, from: :pending, to: :confirmed do
19
+ # guard { items.any? }
20
+ # guard :customer_valid?
21
+ # guard if: :is_ready?
22
+ #
23
+ # validate { errors.add(:base, "Invalid") unless valid_for_confirmation? }
24
+ #
25
+ # before { prepare }
26
+ # after { notify }
27
+ # end
28
+ # end
29
+ #
30
+ class Configurator
31
+ attr_reader :states, :transitions, :initial_state, :table_name
32
+
33
+ def initialize(model_class)
34
+ @model_class = model_class
35
+ @states = []
36
+ @transitions = {}
37
+ @initial_state = nil
38
+ @table_name = nil
39
+ @current_transition = nil
40
+ end
41
+
42
+ # Definisce uno stato
43
+ #
44
+ # @param name [Symbol] Nome dello stato
45
+ # @param initial [Boolean] Se questo è lo stato iniziale
46
+ #
47
+ # @example
48
+ # state :draft, initial: true
49
+ # state :published
50
+ # state :archived
51
+ #
52
+ def state(name, initial: false)
53
+ raise ArgumentError, "State name must be a symbol" unless name.is_a?(Symbol)
54
+ raise ArgumentError, "State #{name} already defined" if @states.include?(name)
55
+
56
+ @states << name
57
+
58
+ if initial
59
+ raise ArgumentError, "Initial state already defined as #{@initial_state}" if @initial_state
60
+ @initial_state = name
61
+ end
62
+ end
63
+
64
+ # Definisce una transizione
65
+ #
66
+ # @param event [Symbol] Nome dell'evento/transizione
67
+ # @param from [Symbol, Array<Symbol>] Stato/i di partenza
68
+ # @param to [Symbol] Stato di arrivo
69
+ # @yield Blocco per configurare guards, validations, callbacks
70
+ #
71
+ # @example Transizione semplice
72
+ # transition :publish, from: :draft, to: :published
73
+ #
74
+ # @example Con guards e callbacks
75
+ # transition :confirm, from: :pending, to: :confirmed do
76
+ # guard { valid? }
77
+ # guard :ready_to_confirm?
78
+ # before { prepare_confirmation }
79
+ # after { send_email }
80
+ # end
81
+ #
82
+ # @example Da multipli stati
83
+ # transition :cancel, from: [:pending, :confirmed, :paid], to: :cancelled
84
+ #
85
+ def transition(event, from:, to:, &block)
86
+ raise ArgumentError, "Event name must be a symbol" unless event.is_a?(Symbol)
87
+ raise ArgumentError, "Transition #{event} already defined" if @transitions.key?(event)
88
+
89
+ # Normalizza from in array
90
+ from_states = Array(from)
91
+
92
+ # Verifica che gli stati esistano
93
+ from_states.each do |state_name|
94
+ unless @states.include?(state_name)
95
+ raise ArgumentError, "Unknown state in from: #{state_name}. Define it with 'state :#{state_name}' first."
96
+ end
97
+ end
98
+
99
+ unless @states.include?(to)
100
+ raise ArgumentError, "Unknown state in to: #{to}. Define it with 'state :#{to}' first."
101
+ end
102
+
103
+ # Inizializza configurazione transizione
104
+ @transitions[event] = {
105
+ from: from_states,
106
+ to: to,
107
+ guards: [],
108
+ validations: [],
109
+ before_callbacks: [],
110
+ after_callbacks: [],
111
+ around_callbacks: []
112
+ }
113
+
114
+ # Se c'è un blocco, configuralo
115
+ if block_given?
116
+ @current_transition = @transitions[event]
117
+ instance_eval(&block)
118
+ @current_transition = nil
119
+ end
120
+ end
121
+
122
+ # Definisce un guard per la transizione corrente
123
+ #
124
+ # I guards sono precondizioni che devono essere vere per permettere la transizione.
125
+ #
126
+ # @overload guard(&block)
127
+ # Guard con lambda/proc
128
+ # @yield Blocco da valutare nel contesto dell'istanza
129
+ # @example
130
+ # guard { items.any? && customer.present? }
131
+ #
132
+ # @overload guard(method_name)
133
+ # Guard con metodo
134
+ # @param method_name [Symbol] Nome del metodo da chiamare
135
+ # @example
136
+ # guard :customer_valid?
137
+ #
138
+ # @overload guard(if: predicate)
139
+ # Guard con Statusable predicate
140
+ # @param if [Symbol] Nome del predicate (integrazione Statusable)
141
+ # @example
142
+ # guard if: :is_ready_for_publishing?
143
+ #
144
+ def guard(method_name = nil, if: nil, &block)
145
+ raise StateableError, "guard can only be called inside a transition block" unless @current_transition
146
+
147
+ if block_given?
148
+ @current_transition[:guards] << { type: :block, block: block }
149
+ elsif method_name
150
+ @current_transition[:guards] << { type: :method, method: method_name }
151
+ elsif binding.local_variable_get(:if)
152
+ @current_transition[:guards] << { type: :predicate, predicate: binding.local_variable_get(:if) }
153
+ else
154
+ raise ArgumentError, "guard requires either a block, method name, or if: option"
155
+ end
156
+ end
157
+
158
+ # Definisce una validazione per la transizione corrente
159
+ #
160
+ # Le validazioni sono eseguite dopo i guards e prima dei callbacks.
161
+ # Devono aggiungere errori all'oggetto errors se la validazione fallisce.
162
+ #
163
+ # @yield Blocco da valutare nel contesto dell'istanza
164
+ #
165
+ # @example
166
+ # validate do
167
+ # errors.add(:base, "Stock unavailable") unless stock_available?
168
+ # errors.add(:payment, "required") if payment_method.blank?
169
+ # end
170
+ #
171
+ def validate(&block)
172
+ raise StateableError, "validate can only be called inside a transition block" unless @current_transition
173
+ raise ArgumentError, "validate requires a block" unless block_given?
174
+
175
+ @current_transition[:validations] << block
176
+ end
177
+
178
+ # Definisce un callback before per la transizione corrente
179
+ #
180
+ # I before callbacks sono eseguiti prima della transizione di stato.
181
+ #
182
+ # @overload before(&block)
183
+ # Before callback con lambda/proc
184
+ # @yield Blocco da eseguire
185
+ # @example
186
+ # before { calculate_total }
187
+ #
188
+ # @overload before(method_name)
189
+ # Before callback con metodo
190
+ # @param method_name [Symbol] Nome del metodo da chiamare
191
+ # @example
192
+ # before :calculate_total
193
+ #
194
+ def before(method_name = nil, &block)
195
+ raise StateableError, "before can only be called inside a transition block" unless @current_transition
196
+
197
+ if block_given?
198
+ @current_transition[:before_callbacks] << { type: :block, block: block }
199
+ elsif method_name
200
+ @current_transition[:before_callbacks] << { type: :method, method: method_name }
201
+ else
202
+ raise ArgumentError, "before requires either a block or method name"
203
+ end
204
+ end
205
+
206
+ # Definisce un callback after per la transizione corrente
207
+ #
208
+ # Gli after callbacks sono eseguiti dopo la transizione di stato.
209
+ #
210
+ # @overload after(&block)
211
+ # After callback con lambda/proc
212
+ # @yield Blocco da eseguire
213
+ # @example
214
+ # after { send_notification }
215
+ #
216
+ # @overload after(method_name)
217
+ # After callback con metodo
218
+ # @param method_name [Symbol] Nome del metodo da chiamare
219
+ # @example
220
+ # after :send_notification
221
+ #
222
+ def after(method_name = nil, &block)
223
+ raise StateableError, "after can only be called inside a transition block" unless @current_transition
224
+
225
+ if block_given?
226
+ @current_transition[:after_callbacks] << { type: :block, block: block }
227
+ elsif method_name
228
+ @current_transition[:after_callbacks] << { type: :method, method: method_name }
229
+ else
230
+ raise ArgumentError, "after requires either a block or method name"
231
+ end
232
+ end
233
+
234
+ # Definisce un callback around per la transizione corrente
235
+ #
236
+ # Gli around callbacks wrappano la transizione di stato.
237
+ # Il blocco riceve un altro blocco che deve chiamare per eseguire la transizione.
238
+ #
239
+ # @yield Blocco da eseguire, riceve un blocco da chiamare
240
+ #
241
+ # @example
242
+ # around do |transition|
243
+ # log_start
244
+ # transition.call
245
+ # log_end
246
+ # end
247
+ #
248
+ def around(&block)
249
+ raise StateableError, "around can only be called inside a transition block" unless @current_transition
250
+ raise ArgumentError, "around requires a block" unless block_given?
251
+
252
+ @current_transition[:around_callbacks] << block
253
+ end
254
+
255
+ # Specifica il nome della tabella per state transitions
256
+ #
257
+ # @param name [String, Symbol] Nome della tabella
258
+ #
259
+ # @example Default (state_transitions)
260
+ # stateable do
261
+ # # Uses 'state_transitions' table by default
262
+ # end
263
+ #
264
+ # @example Custom table name
265
+ # stateable do
266
+ # transitions_table 'order_transitions'
267
+ # end
268
+ #
269
+ # @example Shared table across models
270
+ # class Order < ApplicationRecord
271
+ # stateable do
272
+ # transitions_table 'transitions'
273
+ # end
274
+ # end
275
+ #
276
+ # class Article < ApplicationRecord
277
+ # stateable do
278
+ # transitions_table 'transitions' # Same table
279
+ # end
280
+ # end
281
+ #
282
+ def transitions_table(name)
283
+ @table_name = name.to_s
284
+ end
285
+
286
+ # Restituisce la configurazione completa
287
+ #
288
+ # @return [Hash] Configurazione con stati e transizioni
289
+ #
290
+ def to_h
291
+ {
292
+ states: @states,
293
+ transitions: @transitions,
294
+ initial_state: @initial_state,
295
+ table_name: @table_name
296
+ }
297
+ end
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterModel
4
+ module Stateable
5
+ # Base error for all Stateable errors
6
+ class StateableError < StandardError; end
7
+
8
+ # Raised when Stateable is not enabled but methods are called
9
+ class NotEnabledError < StateableError
10
+ def initialize(msg = nil)
11
+ super(msg || "Stateable is not enabled. Add 'stateable do...end' to your model.")
12
+ end
13
+ end
14
+
15
+ # Raised when an invalid state is referenced
16
+ class InvalidStateError < StateableError
17
+ def initialize(state)
18
+ super("Invalid state: #{state.inspect}")
19
+ end
20
+ end
21
+
22
+ # Raised when trying to transition to an invalid state from current state
23
+ class InvalidTransitionError < StateableError
24
+ def initialize(event, from_state, to_state)
25
+ super("Cannot transition from #{from_state.inspect} to #{to_state.inspect} via #{event.inspect}")
26
+ end
27
+ end
28
+
29
+ # Raised when a guard condition fails
30
+ class GuardFailedError < StateableError
31
+ def initialize(event, guard_description = nil)
32
+ msg = "Guard failed for transition #{event.inspect}"
33
+ msg += ": #{guard_description}" if guard_description
34
+ super(msg)
35
+ end
36
+ end
37
+
38
+ # Raised when a transition validation fails
39
+ class ValidationFailedError < StateableError
40
+ def initialize(event, errors)
41
+ super("Validation failed for transition #{event.inspect}: #{errors.full_messages.join(', ')}")
42
+ end
43
+ end
44
+ end
45
+ end