better_model 1.2.0 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec236d26e4c1e0a5a62e9932606a1baa28de1bcadf8b447f1da545fb35d029b6
4
- data.tar.gz: 24e4aa004924ab09c6104cad770229041b8c8d24556ce5f2f2d190bf15071e90
3
+ metadata.gz: 93cc27ca4bb13c22c5d3ac1f0aa96010f7377bf687805b5f76bee89d44b83f5f
4
+ data.tar.gz: 5234fde47b3b13fc0ccc2da3fc3948f094ccd0f7eaa2cb4d8d219e6cf291a1c3
5
5
  SHA512:
6
- metadata.gz: 77d291e54bcbbb179eabd842290530d0f42e03636981e93bfdeb6575d5d0e02960d359072c0b0db0144f35cdbea647d463bf7320037c557552f6fce8365a393e
7
- data.tar.gz: bfe3bf61206cb343dce4d84b2ff643e719c09b93a623de77e127a00aadbce646d25bfcc2cd66ca3bd725b6af224a82eb7cff2e059f79f45c8e026d5d878c95d6
6
+ metadata.gz: 3f9394cdc2201586b8b1d510d60aa286d29a86ab1a0c313f61401b850545a1924b08b16489b446d786aac38aa919ff7546dd6e3d3d5c9a9ca8a310b74ecbddc0
7
+ data.tar.gz: 8f218e1eb37bc94cb8510cda0ae32033458b1befe2ff5012a766b7c9874865b63b90facb72d7ba97deb1b80175fef06dbf27f6e42b2b9d44d3f47e212908cc7e
data/README.md CHANGED
@@ -104,6 +104,17 @@ class Article < ApplicationRecord
104
104
  default_order [:sort_published_at_desc]
105
105
  security :status_required, [:status_eq]
106
106
  end
107
+
108
+ # 10. TAGGABLE - Tag management with statistics (opt-in)
109
+ taggable do
110
+ tag_field :tags
111
+ normalize true # Automatic lowercase
112
+ strip true # Remove whitespace
113
+ min_length 2 # Minimum tag length
114
+ max_length 30 # Maximum tag length
115
+ delimiter "," # CSV delimiter
116
+ validates_tags minimum: 1, maximum: 10
117
+ end
107
118
  end
108
119
  ```
109
120
 
@@ -172,6 +183,22 @@ Article.search(
172
183
  orders: [:sort_published_at_desc],
173
184
  pagination: { page: 1, per_page: 25 }
174
185
  )
186
+
187
+ # 🏷️ Manage tags
188
+ article.tag_with("ruby", "rails", "tutorial")
189
+ article.untag("tutorial")
190
+ article.tagged_with?("ruby") # => true
191
+ article.tag_list = "ruby, rails, api"
192
+
193
+ # 📊 Query with tags (via Predicable)
194
+ Article.tags_contains("ruby")
195
+ Article.tags_overlaps(["ruby", "python"])
196
+ Article.tags_contains_all(["ruby", "rails"])
197
+
198
+ # 📈 Tag statistics
199
+ Article.tag_counts # => {"ruby" => 45, "rails" => 38}
200
+ Article.popular_tags(limit: 10) # => [["ruby", 45], ["rails", 38]]
201
+ Article.related_tags("ruby", limit: 5) # => ["rails", "gem", "tutorial"]
175
202
  ```
176
203
 
177
204
  ### 🎯 Including Individual Concerns (Advanced)
@@ -189,6 +216,7 @@ class Article < ApplicationRecord
189
216
  include BetterModel::Validatable # Only validations
190
217
  include BetterModel::Stateable # Only state machine
191
218
  include BetterModel::Searchable # Only search (requires Predicable & Sortable)
219
+ include BetterModel::Taggable # Only tag management
192
220
 
193
221
  # Define your features...
194
222
  end
@@ -196,19 +224,20 @@ end
196
224
 
197
225
  ## 📋 Features Overview
198
226
 
199
- BetterModel provides nine powerful concerns that work seamlessly together:
227
+ BetterModel provides ten powerful concerns that work seamlessly together:
200
228
 
201
229
  ### Core Features
202
230
 
203
231
  - **✨ Statusable** - Declarative status management with lambda-based conditions
204
232
  - **🔐 Permissible** - State-based permission system
205
233
  - **🗄️ Archivable** - Soft delete with tracking (by user, reason)
