better_model 1.1.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: 75b360674e8e3baf46d99e536d2e012f0bbeea56169f2133d65193f9e093973b
4
- data.tar.gz: e4feb8e169012f8b94fe9f9ad720838d68d24df3e811c4de2fb1f018e4edef9a
3
+ metadata.gz: 93cc27ca4bb13c22c5d3ac1f0aa96010f7377bf687805b5f76bee89d44b83f5f
4
+ data.tar.gz: 5234fde47b3b13fc0ccc2da3fc3948f094ccd0f7eaa2cb4d8d219e6cf291a1c3
5
5
  SHA512:
6
- metadata.gz: bc082d6e0b93b1750f73ab4878f26f7f99aa2080aeb05c8382f5e4b47ee6287402b984686cebe665a7ffa1b154683aa00ddb8b7a78e841d41f3446a27d104957
7
- data.tar.gz: 5fd80c99b6e83ad69870226c06c949ba11b8e15e44883ba5b33c7e2ca8e79e0f0c79f8465ad6c176ba6726f8b3947819b39feaa94a316935fc2045f41b78a13c
6
+ metadata.gz: 3f9394cdc2201586b8b1d510d60aa286d29a86ab1a0c313f61401b850545a1924b08b16489b446d786aac38aa919ff7546dd6e3d3d5c9a9ca8a310b74ecbddc0
7
+ data.tar.gz: 8f218e1eb37bc94cb8510cda0ae32033458b1befe2ff5012a766b7c9874865b63b90facb72d7ba97deb1b80175fef06dbf27f6e42b2b9d44d3f47e212908cc7e
data/README.md CHANGED
@@ -91,13 +91,30 @@ class Article < ApplicationRecord
91
91
  transition :archive, from: [:draft, :published], to: :archived
92
92
  end
93
93
 
94
- # 8. SEARCHABLE - Configure unified search interface
94
+ # 8. TRACEABLE - Audit trail with time-travel (opt-in)
95
+ traceable do
96
+ track :title, :content, :status, :published_at
97
+ versions_table :article_versions # Optional: custom table
98
+ end
99
+
100
+ # 9. SEARCHABLE - Configure unified search interface
95
101
  searchable do
96
102
  per_page 25
97
103
  max_per_page 100
98
104
  default_order [:sort_published_at_desc]
99
105
  security :status_required, [:status_eq]
100
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
101
118
  end
102
119
  ```
103
120
 
@@ -149,12 +166,39 @@ article.published? # => true
149
166
  article.state_transitions # History of all transitions
150
167
  article.transition_history # Formatted history array
151
168
 
169
+ # ⏰ Time travel & rollback (Traceable)
170
+ article.audit_trail # Full change history
171
+ article.as_of(3.days.ago) # Reconstruct past state
172
+ article.rollback_to(version) # Restore to previous version
173
+ article.changes_for(:status) # Changes for specific field
174
+
175
+ # πŸ” Query changes
176
+ Article.changed_by(user.id)
177
+ Article.changed_between(1.week.ago, Time.current)
178
+ Article.status_changed_from("draft").to("published")
179
+
152
180
  # πŸ”Ž Unified search with filters, sorting, and pagination
153
181
  Article.search(
154
182
  { status_eq: "published", view_count_gteq: 50 },
155
183
  orders: [:sort_published_at_desc],
156
184
  pagination: { page: 1, per_page: 25 }
157
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"]
158
202
  ```
159
203
 
160
204
  ### 🎯 Including Individual Concerns (Advanced)
@@ -166,16 +210,37 @@ class Article < ApplicationRecord
166
210
  include BetterModel::Statusable # Only status management
167
211
  include BetterModel::Permissible # Only permissions
168
212
  include BetterModel::Archivable # Only archiving
213
+ include BetterModel::Traceable # Only audit trail & time-travel
169
214
  include BetterModel::Sortable # Only sorting
170
215
  include BetterModel::Predicable # Only filtering
171
216
  include BetterModel::Validatable # Only validations
