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,11 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "errors/taggable/taggable_error"
|
|
4
|
+
require_relative "errors/taggable/configuration_error"
|
|
5
|
+
|
|
6
|
+
# Taggable - Declarative tag management system for Rails models.
|
|
4
7
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
8
|
+
# This concern enables managing multiple tags on models using PostgreSQL arrays
|
|
9
|
+
# with normalization, validation, and statistics. Search is delegated to Predicable.
|
|
7
10
|
#
|
|
8
|
-
#
|
|
11
|
+
# @example Basic Usage
|
|
9
12
|
# class Article < ApplicationRecord
|
|
10
13
|
# include BetterModel
|
|
11
14
|
#
|
|
@@ -16,18 +19,18 @@
|
|
|
16
19
|
# end
|
|
17
20
|
# end
|
|
18
21
|
#
|
|
19
|
-
#
|
|
20
|
-
# article.tag_with("ruby", "rails") #
|
|
21
|
-
# article.untag("rails") #
|
|
22
|
-
# article.tag_list = "ruby, rails, tutorial" #
|
|
22
|
+
# @example Managing Tags
|
|
23
|
+
# article.tag_with("ruby", "rails") # Add tags
|
|
24
|
+
# article.untag("rails") # Remove tags
|
|
25
|
+
# article.tag_list = "ruby, rails, tutorial" # From CSV string
|
|
23
26
|
# article.tagged_with?("ruby") # => true
|
|
24
27
|
#
|
|
25
|
-
#
|
|
28
|
+
# @example Searching (Delegated to Predicable)
|
|
26
29
|
# Article.tags_contains("ruby") # Predicable
|
|
27
30
|
# Article.tags_overlaps(["ruby", "python"]) # Predicable
|
|
28
31
|
# Article.search(tags_contains: "ruby") # Searchable + Predicable
|
|
29
32
|
#
|
|
30
|
-
#
|
|
33
|
+
# @example Statistics
|
|
31
34
|
# Article.tag_counts # => {"ruby" => 45, "rails" => 38}
|
|
32
35
|
# Article.popular_tags(limit: 10) # => [["ruby", 45], ["rails", 38], ...]
|
|
33
36
|
#
|
|
@@ -35,7 +38,11 @@ module BetterModel
|
|
|
35
38
|
module Taggable
|
|
36
39
|
extend ActiveSupport::Concern
|
|
37
40
|
|
|
38
|
-
#
|
|
41
|
+
# Taggable Configuration.
|
|
42
|
+
#
|
|
43
|
+
# Internal configuration class for the Taggable DSL.
|
|
44
|
+
#
|
|
45
|
+
# @api private
|
|
39
46
|
class Configuration
|
|
40
47
|
attr_reader :validates_minimum, :validates_maximum, :allowed_tags, :forbidden_tags
|
|
41
48
|
|
|
@@ -91,19 +98,22 @@ module BetterModel
|
|
|
91
98
|
end
|
|
92
99
|
|
|
93
100
|
included do
|
|
94
|
-
#
|
|
101
|
+
# Validate ActiveRecord inheritance
|
|
95
102
|
unless ancestors.include?(ActiveRecord::Base)
|
|
96
|
-
raise
|
|
103
|
+
raise BetterModel::Errors::Taggable::ConfigurationError, "Invalid configuration"
|
|
97
104
|
end
|
|
98
105
|
|
|
99
|
-
#
|
|
106
|
+
# Taggable configuration for this class
|
|
100
107
|
class_attribute :taggable_config, default: nil
|
|
101
108
|
end
|
|
102
109
|
|
|
103
110
|
class_methods do
|
|
104
|
-
# DSL
|
|
111
|
+
# DSL to configure Taggable.
|
|
105
112
|
#
|
|
106
|
-
#
|
|
113
|
+
# @yield [config] Configuration block
|
|
114
|
+
# @raise [BetterModel::Errors::Taggable::ConfigurationError] If already configured or field doesn't exist
|
|
115
|
+
#
|
|
116
|
+
# @example
|
|
107
117
|
# taggable do
|
|
108
118
|
# tag_field :tags
|
|
109
119
|
# normalize true
|
|
@@ -114,39 +124,41 @@ module BetterModel
|
|
|
114
124
|
# validates_tags minimum: 1, maximum: 10, allowed_tags: ["ruby", "rails"]
|
|
115
125
|
# end
|
|
116
126
|
def taggable(&block)
|
|
117
|
-
#
|
|
127
|
+
# Prevent multiple configuration
|
|
118
128
|
if taggable_config.present?
|
|
119
|
-
raise
|
|
129
|
+
raise BetterModel::Errors::Taggable::ConfigurationError, "Invalid configuration"
|
|
120
130
|
end
|
|
121
131
|
|
|
122
|
-
#
|
|
132
|
+
# Create configuration
|
|
123
133
|
config = Configuration.new
|
|
124
134
|
config.instance_eval(&block) if block_given?
|
|
125
135
|
|
|
126
|
-
#
|
|
136
|
+
# Validate that field exists
|
|
127
137
|
tag_field_name = config.tag_field.to_s
|
|
128
138
|
unless column_names.include?(tag_field_name)
|
|
129
|
-
raise
|
|
139
|
+
raise BetterModel::Errors::Taggable::ConfigurationError, "Invalid configuration"
|
|
130
140
|
end
|
|
131
141
|
|
|
132
|
-
#
|
|
142
|
+
# Save configuration (frozen for thread-safety)
|
|
133
143
|
self.taggable_config = config.freeze
|
|
134
144
|
|
|
135
|
-
# Auto-
|
|
145
|
+
# Auto-register predicates for search (delegated to Predicable)
|
|
136
146
|
predicates config.tag_field if respond_to?(:predicates)
|
|
137
147
|
|
|
138
|
-
#
|
|
148
|
+
# Register validations if configured
|
|
139
149
|
setup_validations(config) if config.validates_minimum || config.validates_maximum ||
|
|
140
150
|
config.allowed_tags || config.forbidden_tags
|
|
141
151
|
end
|
|
142
152
|
|
|
143
153
|
# ============================================================================
|
|
144
|
-
# CLASS METHODS -
|
|
154
|
+
# CLASS METHODS - Statistics
|
|
145
155
|
# ============================================================================
|
|
146
156
|
|
|
147
|
-
#
|
|
157
|
+
# Returns a hash with the count of each tag.
|
|
158
|
+
#
|
|
159
|
+
# @return [Hash{String => Integer}] Tag counts
|
|
148
160
|
#
|
|
149
|
-
#
|
|
161
|
+
# @example
|
|
150
162
|
# Article.tag_counts # => {"ruby" => 45, "rails" => 38, "tutorial" => 12}
|
|
151
163
|
def tag_counts
|
|
152
164
|
return {} unless taggable_config
|
|
@@ -163,9 +175,12 @@ module BetterModel
|
|
|
163
175
|
counts
|
|
164
176
|
end
|
|
165
177
|
|
|
166
|
-
#
|
|
178
|
+
# Returns the most popular tags with their counts.
|
|
167
179
|
#
|
|
168
|
-
#
|
|
180
|
+
# @param limit [Integer] Maximum number of tags to return
|
|
181
|
+
# @return [Array<Array(String, Integer)>] Tag-count pairs sorted by count
|
|
182
|
+
#
|
|
183
|
+
# @example
|
|
169
184
|
# Article.popular_tags(limit: 10)
|
|
170
185
|
# # => [["ruby", 45], ["rails", 38], ["tutorial", 12]]
|
|
171
186
|
def popular_tags(limit: 10)
|
|
@@ -176,9 +191,13 @@ module BetterModel
|
|
|
176
191
|
.first(limit)
|
|
177
192
|
end
|
|
178
193
|
|
|
179
|
-
#
|
|
194
|
+
# Returns tags that appear together with the specified tag.
|
|
195
|
+
#
|
|
196
|
+
# @param tag [String] Tag to find related tags for
|
|
197
|
+
# @param limit [Integer] Maximum number of related tags to return
|
|
198
|
+
# @return [Array<String>] Related tags sorted by frequency
|
|
180
199
|
#
|
|
181
|
-
#
|
|
200
|
+
# @example
|
|
182
201
|
# Article.related_tags("ruby", limit: 10)
|
|
183
202
|
# # => ["rails", "gem", "activerecord"]
|
|
184
203
|
def related_tags(tag, limit: 10)
|
|
@@ -187,25 +206,25 @@ module BetterModel
|
|
|
187
206
|
field = taggable_config.tag_field
|
|
188
207
|
related_counts = Hash.new(0)
|
|
189
208
|
|
|
190
|
-
#
|
|
209
|
+
# Normalize query tag
|
|
191
210
|
config = taggable_config
|
|
192
211
|
normalized_tag = tag.to_s
|
|
193
212
|
normalized_tag = normalized_tag.strip if config.strip
|
|
194
213
|
normalized_tag = normalized_tag.downcase if config.normalize
|
|
195
214
|
|
|
196
|
-
#
|
|
215
|
+
# Find records containing the tag
|
|
197
216
|
find_each do |record|
|
|
198
217
|
tags = record.public_send(field) || []
|
|
199
218
|
next unless tags.include?(normalized_tag)
|
|
200
219
|
|
|
201
|
-
#
|
|
220
|
+
# Count other tags that appear together
|
|
202
221
|
tags.each do |other_tag|
|
|
203
222
|
next if other_tag == normalized_tag
|
|
204
223
|
related_counts[other_tag] += 1
|
|
205
224
|
end
|
|
206
225
|
end
|
|
207
226
|
|
|
208
|
-
#
|
|
227
|
+
# Return sorted by frequency
|
|
209
228
|
related_counts
|
|
210
229
|
.sort_by { |_tag, count| -count }
|
|
211
230
|
.first(limit)
|
|
@@ -214,11 +233,14 @@ module BetterModel
|
|
|
214
233
|
|
|
215
234
|
private
|
|
216
235
|
|
|
217
|
-
# Setup
|
|
236
|
+
# Setup ActiveRecord validations.
|
|
237
|
+
#
|
|
238
|
+
# @param config [Configuration] Taggable configuration
|
|
239
|
+
# @api private
|
|
218
240
|
def setup_validations(config)
|
|
219
241
|
field = config.tag_field
|
|
220
242
|
|
|
221
|
-
#
|
|
243
|
+
# Minimum validation
|
|
222
244
|
if config.validates_minimum
|
|
223
245
|
min = config.validates_minimum
|
|
224
246
|
validate do
|
|
@@ -229,7 +251,7 @@ module BetterModel
|
|
|
229
251
|
end
|
|
230
252
|
end
|
|
231
253
|
|
|
232
|
-
#
|
|
254
|
+
# Maximum validation
|
|
233
255
|
if config.validates_maximum
|
|
234
256
|
max = config.validates_maximum
|
|
235
257
|
validate do
|
|
@@ -240,7 +262,7 @@ module BetterModel
|
|
|
240
262
|
end
|
|
241
263
|
end
|
|
242
264
|
|
|
243
|
-
#
|
|
265
|
+
# Whitelist validation
|
|
244
266
|
if config.allowed_tags
|
|
245
267
|
allowed = config.allowed_tags
|
|
246
268
|
validate do
|
|
@@ -252,7 +274,7 @@ module BetterModel
|
|
|
252
274
|
end
|
|
253
275
|
end
|
|
254
276
|
|
|
255
|
-
#
|
|
277
|
+
# Blacklist validation
|
|
256
278
|
if config.forbidden_tags
|
|
257
279
|
forbidden = config.forbidden_tags
|
|
258
280
|
validate do
|
|
@@ -267,12 +289,15 @@ module BetterModel
|
|
|
267
289
|
end
|
|
268
290
|
|
|
269
291
|
# ============================================================================
|
|
270
|
-
# INSTANCE METHODS -
|
|
292
|
+
# INSTANCE METHODS - Tag Management
|
|
271
293
|
# ============================================================================
|
|
272
294
|
|
|
273
|
-
#
|
|
295
|
+
# Add one or more tags to the record.
|
|
296
|
+
#
|
|
297
|
+
# @param new_tags [Array<String>] Tags to add
|
|
298
|
+
# @return [void]
|
|
274
299
|
#
|
|
275
|
-
#
|
|
300
|
+
# @example
|
|
276
301
|
# article.tag_with("ruby")
|
|
277
302
|
# article.tag_with("ruby", "rails", "tutorial")
|
|
278
303
|
def tag_with(*new_tags)
|
|
@@ -281,21 +306,24 @@ module BetterModel
|
|
|
281
306
|
config = self.class.taggable_config
|
|
282
307
|
field = config.tag_field
|
|
283
308
|
|
|
284
|
-
#
|
|
309
|
+
# Initialize array if nil
|
|
285
310
|
current_tags = public_send(field) || []
|
|
286
311
|
|
|
287
|
-
#
|
|
312
|
+
# Normalize and add tags (avoid duplicates with |)
|
|
288
313
|
normalized_tags = new_tags.flatten.map { |tag| normalize_tag(tag) }.compact
|
|
289
314
|
updated_tags = (current_tags | normalized_tags)
|
|
290
315
|
|
|
291
|
-
#
|
|
316
|
+
# Update field
|
|
292
317
|
public_send("#{field}=", updated_tags)
|
|
293
318
|
save if persisted?
|
|
294
319
|
end
|
|
295
320
|
|
|
296
|
-
#
|
|
321
|
+
# Remove one or more tags from the record.
|
|
297
322
|
#
|
|
298
|
-
#
|
|
323
|
+
# @param tags_to_remove [Array<String>] Tags to remove
|
|
324
|
+
# @return [void]
|
|
325
|
+
#
|
|
326
|
+
# @example
|
|
299
327
|
# article.untag("tutorial")
|
|
300
328
|
# article.untag("ruby", "rails")
|
|
301
329
|
def untag(*tags_to_remove)
|
|
@@ -304,23 +332,26 @@ module BetterModel
|
|
|
304
332
|
config = self.class.taggable_config
|
|
305
333
|
field = config.tag_field
|
|
306
334
|
|
|
307
|
-
#
|
|
335
|
+
# Get current tags
|
|
308
336
|
current_tags = public_send(field) || []
|
|
309
337
|
|
|
310
|
-
#
|
|
338
|
+
# Normalize tags to remove
|
|
311
339
|
normalized_tags = tags_to_remove.flatten.map { |tag| normalize_tag(tag) }.compact
|
|
312
340
|
|
|
313
|
-
#
|
|
341
|
+
# Remove tags
|
|
314
342
|
updated_tags = current_tags - normalized_tags
|
|
315
343
|
|
|
316
|
-
#
|
|
344
|
+
# Update field
|
|
317
345
|
public_send("#{field}=", updated_tags)
|
|
318
346
|
save if persisted?
|
|
319
347
|
end
|
|
320
348
|
|
|
321
|
-
#
|
|
349
|
+
# Replace all existing tags with new tags.
|
|
350
|
+
#
|
|
351
|
+
# @param new_tags [Array<String>] New tags to set
|
|
352
|
+
# @return [void]
|
|
322
353
|
#
|
|
323
|
-
#
|
|
354
|
+
# @example
|
|
324
355
|
# article.retag("python", "django")
|
|
325
356
|
def retag(*new_tags)
|
|
326
357
|
return unless taggable_enabled?
|
|
@@ -328,17 +359,20 @@ module BetterModel
|
|
|
328
359
|
config = self.class.taggable_config
|
|
329
360
|
field = config.tag_field
|
|
330
361
|
|
|
331
|
-
#
|
|
362
|
+
# Normalize new tags
|
|
332
363
|
normalized_tags = new_tags.flatten.map { |tag| normalize_tag(tag) }.compact.uniq
|
|
333
364
|
|
|
334
|
-
#
|
|
365
|
+
# Replace all tags
|
|
335
366
|
public_send("#{field}=", normalized_tags)
|
|
336
367
|
save if persisted?
|
|
337
368
|
end
|
|
338
369
|
|
|
339
|
-
#
|
|
370
|
+
# Check if record has a specific tag.
|
|
340
371
|
#
|
|
341
|
-
#
|
|
372
|
+
# @param tag [String] Tag to check
|
|
373
|
+
# @return [Boolean] true if record has the tag
|
|
374
|
+
#
|
|
375
|
+
# @example
|
|
342
376
|
# article.tagged_with?("ruby") # => true/false
|
|
343
377
|
def tagged_with?(tag)
|
|
344
378
|
return false unless taggable_enabled?
|
|
@@ -356,9 +390,11 @@ module BetterModel
|
|
|
356
390
|
# TAG LIST (CSV Interface)
|
|
357
391
|
# ============================================================================
|
|
358
392
|
|
|
359
|
-
#
|
|
393
|
+
# Returns tags as a delimited string.
|
|
394
|
+
#
|
|
395
|
+
# @return [String] Tags joined by delimiter
|
|
360
396
|
#
|
|
361
|
-
#
|
|
397
|
+
# @example
|
|
362
398
|
# article.tag_list # => "ruby, rails, tutorial"
|
|
363
399
|
def tag_list
|
|
364
400
|
return "" unless taggable_enabled?
|
|
@@ -369,14 +405,17 @@ module BetterModel
|
|
|
369
405
|
|
|
370
406
|
current_tags = public_send(field) || []
|
|
371
407
|
|
|
372
|
-
#
|
|
408
|
+
# Add space after comma for readability (only if delimiter is comma)
|
|
373
409
|
separator = delimiter == "," ? "#{delimiter} " : delimiter
|
|
374
410
|
current_tags.join(separator)
|
|
375
411
|
end
|
|
376
412
|
|
|
377
|
-
#
|
|
413
|
+
# Set tags from a delimited string.
|
|
378
414
|
#
|
|
379
|
-
#
|
|
415
|
+
# @param tag_string [String] Delimited tag string
|
|
416
|
+
# @return [void]
|
|
417
|
+
#
|
|
418
|
+
# @example
|
|
380
419
|
# article.tag_list = "ruby, rails, tutorial"
|
|
381
420
|
def tag_list=(tag_string)
|
|
382
421
|
return unless taggable_enabled?
|
|
@@ -401,25 +440,26 @@ module BetterModel
|
|
|
401
440
|
# JSON SERIALIZATION
|
|
402
441
|
# ============================================================================
|
|
403
442
|
|
|
404
|
-
# Override as_json
|
|
443
|
+
# Override as_json to include tag information.
|
|
405
444
|
#
|
|
406
|
-
#
|
|
407
|
-
#
|
|
408
|
-
#
|
|
445
|
+
# @param options [Hash] Options for as_json
|
|
446
|
+
# @option options [Boolean] :include_tag_list Include tag_list as string
|
|
447
|
+
# @option options [Boolean] :include_tag_stats Include tag statistics
|
|
448
|
+
# @return [Hash] JSON representation
|
|
409
449
|
#
|
|
410
|
-
#
|
|
450
|
+
# @example
|
|
411
451
|
# article.as_json(include_tag_list: true, include_tag_stats: true)
|
|
412
452
|
def as_json(options = {})
|
|
413
453
|
json = super(options)
|
|
414
454
|
|
|
415
455
|
return json unless taggable_enabled?
|
|
416
456
|
|
|
417
|
-
#
|
|
457
|
+
# Add tag_list if requested
|
|
418
458
|
if options[:include_tag_list]
|
|
419
459
|
json["tag_list"] = tag_list
|
|
420
460
|
end
|
|
421
461
|
|
|
422
|
-
#
|
|
462
|
+
# Add tag statistics if requested
|
|
423
463
|
if options[:include_tag_stats]
|
|
424
464
|
config = self.class.taggable_config
|
|
425
465
|
field = config.tag_field
|
|
@@ -436,12 +476,17 @@ module BetterModel
|
|
|
436
476
|
|
|
437
477
|
private
|
|
438
478
|
|
|
439
|
-
#
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
479
|
+
# Check if Taggable is enabled for this class.
|
|
480
|
+
#
|
|
481
|
+
# @return [Boolean] true if enabled
|
|
482
|
+
# @api private
|
|
483
|
+
def taggable_enabled? = self.class.taggable_config.present?
|
|
443
484
|
|
|
444
|
-
#
|
|
485
|
+
# Normalize a tag according to configuration.
|
|
486
|
+
#
|
|
487
|
+
# @param tag [String] Tag to normalize
|
|
488
|
+
# @return [String, nil] Normalized tag or nil if invalid
|
|
489
|
+
# @api private
|
|
445
490
|
def normalize_tag(tag)
|
|
446
491
|
return nil if tag.blank?
|
|
447
492
|
|
|
@@ -1,53 +1,57 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "errors/traceable/traceable_error"
|
|
4
|
+
require_relative "errors/traceable/not_enabled_error"
|
|
5
|
+
require_relative "errors/traceable/configuration_error"
|
|
6
|
+
|
|
7
|
+
# Traceable - Change tracking with audit trail for Rails models.
|
|
4
8
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
9
|
+
# This concern enables automatic tracking of record changes,
|
|
10
|
+
# maintaining a complete history with timestamps, author, and reasoning.
|
|
7
11
|
#
|
|
8
|
-
#
|
|
9
|
-
# # Opzione 1: Generator automatico (raccomandato)
|
|
12
|
+
# @example Quick Setup - Option 1: Automatic Generator (Recommended)
|
|
10
13
|
# rails g better_model:traceable Article --with-reason
|
|
11
14
|
# rails db:migrate
|
|
12
15
|
#
|
|
13
|
-
#
|
|
16
|
+
# @example Quick Setup - Option 2: Using Included Migration
|
|
17
|
+
# # The better_model_versions migration is already in the gem
|
|
14
18
|
# rails db:migrate
|
|
15
19
|
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
20
|
+
# @note OPT-IN APPROACH
|
|
21
|
+
# Tracking is not enabled automatically. You must explicitly call
|
|
22
|
+
# `traceable do...end` in your model to activate it.
|
|
18
23
|
#
|
|
19
|
-
#
|
|
20
|
-
# - better_model_versions table (
|
|
24
|
+
# @note DATABASE REQUIREMENTS
|
|
25
|
+
# - better_model_versions table (included in gem)
|
|
21
26
|
#
|
|
22
|
-
#
|
|
27
|
+
# @example Basic Model Setup
|
|
23
28
|
# class Article < ApplicationRecord
|
|
24
29
|
# include BetterModel
|
|
25
30
|
#
|
|
26
|
-
# #
|
|
31
|
+
# # Enable traceable (opt-in)
|
|
27
32
|
# traceable do
|
|
28
|
-
# track :status, :title, :published_at #
|
|
33
|
+
# track :status, :title, :published_at # Fields to track
|
|
29
34
|
# end
|
|
30
35
|
# end
|
|
31
36
|
#
|
|
32
|
-
#
|
|
33
|
-
# # Tracking automatico
|
|
37
|
+
# @example Automatic Tracking
|
|
34
38
|
# article.update!(status: "published", updated_by_id: user.id, updated_reason: "Approved")
|
|
35
39
|
#
|
|
36
|
-
#
|
|
37
|
-
# article.versions #
|
|
38
|
-
# article.changes_for(:status) #
|
|
39
|
-
# article.audit_trail #
|
|
40
|
+
# @example Querying Versions
|
|
41
|
+
# article.versions # All versions
|
|
42
|
+
# article.changes_for(:status) # Changes for a field
|
|
43
|
+
# article.audit_trail # Formatted history
|
|
40
44
|
#
|
|
41
|
-
#
|
|
42
|
-
# article.as_of(3.days.ago) #
|
|
45
|
+
# @example Time-Travel
|
|
46
|
+
# article.as_of(3.days.ago) # State at specific date
|
|
43
47
|
#
|
|
44
|
-
#
|
|
45
|
-
# article.rollback_to(version) #
|
|
48
|
+
# @example Rollback
|
|
49
|
+
# article.rollback_to(version) # Restore to previous version
|
|
46
50
|
#
|
|
47
|
-
#
|
|
48
|
-
# Article.changed_by(user.id) #
|
|
49
|
-
# Article.changed_between(start, end) #
|
|
50
|
-
# Article.status_changed_from("draft").to("published") #
|
|
51
|
+
# @example Query Scopes for Changes
|
|
52
|
+
# Article.changed_by(user.id) # Changes by user
|
|
53
|
+
# Article.changed_between(start, end) # Changes in period
|
|
54
|
+
# Article.status_changed_from("draft").to("published") # Specific transitions
|
|
51
55
|
#
|
|
52
56
|
module BetterModel
|
|
53
57
|
module Traceable
|
|
@@ -59,7 +63,7 @@ module BetterModel
|
|
|
59
63
|
included do
|
|
60
64
|
# Validazione ActiveRecord
|
|
61
65
|
unless ancestors.include?(ActiveRecord::Base)
|
|
62
|
-
raise
|
|
66
|
+
raise BetterModel::Errors::Traceable::ConfigurationError, "Invalid configuration"
|
|
63
67
|
end
|
|
64
68
|
|
|
65
69
|
# Configurazione traceable (opt-in)
|
|
@@ -122,16 +126,16 @@ module BetterModel
|
|
|
122
126
|
# Verifica se traceable è attivo
|
|
123
127
|
#
|
|
124
128
|
# @return [Boolean]
|
|
125
|
-
def traceable_enabled?
|
|
126
|
-
traceable_enabled == true
|
|
127
|
-
end
|
|
129
|
+
def traceable_enabled? = traceable_enabled == true
|
|
128
130
|
|
|
129
131
|
# Find records changed by a specific user
|
|
130
132
|
#
|
|
131
133
|
# @param user_id [Integer] User ID
|
|
132
134
|
# @return [ActiveRecord::Relation]
|
|
133
135
|
def changed_by(user_id)
|
|
134
|
-
|
|
136
|
+
unless traceable_enabled?
|
|
137
|
+
raise BetterModel::Errors::Traceable::NotEnabledError, "Module is not enabled"
|
|
138
|
+
end
|
|
135
139
|
|
|
136
140
|
joins(:versions).where(traceable_table_name => { updated_by_id: user_id }).distinct
|
|
137
141
|
end
|
|
@@ -142,7 +146,9 @@ module BetterModel
|
|
|
142
146
|
# @param end_time [Time, Date] End time
|
|
143
147
|
# @return [ActiveRecord::Relation]
|
|
144
148
|
def changed_between(start_time, end_time)
|
|
145
|
-
|
|
149
|
+
unless traceable_enabled?
|
|
150
|
+
raise BetterModel::Errors::Traceable::NotEnabledError, "Module is not enabled"
|
|
151
|
+
end
|
|
146
152
|
|
|
147
153
|
joins(:versions).where(traceable_table_name => { created_at: start_time..end_time }).distinct
|
|
148
154
|
end
|
|
@@ -152,7 +158,9 @@ module BetterModel
|
|
|
152
158
|
# @param field [Symbol] Field name
|
|
153
159
|
# @return [ChangeQuery]
|
|
154
160
|
def field_changed(field)
|
|
155
|
-
|
|
161
|
+
unless traceable_enabled?
|
|
162
|
+
raise BetterModel::Errors::Traceable::NotEnabledError, "Module is not enabled"
|
|
163
|
+
end
|
|
156
164
|
|
|
157
165
|
ChangeQuery.new(self, field)
|
|
158
166
|
end
|
|
@@ -202,7 +210,7 @@ module BetterModel
|
|
|
202
210
|
end
|
|
203
211
|
|
|
204
212
|
# Create new Version class dynamically
|
|
205
|
-
version_class = Class.new(BetterModel::Version) do
|
|
213
|
+
version_class = Class.new(BetterModel::Models::Version) do
|
|
206
214
|
self.table_name = table_name
|
|
207
215
|
end
|
|
208
216
|
|
|
@@ -220,7 +228,9 @@ module BetterModel
|
|
|
220
228
|
# @param field [Symbol] Field name
|
|
221
229
|
# @return [Array<Hash>] Array of changes with :before, :after, :at, :by
|
|
222
230
|
def changes_for(field)
|
|
223
|
-
|
|
231
|
+
unless self.class.traceable_enabled?
|
|
232
|
+
raise BetterModel::Errors::Traceable::NotEnabledError, "Module is not enabled"
|
|
233
|
+
end
|
|
224
234
|
|
|
225
235
|
versions.select { |v| v.changed?(field) }.map do |version|
|
|
226
236
|
change = version.change_for(field)
|
|
@@ -238,7 +248,9 @@ module BetterModel
|
|
|
238
248
|
#
|
|
239
249
|
# @return [Array<Hash>] Full audit trail
|
|
240
250
|
def audit_trail
|
|
241
|
-
|
|
251
|
+
unless self.class.traceable_enabled?
|
|
252
|
+
raise BetterModel::Errors::Traceable::NotEnabledError, "Module is not enabled"
|
|
253
|
+
end
|
|
242
254
|
|
|
243
255
|
versions.map do |version|
|
|
244
256
|
{
|
|
@@ -256,7 +268,9 @@ module BetterModel
|
|
|
256
268
|
# @param timestamp [Time, Date] Point in time
|
|
257
269
|
# @return [self] Reconstructed object (not saved)
|
|
258
270
|
def as_of(timestamp)
|
|
259
|
-
|
|
271
|
+
unless self.class.traceable_enabled?
|
|
272
|
+
raise BetterModel::Errors::Traceable::NotEnabledError, "Module is not enabled"
|
|
273
|
+
end
|
|
260
274
|
|
|
261
275
|
# Get all versions up to timestamp, ordered from oldest to newest
|
|
262
276
|
relevant_versions = versions.where("created_at <= ?", timestamp).order(created_at: :asc)
|
|
@@ -280,12 +294,14 @@ module BetterModel
|
|
|
280
294
|
|
|
281
295
|
# Rollback to a specific version
|
|
282
296
|
#
|
|
283
|
-
# @param version [BetterModel::Version, Integer] Version or version ID
|
|
297
|
+
# @param version [BetterModel::Models::Version, Integer] Version or version ID
|
|
284
298
|
# @param updated_by_id [Integer] User ID performing rollback
|
|
285
299
|
# @param updated_reason [String] Reason for rollback
|
|
286
300
|
# @return [self]
|
|
287
301
|
def rollback_to(version, updated_by_id: nil, updated_reason: nil, allow_sensitive: false)
|
|
288
|
-
|
|
302
|
+
unless self.class.traceable_enabled?
|
|
303
|
+
raise BetterModel::Errors::Traceable::NotEnabledError, "Module is not enabled"
|
|
304
|
+
end
|
|
289
305
|
|
|
290
306
|
version = versions.find(version) if version.is_a?(Integer)
|
|
291
307
|
|
|
@@ -478,14 +494,6 @@ module BetterModel
|
|
|
478
494
|
end
|
|
479
495
|
end
|
|
480
496
|
|
|
481
|
-
# Errori custom
|
|
482
|
-
class TraceableError < StandardError; end
|
|
483
|
-
|
|
484
|
-
class NotEnabledError < TraceableError
|
|
485
|
-
def initialize(msg = nil)
|
|
486
|
-
super(msg || "Traceable is not enabled. Add 'traceable do...end' to your model.")
|
|
487
|
-
end
|
|
488
|
-
end
|
|
489
497
|
|
|
490
498
|
# Configurator per traceable DSL
|
|
491
499
|
class TraceableConfigurator
|