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.
- checksums.yaml +4 -4
- data/README.md +317 -85
- data/lib/better_model/archivable.rb +10 -10
- data/lib/better_model/predicable.rb +21 -44
- data/lib/better_model/state_transition.rb +106 -0
- data/lib/better_model/stateable/configurator.rb +300 -0
- data/lib/better_model/stateable/errors.rb +45 -0
- data/lib/better_model/stateable/guard.rb +87 -0
- data/lib/better_model/stateable/transition.rb +143 -0
- data/lib/better_model/stateable.rb +340 -0
- data/lib/better_model/traceable.rb +446 -0
- data/lib/better_model/validatable/business_rule_validator.rb +47 -0
- data/lib/better_model/validatable/configurator.rb +245 -0
- data/lib/better_model/validatable/order_validator.rb +77 -0
- data/lib/better_model/validatable.rb +270 -0
- data/lib/better_model/version.rb +1 -1
- data/lib/better_model/version_record.rb +63 -0
- data/lib/better_model.rb +15 -2
- data/lib/generators/better_model/stateable/install_generator.rb +37 -0
- data/lib/generators/better_model/stateable/stateable_generator.rb +50 -0
- data/lib/generators/better_model/stateable/templates/README +38 -0
- data/lib/generators/better_model/stateable/templates/install_migration.rb.tt +20 -0
- data/lib/generators/better_model/stateable/templates/migration.rb.tt +9 -0
- data/lib/generators/better_model/traceable/templates/create_table_migration.rb.tt +28 -0
- data/lib/generators/better_model/traceable/traceable_generator.rb +77 -0
- metadata +22 -3
|
@@ -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:
|
|
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 (
|
|
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
|
-
#
|
|
170
|
-
scope :"#{field_name}
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|