172
217
  include BetterModel::Stateable # Only state machine
173
218
  include BetterModel::Searchable # Only search (requires Predicable & Sortable)
219
+ include BetterModel::Taggable # Only tag management
174
220
 
175
221
  # Define your features...
176
222
  end
177
223
  ```
178
224
 
225
+ ## πŸ“‹ Features Overview
226
+
227
+ BetterModel provides ten powerful concerns that work seamlessly together:
228
+
229
+ ### Core Features
230
+
231
+ - **✨ Statusable** - Declarative status management with lambda-based conditions
232
+ - **πŸ” Permissible** - State-based permission system
233
+ - **πŸ—„οΈ Archivable** - Soft delete with tracking (by user, reason)
234
+ - **⏰ Traceable** - Complete audit trail with time-travel and rollback
235
+ - **⬆️ Sortable** - Type-aware sorting scopes
236
+ - **πŸ” Predicable** - Advanced filtering with rich predicate system
237
+ - **πŸ”Ž Searchable** - Unified search interface (Predicable + Sortable)
238
+ - **βœ… Validatable** - Declarative validation DSL with conditional rules
239
+ - **πŸ”„ Stateable** - Declarative state machines with guards & callbacks
240
+ - **🏷️ Taggable** πŸ†• - Tag management with normalization, validation, and statistics
241
+
242
+ [See all features in detail β†’](#-features)
243
+
179
244
  ## βš™οΈ Requirements
180
245
 
181
246
  - **Ruby:** 3.0 or higher
@@ -286,6 +351,27 @@ Define state machines declaratively with transitions, guards, validations, and c
286
351
 
287
352
  ---
288
353
 
354
+ ### ⏰ Traceable - Audit Trail with Time-Travel
355
+
356
+ Track all changes to your records with complete audit trail, time-travel capabilities, and rollback support.
357
+
358
+ **🎯 Key Benefits:**
359
+ - πŸŽ›οΈ Opt-in activation: only enabled when explicitly configured
360
+ - πŸ“ Automatic change tracking on create, update, and destroy
361
+ - πŸ‘€ User attribution: track who made each change
362
+ - πŸ’¬ Change reasons: optional context for changes
363
+ - ⏰ Time-travel: reconstruct object state at any point in history
364
+ - ↩️ Rollback support: restore records to previous versions
365
+ - πŸ” Rich query API: find changes by user, time, or field transitions
366
+ - πŸ“Š Flexible table naming: per-model, shared, or custom tables
367
+ - πŸ”— Polymorphic association for efficient storage
368
+ - πŸ’Ύ Database adapter safety: PostgreSQL, MySQL, SQLite support
369
+ - πŸ”’ Thread-safe dynamic class creation
370
+
371
+ **[πŸ“– Full Documentation β†’](docs/traceable.md)**
372
+
373
+ ---
374
+
289
375
  ### ⬆️ Sortable - Type-Aware Sorting Scopes
290
376
 
291
377
  Generate intelligent sorting scopes automatically with database-specific optimizations and NULL handling.
@@ -334,6 +420,24 @@ Orchestrate Predicable and Sortable into a powerful, secure search interface wit
334
420
 
335
421
  ---
336
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
+
337
441
  ### πŸ“œ Traceable - Audit Trail & Change Tracking
338
442
 
339
443
  Track all changes to your records with comprehensive audit trail functionality, time-travel queries, and rollback capabilities.
@@ -475,9 +579,7 @@ article.update!(title: "Corrected Title")
475
579
 
476
580
  ---
477
581
 
478
- ## πŸ“Œ Version & Changelog
479
-
480
- **Current Version:** 1.0.0
582
+ ## πŸ“Œ Changelog
481
583
 
482
584
  See [CHANGELOG.md](CHANGELOG.md) for version history and release notes.
483
585
 
@@ -487,6 +589,38 @@ See [CHANGELOG.md](CHANGELOG.md) for version history and release notes.
487
589
  - πŸ’» **Source Code:** [GitHub Repository](https://github.com/alessiobussolari/better_model)
488
590
  - πŸ“– **Documentation:** This README and detailed docs in `docs/` directory
489
591
 
592
+ ## πŸ“š Complete Documentation
593
+
594
+ ### πŸ“– Feature Guides
595
+
596
+ Detailed documentation for each BetterModel concern:
597
+
598
+ - [**Statusable**](docs/statusable.md) - Status management with derived conditions
599
+ - [**Permissible**](docs/permissible.md) - Permission system based on state
600
+ - [**Archivable**](docs/archivable.md) - Soft delete with comprehensive tracking
601
+ - [**Traceable**](docs/traceable.md) πŸ†• - Audit trail, time-travel, and rollback
602
+ - [**Sortable**](docs/sortable.md) - Type-aware sorting system
603
+ - [**Predicable**](docs/predicable.md) - Advanced filtering and predicates
604
+ - [**Searchable**](docs/searchable.md) - Unified search interface
605
+ - [**Validatable**](docs/validatable.md) - Declarative validation system
606
+ - [**Stateable**](docs/stateable.md) πŸ†• - State machine with transitions
607
+
608
+ ### πŸŽ“ Advanced Guides
609
+
610
+ Learn how to master BetterModel in production:
611
+
612
+ - [**Integration Guide**](docs/integration_guide.md) πŸ†• - Combining multiple concerns effectively
613
+ - [**Performance Guide**](docs/performance_guide.md) πŸ†• - Optimization strategies and indexing
614
+ - [**Migration Guide**](docs/migration_guide.md) πŸ†• - Adding BetterModel to existing apps
615
+
616
+ ### πŸ’‘ Quick Links
617
+
618
+ - [Installation](#-installation)
619
+ - [Quick Start](#-quick-start)
620
+ - [Features Overview](#-features-overview)
621
+ - [Requirements](#%EF%B8%8F-requirements)
622
+ - [Contributing](#-contributing)
623
+
490
624
  ## 🀝 Contributing
491
625
 
492
626
  We welcome contributions! Here's how you can help:
@@ -532,6 +666,28 @@ bundle exec rake test # Coverage report in coverage/index.html
532
666
  bundle exec rubocop
533
667
  ```
