better_model 2.1.0 → 3.0.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 +96 -13
- data/lib/better_model/archivable.rb +203 -91
- data/lib/better_model/errors/archivable/already_archived_error.rb +11 -0
- data/lib/better_model/errors/archivable/archivable_error.rb +13 -0
- data/lib/better_model/errors/archivable/configuration_error.rb +10 -0
- data/lib/better_model/errors/archivable/not_archived_error.rb +11 -0
- data/lib/better_model/errors/archivable/not_enabled_error.rb +11 -0
- data/lib/better_model/errors/better_model_error.rb +9 -0
- data/lib/better_model/errors/permissible/configuration_error.rb +9 -0
- data/lib/better_model/errors/permissible/permissible_error.rb +13 -0
- data/lib/better_model/errors/predicable/configuration_error.rb +9 -0
- data/lib/better_model/errors/predicable/predicable_error.rb +13 -0
- data/lib/better_model/errors/searchable/configuration_error.rb +9 -0
- data/lib/better_model/errors/searchable/invalid_order_error.rb +11 -0
- data/lib/better_model/errors/searchable/invalid_pagination_error.rb +11 -0
- data/lib/better_model/errors/searchable/invalid_predicate_error.rb +11 -0
- data/lib/better_model/errors/searchable/invalid_security_error.rb +11 -0
- data/lib/better_model/errors/searchable/searchable_error.rb +13 -0
- data/lib/better_model/errors/sortable/configuration_error.rb +10 -0
- data/lib/better_model/errors/sortable/sortable_error.rb +13 -0
- data/lib/better_model/errors/stateable/check_failed_error.rb +14 -0
- data/lib/better_model/errors/stateable/configuration_error.rb +10 -0
- data/lib/better_model/errors/stateable/invalid_state_error.rb +11 -0
- data/lib/better_model/errors/stateable/invalid_transition_error.rb +11 -0
- data/lib/better_model/errors/stateable/not_enabled_error.rb +11 -0
- data/lib/better_model/errors/stateable/stateable_error.rb +13 -0
- data/lib/better_model/errors/stateable/validation_failed_error.rb +11 -0
- data/lib/better_model/errors/statusable/configuration_error.rb +9 -0
- data/lib/better_model/errors/statusable/statusable_error.rb +13 -0
- data/lib/better_model/errors/taggable/configuration_error.rb +10 -0
- data/lib/better_model/errors/taggable/taggable_error.rb +13 -0
- data/lib/better_model/errors/traceable/configuration_error.rb +10 -0
- data/lib/better_model/errors/traceable/not_enabled_error.rb +11 -0
- data/lib/better_model/errors/traceable/traceable_error.rb +13 -0
- data/lib/better_model/errors/validatable/configuration_error.rb +10 -0
- data/lib/better_model/errors/validatable/not_enabled_error.rb +11 -0
- data/lib/better_model/errors/validatable/validatable_error.rb +13 -0
- data/lib/better_model/models/state_transition.rb +122 -0
- data/lib/better_model/models/version.rb +68 -0
- data/lib/better_model/permissible.rb +103 -52
- data/lib/better_model/predicable.rb +114 -63
- data/lib/better_model/repositable/base_repository.rb +232 -0
- data/lib/better_model/repositable.rb +32 -0
- data/lib/better_model/searchable.rb +92 -92
- data/lib/better_model/sortable.rb +137 -41
- data/lib/better_model/stateable/configurator.rb +71 -53
- data/lib/better_model/stateable/guard.rb +35 -15
- data/lib/better_model/stateable/transition.rb +59 -30
- data/lib/better_model/stateable.rb +33 -15
- data/lib/better_model/statusable.rb +84 -52
- data/lib/better_model/taggable.rb +120 -75
- data/lib/better_model/traceable.rb +56 -48
- data/lib/better_model/validatable/configurator.rb +49 -172
- data/lib/better_model/validatable.rb +88 -113
- data/lib/better_model/version.rb +1 -1
- data/lib/better_model.rb +42 -5
- data/lib/generators/better_model/repository/repository_generator.rb +141 -0
- data/lib/generators/better_model/repository/templates/application_repository.rb.tt +21 -0
- data/lib/generators/better_model/repository/templates/repository.rb.tt +71 -0
- data/lib/generators/better_model/stateable/templates/README +1 -1
- metadata +44 -7
- data/lib/better_model/state_transition.rb +0 -106
- data/lib/better_model/stateable/errors.rb +0 -48
- data/lib/better_model/validatable/business_rule_validator.rb +0 -47
- data/lib/better_model/validatable/order_validator.rb +0 -77
- data/lib/better_model/version_record.rb +0 -66
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "errors/sortable/sortable_error"
|
|
4
|
+
require_relative "errors/sortable/configuration_error"
|
|
5
|
+
|
|
6
|
+
# Sortable - Declarative sorting system for Rails models.
|
|
4
7
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
8
|
+
# This concern enables defining sorts on models using a simple, declarative DSL
|
|
9
|
+
# that automatically generates scopes based on column type.
|
|
7
10
|
#
|
|
8
|
-
#
|
|
11
|
+
# @example Basic Usage
|
|
9
12
|
# class Article < ApplicationRecord
|
|
10
13
|
# include BetterModel::Sortable
|
|
11
14
|
#
|
|
12
15
|
# sort :title, :view_count, :published_at
|
|
13
16
|
# end
|
|
14
17
|
#
|
|
15
|
-
#
|
|
18
|
+
# @example Generated Scopes
|
|
16
19
|
# Article.sort_title_asc # ORDER BY title ASC
|
|
17
20
|
# Article.sort_title_desc_i # ORDER BY LOWER(title) DESC
|
|
18
21
|
# Article.sort_view_count_desc_nulls_last # ORDER BY view_count DESC NULLS LAST
|
|
@@ -23,26 +26,30 @@ module BetterModel
|
|
|
23
26
|
extend ActiveSupport::Concern
|
|
24
27
|
|
|
25
28
|
included do
|
|
26
|
-
#
|
|
29
|
+
# Validate ActiveRecord inheritance
|
|
27
30
|
unless ancestors.include?(ActiveRecord::Base)
|
|
28
|
-
raise
|
|
31
|
+
raise BetterModel::Errors::Sortable::ConfigurationError, "BetterModel::Sortable can only be included in ActiveRecord models"
|
|
29
32
|
end
|
|
30
33
|
|
|
31
|
-
# Registry
|
|
34
|
+
# Registry of sortable fields defined for this class
|
|
32
35
|
class_attribute :sortable_fields, default: Set.new
|
|
33
|
-
# Registry
|
|
36
|
+
# Registry of generated sortable scopes
|
|
34
37
|
class_attribute :sortable_scopes, default: Set.new
|
|
38
|
+
# Registry of custom complex sorts
|
|
39
|
+
class_attribute :complex_sorts_registry, default: {}.freeze
|
|
35
40
|
end
|
|
36
41
|
|
|
37
42
|
class_methods do
|
|
38
|
-
# DSL
|
|
43
|
+
# DSL to define sortable fields.
|
|
39
44
|
#
|
|
40
|
-
#
|
|
45
|
+
# Automatically generates sorting scopes based on column type:
|
|
41
46
|
# - String: _asc, _desc, _asc_i, _desc_i (case-insensitive)
|
|
42
47
|
# - Numeric: _asc, _desc, _asc_nulls_last, _desc_nulls_last, etc.
|
|
43
48
|
# - Date: _asc, _desc, _newest, _oldest
|
|
44
49
|
#
|
|
45
|
-
#
|
|
50
|
+
# @param field_names [Array<Symbol>] Field names to make sortable
|
|
51
|
+
#
|
|
52
|
+
# @example
|
|
46
53
|
# sort :title, :view_count, :published_at
|
|
47
54
|
def sort(*field_names)
|
|
48
55
|
field_names.each do |field_name|
|
|
@@ -67,36 +74,103 @@ module BetterModel
|
|
|
67
74
|
end
|
|
68
75
|
end
|
|
69
76
|
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
77
|
+
# Register a custom complex sort.
|
|
78
|
+
#
|
|
79
|
+
# Allows defining complex sorts that combine multiple fields
|
|
80
|
+
# or use custom logic not covered by standard sorts.
|
|
81
|
+
#
|
|
82
|
+
# @param name [Symbol] Sort name (will be prefixed with sort_)
|
|
83
|
+
# @yield Sort implementation block
|
|
84
|
+
# @raise [BetterModel::Errors::Sortable::ConfigurationError] If block is not provided
|
|
85
|
+
#
|
|
86
|
+
# @example Multi-field sort
|
|
87
|
+
# register_complex_sort :by_popularity do
|
|
88
|
+
# order(view_count: :desc, published_at: :desc)
|
|
89
|
+
# end
|
|
90
|
+
#
|
|
91
|
+
# Article.sort_by_popularity
|
|
92
|
+
#
|
|
93
|
+
# @example Parametrized sort
|
|
94
|
+
# register_complex_sort :by_relevance do |keyword|
|
|
95
|
+
# order(Arel.sql("CASE WHEN title ILIKE '%#{sanitize_sql_like(keyword)}%' THEN 1 ELSE 2 END"))
|
|
96
|
+
# end
|
|
97
|
+
#
|
|
98
|
+
# Article.sort_by_relevance('rails')
|
|
99
|
+
def register_complex_sort(name, &block)
|
|
100
|
+
unless block_given?
|
|
101
|
+
raise BetterModel::Errors::Sortable::ConfigurationError, "Block required for complex sort"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Register in registry
|
|
105
|
+
self.complex_sorts_registry = complex_sorts_registry.merge(name.to_sym => block).freeze
|
|
106
|
+
|
|
107
|
+
# Define scope
|
|
108
|
+
scope :"sort_#{name}", block
|
|
74
109
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
sortable_scopes.include?(scope_name.to_sym)
|
|
110
|
+
# Register scope
|
|
111
|
+
register_sortable_scopes(:"sort_#{name}")
|
|
78
112
|
end
|
|
79
113
|
|
|
114
|
+
# Check if a field has been registered as sortable.
|
|
115
|
+
#
|
|
116
|
+
# @param field_name [Symbol] Field name to check
|
|
117
|
+
# @return [Boolean] true if field is sortable
|
|
118
|
+
#
|
|
119
|
+
# @example
|
|
120
|
+
# Article.sortable_field?(:title) # => true
|
|
121
|
+
def sortable_field?(field_name) = sortable_fields.include?(field_name.to_sym)
|
|
122
|
+
|
|
123
|
+
# Check if a sortable scope has been generated.
|
|
124
|
+
#
|
|
125
|
+
# @param scope_name [Symbol] Scope name to check
|
|
126
|
+
# @return [Boolean] true if scope exists
|
|
127
|
+
#
|
|
128
|
+
# @example
|
|
129
|
+
# Article.sortable_scope?(:sort_title_asc) # => true
|
|
130
|
+
def sortable_scope?(scope_name) = sortable_scopes.include?(scope_name.to_sym)
|
|
131
|
+
|
|
132
|
+
# Check if a complex sort has been registered.
|
|
133
|
+
#
|
|
134
|
+
# @param name [Symbol] Sort name to check
|
|
135
|
+
# @return [Boolean] true if complex sort exists
|
|
136
|
+
#
|
|
137
|
+
# @example
|
|
138
|
+
# Article.complex_sort?(:by_popularity) # => true
|
|
139
|
+
def complex_sort?(name) = complex_sorts_registry.key?(name.to_sym)
|
|
140
|
+
|
|
80
141
|
private
|
|
81
142
|
|
|
82
|
-
#
|
|
143
|
+
# Validate that field exists in table.
|
|
144
|
+
#
|
|
145
|
+
# @param field_name [Symbol] Field name to validate
|
|
146
|
+
# @raise [BetterModel::Errors::Sortable::ConfigurationError] If field doesn't exist
|
|
147
|
+
# @api private
|
|
83
148
|
def validate_sortable_field!(field_name)
|
|
84
149
|
unless column_names.include?(field_name.to_s)
|
|
85
|
-
raise
|
|
150
|
+
raise BetterModel::Errors::Sortable::ConfigurationError, "Invalid field name: #{field_name}. Field does not exist in #{table_name}"
|
|
86
151
|
end
|
|
87
152
|
end
|
|
88
153
|
|
|
89
|
-
#
|
|
154
|
+
# Register a field in sortable_fields registry.
|
|
155
|
+
#
|
|
156
|
+
# @param field_name [Symbol] Field name to register
|
|
157
|
+
# @api private
|
|
90
158
|
def register_sortable_field(field_name)
|
|
91
159
|
self.sortable_fields = (sortable_fields + [ field_name.to_sym ]).to_set.freeze
|
|
92
160
|
end
|
|
93
161
|
|
|
94
|
-
#
|
|
162
|
+
# Register scopes in sortable_scopes registry.
|
|
163
|
+
#
|
|
164
|
+
# @param scope_names [Array<Symbol>] Scope names to register
|
|
165
|
+
# @api private
|
|
95
166
|
def register_sortable_scopes(*scope_names)
|
|
96
167
|
self.sortable_scopes = (sortable_scopes + scope_names.map(&:to_sym)).to_set.freeze
|
|
97
168
|
end
|
|
98
169
|
|
|
99
|
-
#
|
|
170
|
+
# Generate base sorting scopes: sort_field_asc and sort_field_desc.
|
|
171
|
+
#
|
|
172
|
+
# @param field_name [Symbol] Field name
|
|
173
|
+
# @api private
|
|
100
174
|
def define_base_sorting(field_name)
|
|
101
175
|
quoted_field = connection.quote_column_name(field_name)
|
|
102
176
|
|
|
@@ -109,7 +183,10 @@ module BetterModel
|
|
|
109
183
|
)
|
|
110
184
|
end
|
|
111
185
|
|
|
112
|
-
#
|
|
186
|
+
# Generate sorting scopes for string fields (includes case-insensitive).
|
|
187
|
+
#
|
|
188
|
+
# @param field_name [Symbol] Field name
|
|
189
|
+
# @api private
|
|
113
190
|
def define_string_sorting(field_name)
|
|
114
191
|
quoted_field = connection.quote_column_name(field_name)
|
|
115
192
|
|
|
@@ -129,21 +206,24 @@ module BetterModel
|
|
|
129
206
|
)
|
|
130
207
|
end
|
|
131
208
|
|
|
132
|
-
#
|
|
209
|
+
# Generate sorting scopes for numeric fields (includes NULL handling).
|
|
210
|
+
#
|
|
211
|
+
# @param field_name [Symbol] Field name
|
|
212
|
+
# @api private
|
|
133
213
|
def define_numeric_sorting(field_name)
|
|
134
214
|
quoted_field = connection.quote_column_name(field_name)
|
|
135
215
|
|
|
136
|
-
#
|
|
216
|
+
# Base scopes
|
|
137
217
|
scope :"sort_#{field_name}_asc", -> { order(Arel.sql("#{quoted_field} ASC")) }
|
|
138
218
|
scope :"sort_#{field_name}_desc", -> { order(Arel.sql("#{quoted_field} DESC")) }
|
|
139
219
|
|
|
140
|
-
# Pre-
|
|
220
|
+
# Pre-calculate SQL for NULL handling (necessary because scope doesn't have access to private methods)
|
|
141
221
|
sql_asc_nulls_last = nulls_order_sql(field_name, "ASC", "LAST")
|
|
142
222
|
sql_desc_nulls_last = nulls_order_sql(field_name, "DESC", "LAST")
|
|
143
223
|
sql_asc_nulls_first = nulls_order_sql(field_name, "ASC", "FIRST")
|
|
144
224
|
sql_desc_nulls_first = nulls_order_sql(field_name, "DESC", "FIRST")
|
|
145
225
|
|
|
146
|
-
#
|
|
226
|
+
# Scopes with NULL handling
|
|
147
227
|
scope :"sort_#{field_name}_asc_nulls_last", -> {
|
|
148
228
|
order(Arel.sql(sql_asc_nulls_last))
|
|
149
229
|
}
|
|
@@ -167,15 +247,18 @@ module BetterModel
|
|
|
167
247
|
)
|
|
168
248
|
end
|
|
169
249
|
|
|
170
|
-
#
|
|
250
|
+
# Generate sorting scopes for date/datetime fields (includes semantic shortcuts).
|
|
251
|
+
#
|
|
252
|
+
# @param field_name [Symbol] Field name
|
|
253
|
+
# @api private
|
|
171
254
|
def define_date_sorting(field_name)
|
|
172
255
|
quoted_field = connection.quote_column_name(field_name)
|
|
173
256
|
|
|
174
|
-
#
|
|
257
|
+
# Base scopes
|
|
175
258
|
scope :"sort_#{field_name}_asc", -> { order(Arel.sql("#{quoted_field} ASC")) }
|
|
176
259
|
scope :"sort_#{field_name}_desc", -> { order(Arel.sql("#{quoted_field} DESC")) }
|
|
177
260
|
|
|
178
|
-
#
|
|
261
|
+
# Semantic shortcuts
|
|
179
262
|
scope :"sort_#{field_name}_newest", -> { order(Arel.sql("#{quoted_field} DESC")) }
|
|
180
263
|
scope :"sort_#{field_name}_oldest", -> { order(Arel.sql("#{quoted_field} ASC")) }
|
|
181
264
|
|
|
@@ -187,19 +270,24 @@ module BetterModel
|
|
|
187
270
|
)
|
|
188
271
|
end
|
|
189
272
|
|
|
190
|
-
#
|
|
273
|
+
# Generate SQL for NULL handling across different databases.
|
|
191
274
|
#
|
|
192
|
-
#
|
|
193
|
-
#
|
|
194
|
-
#
|
|
275
|
+
# @param field_name [Symbol] Field name
|
|
276
|
+
# @param direction [String] Sort direction ('ASC' or 'DESC')
|
|
277
|
+
# @param nulls_position [String] NULL position ('FIRST' or 'LAST')
|
|
278
|
+
# @return [String] SQL ORDER BY clause
|
|
279
|
+
# @api private
|
|
280
|
+
#
|
|
281
|
+
# @note The MySQL/MariaDB else block is not covered by automated tests
|
|
282
|
+
# because tests run on SQLite. Test manually on MySQL with: rails console RAILS_ENV=test
|
|
195
283
|
def nulls_order_sql(field_name, direction, nulls_position)
|
|
196
284
|
quoted_field = connection.quote_column_name(field_name)
|
|
197
285
|
|
|
198
|
-
# PostgreSQL
|
|
286
|
+
# PostgreSQL and SQLite 3.30+ support NULLS LAST/FIRST natively
|
|
199
287
|
if connection.adapter_name.match?(/PostgreSQL|SQLite/)
|
|
200
288
|
"#{quoted_field} #{direction} NULLS #{nulls_position}"
|
|
201
289
|
else
|
|
202
|
-
# MySQL/MariaDB:
|
|
290
|
+
# MySQL/MariaDB: emulate with CASE
|
|
203
291
|
if nulls_position == "LAST"
|
|
204
292
|
"CASE WHEN #{quoted_field} IS NULL THEN 1 ELSE 0 END, #{quoted_field} #{direction}"
|
|
205
293
|
else # FIRST
|
|
@@ -209,9 +297,17 @@ module BetterModel
|
|
|
209
297
|
end
|
|
210
298
|
end
|
|
211
299
|
|
|
212
|
-
#
|
|
213
|
-
|
|
214
|
-
#
|
|
300
|
+
# Instance Methods
|
|
301
|
+
|
|
302
|
+
# Returns list of sortable attributes (excludes sensitive fields).
|
|
303
|
+
#
|
|
304
|
+
# Automatically filters out password and encrypted fields for security.
|
|
305
|
+
#
|
|
306
|
+
# @return [Array<String>] Sortable attribute names
|
|
307
|
+
#
|
|
308
|
+
# @example
|
|
309
|
+
# article.sortable_attributes
|
|
310
|
+
# # => ["id", "title", "view_count", "published_at", "created_at", "updated_at"]
|
|
215
311
|
def sortable_attributes
|
|
216
312
|
self.class.column_names.reject do |attr|
|
|
217
313
|
attr.start_with?("password", "encrypted_")
|
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
module BetterModel
|
|
4
4
|
module Stateable
|
|
5
|
-
# Configurator
|
|
5
|
+
# Configurator for Stateable DSL.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
7
|
+
# This configurator enables defining state machines declaratively
|
|
8
|
+
# within the `stateable do...end` block.
|
|
9
9
|
#
|
|
10
|
-
#
|
|
10
|
+
# @example
|
|
11
11
|
# stateable do
|
|
12
|
-
# #
|
|
12
|
+
# # Define states
|
|
13
13
|
# state :pending, initial: true
|
|
14
14
|
# state :confirmed
|
|
15
15
|
# state :paid
|
|
16
16
|
#
|
|
17
|
-
# #
|
|
17
|
+
# # Define transitions
|
|
18
18
|
# transition :confirm, from: :pending, to: :confirmed do
|
|
19
19
|
# check { items.any? }
|
|
20
20
|
# check :customer_valid?
|
|
@@ -27,9 +27,13 @@ module BetterModel
|
|
|
27
27
|
# end
|
|
28
28
|
# end
|
|
29
29
|
#
|
|
30
|
+
# @api private
|
|
30
31
|
class Configurator
|
|
31
32
|
attr_reader :states, :transitions, :initial_state, :table_name
|
|
32
33
|
|
|
34
|
+
# Initialize a new Configurator.
|
|
35
|
+
#
|
|
36
|
+
# @param model_class [Class] Model class being configured
|
|
33
37
|
def initialize(model_class)
|
|
34
38
|
@model_class = model_class
|
|
35
39
|
@states = []
|
|
@@ -39,16 +43,19 @@ module BetterModel
|
|
|
39
43
|
@current_transition = nil
|
|
40
44
|
end
|
|
41
45
|
|
|
42
|
-
#
|
|
46
|
+
# Define a state.
|
|
43
47
|
#
|
|
44
|
-
# @param name [Symbol]
|
|
45
|
-
# @param initial [Boolean]
|
|
48
|
+
# @param name [Symbol] State name
|
|
49
|
+
# @param initial [Boolean] Whether this is the initial state
|
|
50
|
+
# @raise [ArgumentError] If state name is invalid or already defined
|
|
46
51
|
#
|
|
47
52
|
# @example
|
|
48
53
|
# state :draft, initial: true
|
|
49
54
|
# state :published
|
|
50
55
|
# state :archived
|
|
51
56
|
#
|
|
57
|
+
# @example With initial state
|
|
58
|
+
# state :pending, initial: true
|
|
52
59
|
def state(name, initial: false)
|
|
53
60
|
raise ArgumentError, "State name must be a symbol" unless name.is_a?(Symbol)
|
|
54
61
|
raise ArgumentError, "State #{name} already defined" if @states.include?(name)
|
|
@@ -61,17 +68,18 @@ module BetterModel
|
|
|
61
68
|
end
|
|
62
69
|
end
|
|
63
70
|
|
|
64
|
-
#
|
|
71
|
+
# Define a transition.
|
|
65
72
|
#
|
|
66
|
-
# @param event [Symbol]
|
|
67
|
-
# @param from [Symbol, Array<Symbol>]
|
|
68
|
-
# @param to [Symbol]
|
|
69
|
-
# @yield
|
|
73
|
+
# @param event [Symbol] Event/transition name
|
|
74
|
+
# @param from [Symbol, Array<Symbol>] Source state(s)
|
|
75
|
+
# @param to [Symbol] Destination state
|
|
76
|
+
# @yield Block to configure guards, validations, callbacks
|
|
77
|
+
# @raise [ArgumentError] If event is invalid, already defined, or states don't exist
|
|
70
78
|
#
|
|
71
|
-
# @example
|
|
79
|
+
# @example Simple transition
|
|
72
80
|
# transition :publish, from: :draft, to: :published
|
|
73
81
|
#
|
|
74
|
-
# @example
|
|
82
|
+
# @example With checks and callbacks
|
|
75
83
|
# transition :confirm, from: :pending, to: :confirmed do
|
|
76
84
|
# check { valid? }
|
|
77
85
|
# check :ready_to_confirm?
|
|
@@ -79,17 +87,17 @@ module BetterModel
|
|
|
79
87
|
# after_transition { send_email }
|
|
80
88
|
# end
|
|
81
89
|
#
|
|
82
|
-
# @example
|
|
90
|
+
# @example From multiple states
|
|
83
91
|
# transition :cancel, from: [:pending, :confirmed, :paid], to: :cancelled
|
|
84
92
|
#
|
|
85
93
|
def transition(event, from:, to:, &block)
|
|
86
94
|
raise ArgumentError, "Event name must be a symbol" unless event.is_a?(Symbol)
|
|
87
95
|
raise ArgumentError, "Transition #{event} already defined" if @transitions.key?(event)
|
|
88
96
|
|
|
89
|
-
#
|
|
97
|
+
# Normalize from to array
|
|
90
98
|
from_states = Array(from)
|
|
91
99
|
|
|
92
|
-
#
|
|
100
|
+
# Verify states exist
|
|
93
101
|
from_states.each do |state_name|
|
|
94
102
|
unless @states.include?(state_name)
|
|
95
103
|
raise ArgumentError, "Unknown state in from: #{state_name}. Define it with 'state :#{state_name}' first."
|
|
@@ -100,7 +108,7 @@ module BetterModel
|
|
|
100
108
|
raise ArgumentError, "Unknown state in to: #{to}. Define it with 'state :#{to}' first."
|
|
101
109
|
end
|
|
102
110
|
|
|
103
|
-
#
|
|
111
|
+
# Initialize transition configuration
|
|
104
112
|
@transitions[event] = {
|
|
105
113
|
from: from_states,
|
|
106
114
|
to: to,
|
|
@@ -111,7 +119,7 @@ module BetterModel
|
|
|
111
119
|
around_callbacks: []
|
|
112
120
|
}
|
|
113
121
|
|
|
114
|
-
#
|
|
122
|
+
# If block provided, configure it
|
|
115
123
|
if block_given?
|
|
116
124
|
@current_transition = @transitions[event]
|
|
117
125
|
instance_eval(&block)
|
|
@@ -119,28 +127,30 @@ module BetterModel
|
|
|
119
127
|
end
|
|
120
128
|
end
|
|
121
129
|
|
|
122
|
-
#
|
|
130
|
+
# Define a check for the current transition.
|
|
123
131
|
#
|
|
124
|
-
#
|
|
132
|
+
# Checks are preconditions that must be true to allow the transition.
|
|
125
133
|
#
|
|
126
134
|
# @overload check(&block)
|
|
127
|
-
# Check
|
|
128
|
-
# @yield
|
|
135
|
+
# Check with lambda/proc
|
|
136
|
+
# @yield Block to evaluate in instance context
|
|
129
137
|
# @example
|
|
130
138
|
# check { items.any? && customer.present? }
|
|
131
139
|
#
|
|
132
140
|
# @overload check(method_name)
|
|
133
|
-
# Check
|
|
134
|
-
# @param method_name [Symbol]
|
|
141
|
+
# Check with method
|
|
142
|
+
# @param method_name [Symbol] Method name to call
|
|
135
143
|
# @example
|
|
136
144
|
# check :customer_valid?
|
|
137
145
|
#
|
|
138
146
|
# @overload check(if: predicate)
|
|
139
|
-
# Check
|
|
140
|
-
# @param if [Symbol]
|
|
147
|
+
# Check with Statusable predicate
|
|
148
|
+
# @param if [Symbol] Predicate name (Statusable integration)
|
|
141
149
|
# @example
|
|
142
150
|
# check if: :is_ready_for_publishing?
|
|
143
151
|
#
|
|
152
|
+
# @raise [StateableError] If called outside transition block
|
|
153
|
+
# @raise [ArgumentError] If no check provided
|
|
144
154
|
def check(method_name = nil, if: nil, &block)
|
|
145
155
|
raise StateableError, "check can only be called inside a transition block" unless @current_transition
|
|
146
156
|
|
|
@@ -155,12 +165,14 @@ module BetterModel
|
|
|
155
165
|
end
|
|
156
166
|
end
|
|
157
167
|
|
|
158
|
-
#
|
|
168
|
+
# Define a validation for the current transition.
|
|
159
169
|
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
170
|
+
# Validations are executed after guards and before callbacks.
|
|
171
|
+
# They must add errors to the errors object if validation fails.
|
|
162
172
|
#
|
|
163
|
-
# @yield
|
|
173
|
+
# @yield Block to evaluate in instance context
|
|
174
|
+
# @raise [StateableError] If called outside transition block
|
|
175
|
+
# @raise [ArgumentError] If no block provided
|
|
164
176
|
#
|
|
165
177
|
# @example
|
|
166
178
|
# validate do
|
|
@@ -175,22 +187,24 @@ module BetterModel
|
|
|
175
187
|
@current_transition[:validations] << block
|
|
176
188
|
end
|
|
177
189
|
|
|
178
|
-
#
|
|
190
|
+
# Define a before_transition callback for the current transition.
|
|
179
191
|
#
|
|
180
|
-
#
|
|
192
|
+
# Before_transition callbacks are executed before the state transition.
|
|
181
193
|
#
|
|
182
194
|
# @overload before_transition(&block)
|
|
183
|
-
# Before_transition callback
|
|
184
|
-
# @yield
|
|
195
|
+
# Before_transition callback with lambda/proc
|
|
196
|
+
# @yield Block to execute
|
|
185
197
|
# @example
|
|
186
198
|
# before_transition { calculate_total }
|
|
187
199
|
#
|
|
188
200
|
# @overload before_transition(method_name)
|
|
189
|
-
# Before_transition callback
|
|
190
|
-
# @param method_name [Symbol]
|
|
201
|
+
# Before_transition callback with method
|
|
202
|
+
# @param method_name [Symbol] Method name to call
|
|
191
203
|
# @example
|
|
192
204
|
# before_transition :calculate_total
|
|
193
205
|
#
|
|
206
|
+
# @raise [StateableError] If called outside transition block
|
|
207
|
+
# @raise [ArgumentError] If no callback provided
|
|
194
208
|
def before_transition(method_name = nil, &block)
|
|
195
209
|
raise StateableError, "before_transition can only be called inside a transition block" unless @current_transition
|
|
196
210
|
|
|
@@ -203,22 +217,24 @@ module BetterModel
|
|
|
203
217
|
end
|
|
204
218
|
end
|
|
205
219
|
|
|
206
|
-
#
|
|
220
|
+
# Define an after_transition callback for the current transition.
|
|
207
221
|
#
|
|
208
|
-
#
|
|
222
|
+
# After_transition callbacks are executed after the state transition.
|
|
209
223
|
#
|
|
210
224
|
# @overload after_transition(&block)
|
|
211
|
-
# After_transition callback
|
|
212
|
-
# @yield
|
|
225
|
+
# After_transition callback with lambda/proc
|
|
226
|
+
# @yield Block to execute
|
|
213
227
|
# @example
|
|
214
228
|
# after_transition { send_notification }
|
|
215
229
|
#
|
|
216
230
|
# @overload after_transition(method_name)
|
|
217
|
-
# After_transition callback
|
|
218
|
-
# @param method_name [Symbol]
|
|
231
|
+
# After_transition callback with method
|
|
232
|
+
# @param method_name [Symbol] Method name to call
|
|
219
233
|
# @example
|
|
220
234
|
# after_transition :send_notification
|
|
221
235
|
#
|
|
236
|
+
# @raise [StateableError] If called outside transition block
|
|
237
|
+
# @raise [ArgumentError] If no callback provided
|
|
222
238
|
def after_transition(method_name = nil, &block)
|
|
223
239
|
raise StateableError, "after_transition can only be called inside a transition block" unless @current_transition
|
|
224
240
|
|
|
@@ -231,12 +247,14 @@ module BetterModel
|
|
|
231
247
|
end
|
|
232
248
|
end
|
|
233
249
|
|
|
234
|
-
#
|
|
250
|
+
# Define an around callback for the current transition.
|
|
235
251
|
#
|
|
236
|
-
#
|
|
237
|
-
#
|
|
252
|
+
# Around callbacks wrap the state transition.
|
|
253
|
+
# The block receives another block that must be called to execute the transition.
|
|
238
254
|
#
|
|
239
|
-
# @yield
|
|
255
|
+
# @yield Block to execute, receives a block to call
|
|
256
|
+
# @raise [StateableError] If called outside transition block
|
|
257
|
+
# @raise [ArgumentError] If no block provided
|
|
240
258
|
#
|
|
241
259
|
# @example
|
|
242
260
|
# around do |transition|
|
|
@@ -252,9 +270,9 @@ module BetterModel
|
|
|
252
270
|
@current_transition[:around_callbacks] << block
|
|
253
271
|
end
|
|
254
272
|
|
|
255
|
-
#
|
|
273
|
+
# Specify the table name for state transitions.
|
|
256
274
|
#
|
|
257
|
-
# @param name [String, Symbol]
|
|
275
|
+
# @param name [String, Symbol] Table name
|
|
258
276
|
#
|
|
259
277
|
# @example Default (state_transitions)
|
|
260
278
|
# stateable do
|
|
@@ -283,9 +301,9 @@ module BetterModel
|
|
|
283
301
|
@table_name = name.to_s
|
|
284
302
|
end
|
|
285
303
|
|
|
286
|
-
#
|
|
304
|
+
# Return complete configuration.
|
|
287
305
|
#
|
|
288
|
-
# @return [Hash]
|
|
306
|
+
# @return [Hash] Configuration with states and transitions
|
|
289
307
|
#
|
|
290
308
|
def to_h
|
|
291
309
|
{
|