206
- - **⏰ Traceable** 🆕 - Complete audit trail with time-travel and rollback
234
+ - **⏰ Traceable** - Complete audit trail with time-travel and rollback
207
235
  - **⬆️ Sortable** - Type-aware sorting scopes
208
236
  - **🔍 Predicable** - Advanced filtering with rich predicate system
209
237
  - **🔎 Searchable** - Unified search interface (Predicable + Sortable)
210
238
  - **✅ Validatable** - Declarative validation DSL with conditional rules
211
- - **🔄 Stateable** 🆕 - Declarative state machines with guards & callbacks
239
+ - **🔄 Stateable** - Declarative state machines with guards & callbacks
240
+ - **🏷️ Taggable** 🆕 - Tag management with normalization, validation, and statistics
212
241
 
213
242
  [See all features in detail →](#-features)
214
243
 
@@ -391,6 +420,24 @@ Orchestrate Predicable and Sortable into a powerful, secure search interface wit
391
420
 
392
421
  ---
393
422
 
423
+ ### 🏷️ Taggable - Tag Management with Statistics
424
+
425
+ Manage tags with automatic normalization, validation, and comprehensive statistics - integrated with Predicable for powerful searches.
426
+
427
+ **🎯 Key Benefits:**
428
+ - 🎛️ Opt-in activation: only enabled when explicitly configured
429
+ - 🤖 Automatic normalization (lowercase, strip, length limits)
430
+ - ✅ Validation (min/max count, whitelist, blacklist)
431
+ - 📊 Statistics (tag counts, popularity, co-occurrence)
432
+ - 🔍 Automatic Predicable integration for searches
433
+ - 📝 CSV import/export with tag_list
434
+ - 🐘 PostgreSQL arrays or serialized JSON for SQLite
435
+ - 🎯 Thread-safe configuration
436
+
437
+ **[📖 Full Documentation →](docs/taggable.md)** | **[📚 Examples →](docs/examples/11_taggable.md)**
438
+
439
+ ---
440
+
394
441
  ### 📜 Traceable - Audit Trail & Change Tracking
395
442
 
396
443
  Track all changes to your records with comprehensive audit trail functionality, time-travel queries, and rollback capabilities.
@@ -532,9 +579,7 @@ article.update!(title: "Corrected Title")
532
579
 
533
580
  ---
534
581
 
535
- ## 📌 Version & Changelog
536
-
537
- **Current Version:** 1.0.0
582
+ ## 📌 Changelog
538
583
 
539
584
  See [CHANGELOG.md](CHANGELOG.md) for version history and release notes.
540
585
 
@@ -623,12 +668,13 @@ bundle exec rubocop
623
668
 
624
669
  ### 📊 Test Coverage Notes
625
670
 
626
- The test suite runs on **SQLite** for performance and portability. Current coverage: **91.45%** (1272 / 1391 lines).
671
+ The test suite runs on **SQLite** for performance and portability. Current coverage: **92.57%** (1507 / 1628 lines).
627
672
 
628
673
  **Database-Specific Features Not Covered:**
629
- - **Predicable**: PostgreSQL array predicates (`_overlaps`, `_contains`, `_contained_by`) and JSONB predicates (`_has_key`, `_has_any_key`, `_has_all_keys`, `_jsonb_contains`) - lines 278-376 in `lib/better_model/predicable.rb`
630
- - **Traceable**: PostgreSQL JSONB queries and MySQL JSON_EXTRACT queries for field-specific change tracking - lines 454-489 in `lib/better_model/traceable.rb`
631
- - **Sortable**: MySQL NULLS emulation with CASE statements - lines 198-203 in `lib/better_model/sortable.rb`
674
+ - **Predicable**: PostgreSQL array predicates (`_overlaps`, `_contains`, `_contained_by`) and JSONB predicates (`_has_key`, `_has_any_key`, `_has_all_keys`, `_jsonb_contains`)
675
+ - **Traceable**: PostgreSQL JSONB queries and MySQL JSON_EXTRACT queries for field-specific change tracking
676
+ - **Sortable**: MySQL NULLS emulation with CASE statements
677
+ - **Taggable**: PostgreSQL native array operations (covered by Predicable tests)
632
678
 
633
679
  These features are fully implemented with proper SQL sanitization but require manual testing on PostgreSQL/MySQL:
634
680
 
@@ -0,0 +1,466 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Taggable - Sistema di gestione tag dichiarativo per modelli Rails
4
+ #
5
+ # Questo concern permette di gestire tag multipli sui modelli utilizzando array PostgreSQL
6
+ # con normalizzazione, validazione e statistiche. La ricerca è delegata a Predicable.
7
+ #
8
+ # Esempio di utilizzo:
9
+ # class Article < ApplicationRecord
10
+ # include BetterModel
11
+ #
12
+ # taggable do
13
+ # tag_field :tags
14
+ # normalize true
15
+ # validates_tags minimum: 1, maximum: 10
16
+ # end
17
+ # end
18
+ #
19
+ # Utilizzo:
20
+ # article.tag_with("ruby", "rails") # Aggiungi tag
21
+ # article.untag("rails") # Rimuovi tag
22
+ # article.tag_list = "ruby, rails, tutorial" # Da stringa CSV
23
+ # article.tagged_with?("ruby") # => true
24
+ #
25
+ # Ricerca (delegata a Predicable):
26
+ # Article.tags_contains("ruby") # Predicable
27
+ # Article.tags_overlaps(["ruby", "python"]) # Predicable
28
+ # Article.search(tags_contains: "ruby") # Searchable + Predicable
29
+ #
30
+ # Statistiche:
31
+ # Article.tag_counts # => {"ruby" => 45, "rails" => 38}
32
+ # Article.popular_tags(limit: 10) # => [["ruby", 45], ["rails", 38], ...]
33
+ #
34
+ module BetterModel
35
+ module Taggable
36
+ extend ActiveSupport::Concern
37
+
38
+ # Configurazione Taggable
39
+ class Configuration
40
+ attr_reader :validates_minimum, :validates_maximum, :allowed_tags, :forbidden_tags
41
+
42
+ def initialize
43
+ @tag_field = :tags
44
+ @normalize = false
45
+ @strip = true
46
+ @min_length = nil
47
+ @max_length = nil
48
+ @delimiter = ","
49
+ @validates_minimum = nil
50
+ @validates_maximum = nil
51
+ @allowed_tags = nil
52
+ @forbidden_tags = nil
53
+ end
54
+
55
+ def tag_field(field_name = nil)
56
+ return @tag_field if field_name.nil?
57
+ @tag_field = field_name.to_sym
58
+ end
59
+
60
+ def normalize(value = nil)
61
+ return @normalize if value.nil?
62
+ @normalize = value
63
+ end
64
+
65
+ def strip(value = nil)
66
+ return @strip if value.nil?
67
+ @strip = value
68
+ end
69
+
70
+ def min_length(value = nil)
71
+ return @min_length if value.nil?
72
+ @min_length = value
73
+ end
74
+
75
+ def max_length(value = nil)
76
+ return @max_length if value.nil?
77
+ @max_length = value
78
+ end
79
+
80
+ def delimiter(value = nil)
81
+ return @delimiter if value.nil?
82
+ @delimiter = value
83
+ end
84
+
85
+ def validates_tags(options = {})
86
+ @validates_minimum = options[:minimum]
87
+ @validates_maximum = options[:maximum]
88
+ @allowed_tags = Array(options[:allowed_tags]) if options[:allowed_tags]
89
+ @forbidden_tags = Array(options[:forbidden_tags]) if options[:forbidden_tags]
90
+ end
91
+ end
92
+
93
+ included do
94
+ # Valida che sia incluso solo in modelli ActiveRecord
95
+ unless ancestors.include?(ActiveRecord::Base)
96
+ raise ArgumentError, "BetterModel::Taggable can only be included in ActiveRecord models"
97
+ end
98
+
99
+ # Configurazione Taggable per questa classe
100
+ class_attribute :taggable_config, default: nil
101
+ end
102
+
103
+ class_methods do
104
+ # DSL per configurare Taggable
105
+ #
106
+ # Esempio:
107
+ # taggable do
108
+ # tag_field :tags
109
+ # normalize true
110
+ # strip true
111
+ # min_length 2
112
+ # max_length 50
113
+ # delimiter ','
114
+ # validates_tags minimum: 1, maximum: 10, allowed_tags: ["ruby", "rails"]
115
+ # end
116
+ def taggable(&block)
117
+ # Previeni configurazione multipla
118
+ if taggable_config.present?
119
+ raise ArgumentError, "Taggable already configured for #{name}"
120
+ end
121
+
122
+ # Crea configurazione
123
+ config = Configuration.new
124
+ config.instance_eval(&block) if block_given?
125
+
126
+ # Valida che il campo esista
127
+ tag_field_name = config.tag_field.to_s
128
+ unless column_names.include?(tag_field_name)
129
+ raise ArgumentError, "Tag field #{config.tag_field} does not exist in #{table_name}"
130
+ end
131
+
132
+ # Salva configurazione (frozen per thread-safety)
133
+ self.taggable_config = config.freeze
134
+
135
+ # Auto-registra predicates per ricerca (delegato a Predicable)
136
+ predicates config.tag_field if respond_to?(:predicates)
137
+
138
+ # Registra validazioni se configurate
139
+ setup_validations(config) if config.validates_minimum || config.validates_maximum ||
140
+ config.allowed_tags || config.forbidden_tags
141
+ end
142
+
143
+ # ============================================================================
144
+ # CLASS METHODS - Statistiche
145
+ # ============================================================================
146
+
147
+ # Restituisce un hash con il conteggio di ciascun tag
148
+ #
149
+ # Esempio:
150
+ # Article.tag_counts # => {"ruby" => 45, "rails" => 38, "tutorial" => 12}
151
+ def tag_counts
152
+ return {} unless taggable_config
153
+
154
+ field = taggable_config.tag_field
155
+ counts = Hash.new(0)
156
+
157
+ # Itera tutti i record e conta i tag
158
+ find_each do |record|
159
+ tags = record.public_send(field) || []
160
+ tags.each { |tag| counts[tag] += 1 }
161
+ end
162
+
163
+ counts
164
+ end
165
+
166
+ # Restituisce i tag più popolari con il loro conteggio
167
+ #
168
+ # Esempio:
169
+ # Article.popular_tags(limit: 10)
170
+ # # => [["ruby", 45], ["rails", 38], ["tutorial", 12]]
171
+ def popular_tags(limit: 10)
172
+ return [] unless taggable_config
173
+
174
+ tag_counts
175
+ .sort_by { |_tag, count| -count }
176
+ .first(limit)
177
+ end
178
+
179
+ # Restituisce i tag che appaiono insieme al tag specificato
180
+ #
181
+ # Esempio:
182
+ # Article.related_tags("ruby", limit: 10)
183
+ # # => ["rails", "gem", "activerecord"]
184
+ def related_tags(tag, limit: 10)
185
+ return [] unless taggable_config
186
+
187
+ field = taggable_config.tag_field
188
+ related_counts = Hash.new(0)
189
+
190
+ # Normalizza il tag query
191
+ config = taggable_config
192
+ normalized_tag = tag.to_s
193
+ normalized_tag = normalized_tag.strip if config.strip
194
+ normalized_tag = normalized_tag.downcase if config.normalize
195
+
196
+ # Trova record che contengono il tag
197
+ find_each do |record|
198
+ tags = record.public_send(field) || []
199
+ next unless tags.include?(normalized_tag)
200
+
201
+ # Conta gli altri tag che appaiono insieme
202
+ tags.each do |other_tag|
203
+ next if other_tag == normalized_tag
204
+ related_counts[other_tag] += 1
205
+ end
206
+ end
207
+
208
+ # Restituisci ordinati per frequenza
209
+ related_counts
210
+ .sort_by { |_tag, count| -count }
211
+ .first(limit)
212
+ .map(&:first)
213
+ end
214
+
215
+ private
216
+
217
+ # Setup delle validazioni ActiveRecord
218
+ def setup_validations(config)
219
+ field = config.tag_field
220
+
221
+ # Validazione minimum
222
+ if config.validates_minimum
223
+ min = config.validates_minimum
224
+ validate do
225
+ tags = public_send(field) || []
226
+ if tags.size < min
227
+ errors.add(field, "must have at least #{min} tags")
228
+ end
229
+ end
230
+ end
231
+
232
+ # Validazione maximum
233
+ if config.validates_maximum
234
+ max = config.validates_maximum
235
+ validate do
236
+ tags = public_send(field) || []
237
+ if tags.size > max
238
+ errors.add(field, "must have at most #{max} tags")
239
+ end
240
+ end
241
+ end
242
+
243
+ # Validazione whitelist
244
+ if config.allowed_tags
245
+ allowed = config.allowed_tags
246
+ validate do
247
+ tags = public_send(field) || []
248
+ invalid_tags = tags - allowed
249
+ if invalid_tags.any?
250
+ errors.add(field, "contains invalid tags: #{invalid_tags.join(', ')}")
251
+ end
252
+ end
253
+ end
254
+
255
+ # Validazione blacklist
256
+ if config.forbidden_tags
257
+ forbidden = config.forbidden_tags
258
+ validate do
259
+ tags = public_send(field) || []
260
+ forbidden_found = tags & forbidden
261
+ if forbidden_found.any?
262
+ errors.add(field, "contains forbidden tags: #{forbidden_found.join(', ')}")
263
+ end
264
+ end
265
+ end
266
+ end
267
+ end
268
+
269
+ # ============================================================================
270
+ # INSTANCE METHODS - Gestione Tag
271
+ # ============================================================================
272
+
273
+ # Aggiunge uno o più tag al record
274
+ #
275
+ # Esempio:
276
+ # article.tag_with("ruby")
277
+ # article.tag_with("ruby", "rails", "tutorial")
278
+ def tag_with(*new_tags)
279
+ return unless taggable_enabled?
280
+
281
+ config = self.class.taggable_config
282
+ field = config.tag_field
283
+
284
+ # Inizializza array se nil
285
+ current_tags = public_send(field) || []
286
+
287
+ # Normalizza e aggiungi tag (evita duplicati con |)
288
+ normalized_tags = new_tags.flatten.map { |tag| normalize_tag(tag) }.compact
289
+ updated_tags = (current_tags | normalized_tags)
290
+
291
+ # Aggiorna il campo
292
+ public_send("#{field}=", updated_tags)
293
+ save if persisted?
294
+ end
295
+
296
+ # Rimuove uno o più tag dal record
297
+ #
298
+ # Esempio:
299
+ # article.untag("tutorial")
300
+ # article.untag("ruby", "rails")
301
+ def untag(*tags_to_remove)
302
+ return unless taggable_enabled?
303
+
304
+ config = self.class.taggable_config
305
+ field = config.tag_field
306
+
307
+ # Ottieni tag attuali
308
+ current_tags = public_send(field) || []
309
+
310
+ # Normalizza tag da rimuovere
311
+ normalized_tags = tags_to_remove.flatten.map { |tag| normalize_tag(tag) }.compact
312
+
313
+ # Rimuovi tag
314
+ updated_tags = current_tags - normalized_tags
315
+
316
+ # Aggiorna il campo
317
+ public_send("#{field}=", updated_tags)
318
+ save if persisted?
319
+ end
320
+
321
+ # Sostituisce tutti i tag esistenti con nuovi tag
322
+ #
323
+ # Esempio:
324
+ # article.retag("python", "django")
325
+ def retag(*new_tags)
326
+ return unless taggable_enabled?
327
+
328
+ config = self.class.taggable_config
329
+ field = config.tag_field
330
+
331
+ # Normalizza nuovi tag
332
+ normalized_tags = new_tags.flatten.map { |tag| normalize_tag(tag) }.compact.uniq
333
+
334
+ # Sostituisci tutti i tag
335
+ public_send("#{field}=", normalized_tags)
336
+ save if persisted?
337
+ end
338
+
339
+ # Verifica se il record ha un determinato tag
340
+ #
341
+ # Esempio:
342
+ # article.tagged_with?("ruby") # => true/false
343
+ def tagged_with?(tag)
344
+ return false unless taggable_enabled?
345
+
346
+ config = self.class.taggable_config
347
+ field = config.tag_field
348
+
349
+ current_tags = public_send(field) || []
350
+ normalized_tag = normalize_tag(tag)
351
+
352
+ current_tags.include?(normalized_tag)
353
+ end
354
+
355
+ # ============================================================================
356
+ # TAG LIST (CSV Interface)
357
+ # ============================================================================
358
+
359
+ # Restituisce i tag come stringa separata da delimitatore
360
+ #
361
+ # Esempio:
362
+ # article.tag_list # => "ruby, rails, tutorial"
363
+ def tag_list
364
+ return "" unless taggable_enabled?
365
+
366
+ config = self.class.taggable_config
367
+ field = config.tag_field
368
+ delimiter = config.delimiter
369
+
370
+ current_tags = public_send(field) || []
371
+
372
+ # Aggiungi spazio dopo virgola per leggibilità (solo se delimiter è virgola)
373
+ separator = delimiter == "," ? "#{delimiter} " : delimiter
374
+ current_tags.join(separator)
375
+ end
376
+
377
+ # Imposta i tag da una stringa separata da delimitatore
378
+ #
379
+ # Esempio:
380
+ # article.tag_list = "ruby, rails, tutorial"
381
+ def tag_list=(tag_string)
382
+ return unless taggable_enabled?
383
+
384
+ config = self.class.taggable_config
385
+ field = config.tag_field
386
+ delimiter = config.delimiter
387
+
388
+ # Parse string
389
+ if tag_string.blank?
390
+ tags = []
391
+ else
392
+ tags = tag_string.split(delimiter).map { |tag| normalize_tag(tag) }.compact.uniq
393
+ end
394
+
395
+ # Imposta tags
396
+ public_send("#{field}=", tags)
397
+ save if persisted?
398
+ end
399
+
400
+ # ============================================================================
401
+ # JSON SERIALIZATION
402
+ # ============================================================================
403
+
404
+ # Override as_json per includere informazioni tag
405
+ #
406
+ # Opzioni:
407
+ # include_tag_list: true # Includi tag_list come string
408
+ # include_tag_stats: true # Includi statistiche tag
409
+ #
410
+ # Esempio:
411
+ # article.as_json(include_tag_list: true, include_tag_stats: true)
412
+ def as_json(options = {})
413
+ json = super(options)
414
+
415
+ return json unless taggable_enabled?
416
+
417
+ # Aggiungi tag_list se richiesto
418
+ if options[:include_tag_list]
419
+ json["tag_list"] = tag_list
420
+ end
421
+
422
+ # Aggiungi statistiche tag se richiesto
423
+ if options[:include_tag_stats]
424
+ config = self.class.taggable_config
425
+ field = config.tag_field
426
+ tags = public_send(field) || []
427
+
428
+ json["tag_stats"] = {
429
+ "count" => tags.size,
430
+ "tags" => tags
431
+ }
432
+ end
433
+
434
+ json
435
+ end
436
+
437
+ private
438
+
439
+ # Verifica se Taggable è abilitato per questa classe
440
+ def taggable_enabled?
441
+ self.class.taggable_config.present?
442
+ end
443
+
444
+ # Normalizza un tag secondo la configurazione
445
+ def normalize_tag(tag)
446
+ return nil if tag.blank?
447
+
448
+ config = self.class.taggable_config
449
+ normalized = tag.to_s
450
+
451
+ # Strip whitespace
452
+ normalized = normalized.strip if config.strip
453
+
454
+ # Lowercase
455
+ normalized = normalized.downcase if config.normalize
456
+
457
+ # Min length
458
+ return nil if config.min_length && normalized.length < config.min_length
459
+
460
+ # Max length
461
+ normalized = normalized[0...config.max_length] if config.max_length && normalized.length > config.max_length
462
+
463
+ normalized
464
+ end
465
+ end
466
+ end
@@ -66,6 +66,7 @@ module BetterModel
66
66
  class_attribute :traceable_enabled, default: false
67
67
  class_attribute :traceable_config, default: {}.freeze
68
68
  class_attribute :traceable_fields, default: [].freeze
69
+ class_attribute :traceable_sensitive_fields, default: {}.freeze
69
70
  class_attribute :traceable_table_name, default: nil
70
71
  class_attribute :_traceable_setup_done, default: false
71
72
  end
@@ -88,6 +89,7 @@ module BetterModel
88
89
  configurator.instance_eval(&block)
89
90
  self.traceable_config = configurator.to_h.freeze
90
91
  self.traceable_fields = configurator.fields.freeze
92
+ self.traceable_sensitive_fields = configurator.sensitive_fields.freeze
91
93
  self.traceable_table_name = configurator.table_name
92
94
  end
93
95
 
@@ -282,7 +284,7 @@ module BetterModel
282
284
  # @param updated_by_id [Integer] User ID performing rollback
283
285
  # @param updated_reason [String] Reason for rollback
284
286
  # @return [self]
285
- def rollback_to(version, updated_by_id: nil, updated_reason: nil)
287
+ def rollback_to(version, updated_by_id: nil, updated_reason: nil, allow_sensitive: false)
286
288
  raise NotEnabledError unless self.class.traceable_enabled?
287
289
 
288
290
  version = versions.find(version) if version.is_a?(Integer)
@@ -292,8 +294,23 @@ module BetterModel
292
294
 
293
295
  # Apply changes from version
294
296
  if version.object_changes
295
- version.object_changes.each do |field, (before_value, _after_value)|
296
- send("#{field}=", before_value) if respond_to?("#{field}=")
297
+ version.object_changes.each do |field, (before_value, after_value)|
298
+ field_sym = field.to_sym
299
+
300
+ # Check if field is sensitive
301
+ if self.class.traceable_sensitive_fields.key?(field_sym)
302
+ unless allow_sensitive
303
+ Rails.logger.warn "[BetterModel::Traceable] Skipping sensitive field '#{field}' in rollback. Use allow_sensitive: true to rollback sensitive fields."
304
+ next
305
+ end
306
+
307
+ Rails.logger.warn "[BetterModel::Traceable] Rolling back sensitive field '#{field}' - allowed by allow_sensitive flag"
308
+ end
309
+
310
+ # For 'created' events, use after_value (the value at creation)
311
+ # For 'updated' events, use before_value (the value before the update)
312
+ rollback_value = version.event == "created" ? after_value : before_value
313
+ send("#{field}=", rollback_value) if respond_to?("#{field}=")
297
314
  end
298
315
  end
299
316
 
@@ -350,20 +367,104 @@ module BetterModel
350
367
  def tracked_changes
351
368
  return {} if self.class.traceable_fields.empty?
352
369
 
353
- if saved_changes.any?
370
+ raw_changes = if saved_changes.any?
354
371
  # After save: use saved_changes
355
372
  saved_changes.slice(*self.class.traceable_fields.map(&:to_s))
356
373
  else
357
374
  # Before save: use changes
358
375
  changes.slice(*self.class.traceable_fields.map(&:to_s))
359
376
  end
377
+
378
+ # Apply redaction to sensitive fields
379
+ apply_redaction_to_changes(raw_changes)
360
380
  end
361
381
 
362
382
  # Get final state for destroyed records
363
383
  def tracked_final_state
364
- self.class.traceable_fields.each_with_object({}) do |field, hash|
384
+ raw_state = self.class.traceable_fields.each_with_object({}) do |field, hash|
365
385
  hash[field.to_s] = [ send(field), nil ]
366
386
  end
387
+
388
+ # Apply redaction to sensitive fields
389
+ apply_redaction_to_changes(raw_state)
390
+ end
391
+
392
+ # Apply redaction to changes hash based on sensitive field configuration
393
+ #
394
+ # @param changes_hash [Hash] Hash of field changes {field => [old, new]}
395
+ # @return [Hash] Redacted changes hash
396
+ def apply_redaction_to_changes(changes_hash)
397
+ return changes_hash if self.class.traceable_sensitive_fields.empty?
398
+
399
+ changes_hash.each_with_object({}) do |(field, values), result|
400
+ field_sym = field.to_sym
401
+
402
+ if self.class.traceable_sensitive_fields.key?(field_sym)
403
+ level = self.class.traceable_sensitive_fields[field_sym]
404
+ result[field] = [
405
+ redact_value(field_sym, values[0], level),
406
+ redact_value(field_sym, values[1], level)
407
+ ]
408
+ else
409
+ result[field] = values
410
+ end
411
+ end
412
+ end
413
+
414
+ # Redact a single value based on sensitivity level
415
+ #
416
+ # @param field [Symbol] Field name
417
+ # @param value [Object] Value to redact
418
+ # @param level [Symbol] Sensitivity level (:full, :partial, :hash)
419
+ # @return [String] Redacted value
420
+ def redact_value(field, value, level)
421
+ return "[REDACTED]" if value.nil? && level == :full
422
+
423
+ case level
424
+ when :full
425
+ "[REDACTED]"
426
+ when :partial
427
+ redact_partial(field, value)
428
+ when :hash
429
+ require "digest"
430
+ "sha256:#{Digest::SHA256.hexdigest(value.to_s)}"
431
+ else
432
+ value # Fallback to original value
433
+ end
434
+ end
435
+
436
+ # Partially redact a value based on field patterns
437
+ #
438
+ # @param field [Symbol] Field name
439
+ # @param value [Object] Value to partially redact
440
+ # @return [String] Partially redacted value
441
+ def redact_partial(field, value)
442
+ return "[REDACTED]" if value.blank?
443
+
444
+ str = value.to_s
445
+
446
+ # Credit card pattern (13-19 digits)
447
+ if str.gsub(/\D/, "").match?(/^\d{13,19}$/)
448
+ digits = str.gsub(/\D/, "")
449
+ "****#{digits[-4..-1]}"
450
+ # Email pattern
451
+ elsif str.include?("@")
452
+ parts = str.split("@")
453
+ username_length = parts.first.length
454
+ masked_username = username_length <= 3 ? "***" : "#{parts.first[0]}***"
455
+ "#{masked_username}@#{parts.last}"
456
+ # SSN pattern (US: XXX-XX-XXXX or XXXXXXXXX)
457
+ elsif str.gsub(/\D/, "").match?(/^\d{9}$/)
458
+ digits = str.gsub(/\D/, "")
459
+ "***-**-#{digits[-4..-1]}"
460
+ # Phone pattern (10+ digits)
461
+ elsif str.gsub(/\D/, "").match?(/^\d{10,}$/)
462
+ digits = str.gsub(/\D/, "")
463
+ "***-***-#{digits[-4..-1]}"
464
+ # Default: show only length
465
+ else
466
+ "[REDACTED:#{str.length}chars]"
467
+ end
367
468
  end
368
469
 
369
470
  # Create a version record
@@ -388,19 +489,32 @@ module BetterModel
388
489
 
389
490
  # Configurator per traceable DSL
390
491
  class TraceableConfigurator
391
- attr_reader :fields, :table_name
492
+ attr_reader :fields, :table_name, :sensitive_fields
392
493
 
393
494
  def initialize(model_class)
394
495
  @model_class = model_class
395
496
  @fields = []
497
+ @sensitive_fields = {}
396
498
  @table_name = nil
397
499
  end
398
500
 
399
501
  # Specify which fields to track
400
502
  #
401
503
  # @param field_names [Array<Symbol>] Field names to track
402
- def track(*field_names)
403
- @fields.concat(field_names)
504
+ # @param sensitive [Symbol, nil] Sensitivity level (:full, :partial, :hash)
505
+ #
506
+ # @example Normal tracking
507
+ # track :title, :status
508
+ #
509
+ # @example Sensitive tracking
510
+ # track :password, sensitive: :full
511
+ # track :email, sensitive: :partial
512
+ # track :ssn, sensitive: :hash
513
+ def track(*field_names, sensitive: nil)
514
+ field_names.each do |field|
515
+ @fields << field
516
+ @sensitive_fields[field] = sensitive if sensitive
517
+ end
404
518
  end
405
519
 
406
520
  # Specify custom table name for versions
@@ -411,7 +525,7 @@ module BetterModel
411
525
  end
412
526
 
413
527
  def to_h
414
- { fields: @fields, table_name: @table_name }
528
+ { fields: @fields, sensitive_fields: @sensitive_fields, table_name: @table_name }
415
529
  end
416
530
  end
417
531
 
@@ -1,3 +1,3 @@
1
1
  module BetterModel
2
- VERSION = "1.2.0"
2
+ VERSION = "1.3.0"
3
3
  end
@@ -45,11 +45,14 @@ module BetterModel
45
45
  }