534
668
 
669
+ ### πŸ“Š Test Coverage Notes
670
+
671
+ The test suite runs on **SQLite** for performance and portability. Current coverage: **92.57%** (1507 / 1628 lines).
672
+
673
+ **Database-Specific Features Not Covered:**
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)
678
+
679
+ These features are fully implemented with proper SQL sanitization but require manual testing on PostgreSQL/MySQL:
680
+
681
+ ```bash
682
+ # Test on PostgreSQL
683
+ RAILS_ENV=test DATABASE_URL=postgresql://user:pass@localhost/better_model_test rails console
684
+
685
+ # Test on MySQL
686
+ RAILS_ENV=test DATABASE_URL=mysql2://user:pass@localhost/better_model_test rails console
687
+ ```
688
+
689
+ All code has inline comments marking database-specific sections for maintainability.
690
+
535
691
  ### πŸ“ Code Guidelines
536
692
 
537
693
  - ✨ Follow the existing code style (enforced by RuboCop Omakase)
@@ -59,7 +59,7 @@ module BetterModel
59
59
  next unless column
60
60
 
61
61
  # Base predicates available for all column types
62
- define_base_predicates(field_name)
62
+ define_base_predicates(field_name, column.type)
63
63
 
64
64
  case column.type
65
65
  when :string, :text
@@ -142,7 +142,7 @@ module BetterModel
142
142
  end
143
143
 
144
144
  # Genera predicati base: _eq, _not_eq, _present
145
- def define_base_predicates(field_name)
145
+ def define_base_predicates(field_name, column_type = nil)
146
146
  table = arel_table
147
147
  field = table[field_name]
148
148
 
@@ -150,24 +150,26 @@ module BetterModel
150
150
  scope :"#{field_name}_eq", ->(value) { where(field.eq(value)) }
151
151
  scope :"#{field_name}_not_eq", ->(value) { where(field.not_eq(value)) }
152
152
 
153
- # Presence
154
- scope :"#{field_name}_present", -> { where(field.not_eq(nil)) }
153
+ # Presence - skip for string/text types as they get specialized version
154
+ # String types need to check for both nil and empty string
155
+ unless [ :string, :text ].include?(column_type)
156
+ scope :"#{field_name}_present", -> { where(field.not_eq(nil)) }
157
+ end
155
158
 
156
- register_predicable_scopes(
157
- :"#{field_name}_eq",
158
- :"#{field_name}_not_eq",
159
- :"#{field_name}_present"
160
- )
159
+ scopes_to_register = [ :"#{field_name}_eq", :"#{field_name}_not_eq" ]
160
+ scopes_to_register << :"#{field_name}_present" unless [ :string, :text ].include?(column_type)
161
+
162
+ register_predicable_scopes(*scopes_to_register)
161
163
  end
162
164
 
163
165
  # Genera predicati per campi stringa (12 scope)
164
166
  # Base predicates (_eq, _not_eq) are defined separately
165
- # _present is redefined here to handle empty strings
167
+ # _present is defined here to handle both nil and empty strings
166
168
  def define_string_predicates(field_name)
167
169
  table = arel_table
168
170
  field = table[field_name]
169
171
 
170
- # String-specific presence check (overrides base _present)
172
+ # String-specific presence check (checks both nil and empty string)
171
173
  scope :"#{field_name}_present", -> { where(field.not_eq(nil).and(field.not_eq(""))) }
172
174
 
173
175
  # Pattern matching (4)
@@ -273,6 +275,10 @@ module BetterModel
273
275
  end
274
276
 
275
277
  # Genera predicati per campi array PostgreSQL (3 scope)
278
+ #
279
+ # NOTA: Questo metodo non Γ¨ coperto da test automatici perchΓ© richiede
280
+ # PostgreSQL. I test vengono eseguiti su SQLite per performance.
281
+ # Testare manualmente su PostgreSQL con: rails console RAILS_ENV=test
276
282
  def define_postgresql_array_predicates(field_name)
277
283
  return unless postgresql_adapter?
278
284
 
@@ -325,6 +331,10 @@ module BetterModel
325
331
  end
326
332
 
327
333
  # Genera predicati per campi JSONB PostgreSQL (4 scope)
334
+ #
335
+ # NOTA: Questo metodo non Γ¨ coperto da test automatici perchΓ© richiede
336
+ # PostgreSQL con supporto JSONB. I test vengono eseguiti su SQLite per performance.
337
+ # Testare manualmente su PostgreSQL con: rails console RAILS_ENV=test
328
338
  def define_postgresql_jsonb_predicates(field_name)
329
339
  return unless postgresql_adapter?
330
340
 
@@ -188,6 +188,10 @@ module BetterModel
188
188
  end
189
189
 
190
190
  # Genera SQL per gestione NULL multi-database
191
+ #
192
+ # NOTA: Il blocco MySQL else qui sotto non Γ¨ coperto da test automatici
193
+ # perchΓ© i test vengono eseguiti su SQLite. Testare manualmente su MySQL
194
+ # con: rails console RAILS_ENV=test
191
195
  def nulls_order_sql(field_name, direction, nulls_position)
192
196
  quoted_field = connection.quote_column_name(field_name)
193
197
 
@@ -90,6 +90,9 @@ module BetterModel
90
90
  module Stateable
91
91
  extend ActiveSupport::Concern
92
92
 
93
+ # Thread-safe mutex for dynamic class creation
94
+ CLASS_CREATION_MUTEX = Mutex.new
95
+
93
96
  included do
94
97
  # Validazione ActiveRecord
95
98
  unless ancestors.include?(ActiveRecord::Base)
@@ -188,6 +191,9 @@ module BetterModel
188
191
 
189
192
  # Create or retrieve a StateTransition class for the given table name
190
193
  #