46
46
  end
47
47
 
48
- # Check if a specific field changed
48
+ # Check if a specific field changed in this version
49
+ # This method overrides ActiveRecord's changed? to accept a field_name parameter
49
50
  #
50
- # @param field_name [Symbol, String] Field name
51
+ # @param field_name [Symbol, String, nil] Field name (if nil, calls ActiveRecord's changed?)
51
52
  # @return [Boolean]
52
- def changed?(field_name)
53
+ def changed?(field_name = nil)
54
+ return super() if field_name.nil?
55
+
53
56
  object_changes&.key?(field_name.to_s) || false
54
57
  end
55
58
 
data/lib/better_model.rb CHANGED
@@ -18,6 +18,7 @@ require "better_model/stateable/configurator"
18
18
  require "better_model/stateable/errors"
19
19
  require "better_model/stateable/guard"
20
20
  require "better_model/stateable/transition"
21
+ require "better_model/taggable"
21
22
 
22
23
  module BetterModel
23
24
  extend ActiveSupport::Concern
@@ -33,5 +34,6 @@ module BetterModel
33
34
  include BetterModel::Traceable
34
35
  include BetterModel::Validatable
35
36
  include BetterModel::Stateable
37
+ include BetterModel::Taggable
36
38
  end
37
39
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: better_model
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - alessiobussolari
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-10-30 00:00:00.000000000 Z
11
+ date: 2025-10-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -55,6 +55,7 @@ files:
55
55
  - lib/better_model/stateable/guard.rb
56
56
  - lib/better_model/stateable/transition.rb
57
57
  - lib/better_model/statusable.rb
58
+ - lib/better_model/taggable.rb
58
59
  - lib/better_model/traceable.rb
59
60
  - lib/better_model/validatable.rb
60
61
  - lib/better_model/validatable/business_rule_validator.rb