194
+ # Thread-safe implementation using mutex to prevent race conditions
195
+ # when multiple threads try to create the same class simultaneously.
196
+ #
191
197
  # @param table_name [String] Table name for state transitions
192
198
  # @return [Class] StateTransition class
193
199
  #
@@ -195,19 +201,27 @@ module BetterModel
195
201
  # Create a unique class name based on table name
196
202
  class_name = "#{table_name.camelize.singularize}"
197
203
 
198
- # Check if class already exists in BetterModel namespace
204
+ # Fast path: check if class already exists (no lock needed)
199
205
  if BetterModel.const_defined?(class_name, false)
200
206
  return BetterModel.const_get(class_name)
201
207
  end
202
208
 
203
- # Create new StateTransition class dynamically
204
- transition_class = Class.new(BetterModel::StateTransition) do
205
- self.table_name = table_name
206
- end
209
+ # Slow path: acquire lock and create class
210
+ CLASS_CREATION_MUTEX.synchronize do
211
+ # Double-check after acquiring lock (another thread may have created it)
212
+ if BetterModel.const_defined?(class_name, false)
213
+ return BetterModel.const_get(class_name)
214
+ end
207
215
 
208
- # Register the class in BetterModel namespace
209
- BetterModel.const_set(class_name, transition_class)
210
- transition_class
216
+ # Create new StateTransition class dynamically
217
+ transition_class = Class.new(BetterModel::StateTransition) do
218
+ self.table_name = table_name
219
+ end
220
+
221
+ # Register the class in BetterModel namespace
222
+ BetterModel.const_set(class_name, transition_class)
223
+ transition_class
224
+ end
211
225
  end
212
226
 
213
227
  # Setup dynamic methods per stati e transizioni
@@ -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
@@ -53,6 +53,9 @@ module BetterModel
53
53
  module Traceable
54
54
  extend ActiveSupport::Concern
55
55
 
56
+ # Thread-safe mutex for dynamic class creation
57
+ CLASS_CREATION_MUTEX = Mutex.new
58
+
56
59
  included do
57
60
  # Validazione ActiveRecord
58
61
  unless ancestors.include?(ActiveRecord::Base)
@@ -63,6 +66,7 @@ module BetterModel
63
66
  class_attribute :traceable_enabled, default: false
64
67
  class_attribute :traceable_config, default: {}.freeze
65
68
  class_attribute :traceable_fields, default: [].freeze
69
+ class_attribute :traceable_sensitive_fields, default: {}.freeze
66
70
  class_attribute :traceable_table_name, default: nil
67
71
  class_attribute :_traceable_setup_done, default: false
68
72
  end
@@ -85,6 +89,7 @@ module BetterModel
85
89
  configurator.instance_eval(&block)
86
90
  self.traceable_config = configurator.to_h.freeze
87
91
  self.traceable_fields = configurator.fields.freeze
92
+ self.traceable_sensitive_fields = configurator.sensitive_fields.freeze
88
93
  self.traceable_table_name = configurator.table_name
89
94
  end
90
95
 
@@ -175,25 +180,36 @@ module BetterModel
175
180
 
176
181
  # Create or retrieve a Version class for the given table name
177
182
  #
183
+ # Thread-safe implementation using mutex to prevent race conditions
184
+ # when multiple threads try to create the same class simultaneously.
185
+ #
178
186
  # @param table_name [String] Table name for versions
179
187
  # @return [Class] Version class
180
188
  def create_version_class_for_table(table_name)
181
189
  # Create a unique class name based on table name
182
190
  class_name = "#{table_name.camelize.singularize}"
183
191
 
184
- # Check if class already exists in BetterModel namespace
192
+ # Fast path: check if class already exists (no lock needed)
185
193
  if BetterModel.const_defined?(class_name, false)
186
194
  return BetterModel.const_get(class_name)
187
195
  end
188
196
 
189
- # Create new Version class dynamically
190
- version_class = Class.new(BetterModel::Version) do
191
- self.table_name = table_name
197
+ # Slow path: acquire lock and create class
198
+ CLASS_CREATION_MUTEX.synchronize do
199
+ # Double-check after acquiring lock (another thread may have created it)
200
+ if BetterModel.const_defined?(class_name, false)
201
+ return BetterModel.const_get(class_name)
202
+ end
203
+
204
+ # Create new Version class dynamically
205
+ version_class = Class.new(BetterModel::Version) do
206
+ self.table_name = table_name
207
+ end
208
+
209
+ # Register the class in BetterModel namespace
210
+ BetterModel.const_set(class_name, version_class)
211
+ version_class
192
212
  end
193
-
194
- # Register the class in BetterModel namespace
195
- BetterModel.const_set(class_name, version_class)
196
- version_class
197
213
  end
198
214
  end
199
215
 
@@ -268,22 +284,33 @@ module BetterModel
268
284
  # @param updated_by_id [Integer] User ID performing rollback
269
285
  # @param updated_reason [String] Reason for rollback
270
286
  # @return [self]
271
- 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)
272
288
  raise NotEnabledError unless self.class.traceable_enabled?
273
289
 
274
- begin
275
- version = versions.find(version) if version.is_a?(Integer)
276
- rescue ActiveRecord::RecordNotFound
277
- raise ArgumentError, "Version not found"
278
- end
290
+ version = versions.find(version) if version.is_a?(Integer)
279
291
 
280
- raise ArgumentError, "Version not found" unless version
281
- raise ArgumentError, "Version does not belong to this record" unless version.item == self
292
+ raise ActiveRecord::RecordNotFound, "Version not found" unless version
293
+ raise ActiveRecord::RecordNotFound, "Version does not belong to this record" unless version.item == self
282
294
 
283
295
  # Apply changes from version
284
296
  if version.object_changes
285
- version.object_changes.each do |field, (before_value, _after_value)|
286
- 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}=")
287
314
  end
288
315
  end
289
316
 
@@ -340,19 +367,103 @@ module BetterModel
340
367
  def tracked_changes
341
368
  return {} if self.class.traceable_fields.empty?
342
369
 
343
- if saved_changes.any?
370
+ raw_changes = if saved_changes.any?
344
371
  # After save: use saved_changes
345
372
  saved_changes.slice(*self.class.traceable_fields.map(&:to_s))
346
373
  else
347
374
  # Before save: use changes
348
375
  changes.slice(*self.class.traceable_fields.map(&:to_s))
349
376
  end
377
+
378
+ # Apply redaction to sensitive fields
379
+ apply_redaction_to_changes(raw_changes)
350
380
  end
351
381
 
352
382
  # Get final state for destroyed records
353
383
  def tracked_final_state
354
- self.class.traceable_fields.each_with_object({}) do |field, hash|
355
- hash[field.to_s] = [send(field), nil]
384
+ raw_state = self.class.traceable_fields.each_with_object({}) do |field, hash|
385
+ hash[field.to_s] = [ send(field), nil ]
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]"
356
467
  end
357
468
  end
358
469
 
@@ -378,19 +489,32 @@ module BetterModel
378
489
 
379
490
  # Configurator per traceable DSL
380
491
  class TraceableConfigurator
381
- attr_reader :fields, :table_name
492
+ attr_reader :fields, :table_name, :sensitive_fields
382
493
 
383
494
  def initialize(model_class)
384
495
  @model_class = model_class
385
496
  @fields = []
497
+ @sensitive_fields = {}
386
498
  @table_name = nil
387
499
  end
388
500
 
389
501
  # Specify which fields to track
390
502
  #
391
503
  # @param field_names [Array<Symbol>] Field names to track
392
- def track(*field_names)
393
- @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
394
518
  end
395
519
 
396
520
  # Specify custom table name for versions
@@ -401,7 +525,7 @@ module BetterModel
401
525
  end
402
526
 
403
527
  def to_h
404
- { fields: @fields, table_name: @table_name }
528
+ { fields: @fields, sensitive_fields: @sensitive_fields, table_name: @table_name }
405
529
  end
406
530
  end
407
531
 
@@ -427,17 +551,59 @@ module BetterModel
427
551
 
428
552
  private
429
553
 
554
+ # Check if database supports JSON/JSONB queries
555
+ def postgres?
556
+ ActiveRecord::Base.connection.adapter_name.downcase == "postgresql"
557
+ end
558
+
559
+ def mysql?
560
+ adapter = ActiveRecord::Base.connection.adapter_name.downcase
561
+ adapter.include?("mysql") || adapter == "trilogy"
562
+ end
563
+
430
564
  def execute_query
431
- query = @model_class
432
- .joins(:versions)
433
- .where("#{@table_name}.object_changes->>'#{@field}' IS NOT NULL")
565
+ # Base query
566
+ query = @model_class.joins(:versions)
434
567
 
435
- if @from_value
436
- query = query.where("#{@table_name}.object_changes->>'#{@field}' LIKE ?", "%#{@from_value}%")
437
- end
568
+ # NOTA: I blocchi PostgreSQL e MySQL qui sotto non sono coperti da test
569
+ # automatici perchΓ© i test vengono eseguiti su SQLite per performance.
570
+ # Testare manualmente su PostgreSQL/MySQL con: rails console RAILS_ENV=test
571
+
572
+ # PostgreSQL: Use JSONB operators for better performance
573
+ if postgres?
574
+ query = query.where("#{@table_name}.object_changes ? :field", field: @field)
575
+
576
+ if @from_value
577
+ query = query.where("#{@table_name}.object_changes->>'#{@field}' LIKE ?", "%#{@from_value}%")
578
+ end
438
579
 
439
- if @to_value
440
- query = query.where("#{@table_name}.object_changes->>'#{@field}' LIKE ?", "%#{@to_value}%")
580
+ if @to_value
581
+ query = query.where("#{@table_name}.object_changes->>'#{@field}' LIKE ?", "%#{@to_value}%")
582
+ end
583
+ # MySQL 5.7+: Use JSON_EXTRACT
584
+ elsif mysql?
585
+ query = query.where("JSON_EXTRACT(#{@table_name}.object_changes, '$.#{@field}') IS NOT NULL")
586
+
587
+ if @from_value
588
+ query = query.where("JSON_EXTRACT(#{@table_name}.object_changes, '$.#{@field}') LIKE ?", "%#{@from_value}%")
589
+ end
590
+
591
+ if @to_value
592
+ query = query.where("JSON_EXTRACT(#{@table_name}.object_changes, '$.#{@field}') LIKE ?", "%#{@to_value}%")
593
+ end
594
+ # SQLite or fallback: Use text-based search (limited functionality)
595
+ else
596
+ Rails.logger.warn "Traceable field-specific queries may have limited functionality on #{ActiveRecord::Base.connection.adapter_name}"
597
+
598
+ query = query.where("#{@table_name}.object_changes LIKE ?", "%\"#{@field}\"%")
599
+
600
+ if @from_value
601
+ query = query.where("#{@table_name}.object_changes LIKE ?", "%#{@from_value}%")
602
+ end
603
+
604
+ if @to_value
605
+ query = query.where("#{@table_name}.object_changes LIKE ?", "%#{@to_value}%")
606
+ end
441
607
  end
442
608
 
443
609
  query.distinct
@@ -135,11 +135,11 @@ module BetterModel
135
135
  validate do
136
136
  condition_met = if condition.is_a?(Symbol)
137
137
  send(condition)
138
- elsif condition.is_a?(Proc)
138
+ elsif condition.is_a?(Proc)
139
139
  instance_exec(&condition)
140
- else
140
+ else
141
141
  raise ArgumentError, "Condition must be a Symbol or Proc"
142
- end
142
+ end
143
143
 
144
144
  condition_met = !condition_met if negate
145
145
 
@@ -1,3 +1,3 @@
1
1
  module BetterModel
2
- VERSION = "1.1.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.1.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