better_model 1.1.0 β†’ 1.2.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: ec236d26e4c1e0a5a62e9932606a1baa28de1bcadf8b447f1da545fb35d029b6
4
+ data.tar.gz: 24e4aa004924ab09c6104cad770229041b8c8d24556ce5f2f2d190bf15071e90
5
5
  SHA512:
6
- metadata.gz: bc082d6e0b93b1750f73ab4878f26f7f99aa2080aeb05c8382f5e4b47ee6287402b984686cebe665a7ffa1b154683aa00ddb8b7a78e841d41f3446a27d104957
7
- data.tar.gz: 5fd80c99b6e83ad69870226c06c949ba11b8e15e44883ba5b33c7e2ca8e79e0f0c79f8465ad6c176ba6726f8b3947819b39feaa94a316935fc2045f41b78a13c
6
+ metadata.gz: 77d291e54bcbbb179eabd842290530d0f42e03636981e93bfdeb6575d5d0e02960d359072c0b0db0144f35cdbea647d463bf7320037c557552f6fce8365a393e
7
+ data.tar.gz: bfe3bf61206cb343dce4d84b2ff643e719c09b93a623de77e127a00aadbce646d25bfcc2cd66ca3bd725b6af224a82eb7cff2e059f79f45c8e026d5d878c95d6
data/README.md CHANGED
@@ -91,7 +91,13 @@ 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
@@ -149,6 +155,17 @@ article.published? # => true
149
155
  article.state_transitions # History of all transitions
150
156
  article.transition_history # Formatted history array
151
157
 
158
+ # ⏰ Time travel & rollback (Traceable)
159
+ article.audit_trail # Full change history
160
+ article.as_of(3.days.ago) # Reconstruct past state
161
+ article.rollback_to(version) # Restore to previous version
162
+ article.changes_for(:status) # Changes for specific field
163
+
164
+ # πŸ” Query changes
165
+ Article.changed_by(user.id)
166
+ Article.changed_between(1.week.ago, Time.current)
167
+ Article.status_changed_from("draft").to("published")
168
+
152
169
  # πŸ”Ž Unified search with filters, sorting, and pagination
153
170
  Article.search(
154
171
  { status_eq: "published", view_count_gteq: 50 },
@@ -166,6 +183,7 @@ class Article < ApplicationRecord
166
183
  include BetterModel::Statusable # Only status management
167
184
  include BetterModel::Permissible # Only permissions
168
185
  include BetterModel::Archivable # Only archiving
186
+ include BetterModel::Traceable # Only audit trail & time-travel
169
187
  include BetterModel::Sortable # Only sorting
170
188
  include BetterModel::Predicable # Only filtering
171
189
  include BetterModel::Validatable # Only validations
@@ -176,6 +194,24 @@ class Article < ApplicationRecord
176
194
  end
177
195
  ```
178
196
 
197
+ ## πŸ“‹ Features Overview
198
+
199
+ BetterModel provides nine powerful concerns that work seamlessly together:
200
+
201
+ ### Core Features
202
+
203
+ - **✨ Statusable** - Declarative status management with lambda-based conditions
204
+ - **πŸ” Permissible** - State-based permission system
205
+ - **πŸ—„οΈ Archivable** - Soft delete with tracking (by user, reason)
206
+ - **⏰ Traceable** πŸ†• - Complete audit trail with time-travel and rollback
207
+ - **⬆️ Sortable** - Type-aware sorting scopes
208
+ - **πŸ” Predicable** - Advanced filtering with rich predicate system
209
+ - **πŸ”Ž Searchable** - Unified search interface (Predicable + Sortable)
210
+ - **βœ… Validatable** - Declarative validation DSL with conditional rules
211
+ - **πŸ”„ Stateable** πŸ†• - Declarative state machines with guards & callbacks
212
+
213
+ [See all features in detail β†’](#-features)
214
+
179
215
  ## βš™οΈ Requirements
180
216
 
181
217
  - **Ruby:** 3.0 or higher
@@ -286,6 +322,27 @@ Define state machines declaratively with transitions, guards, validations, and c
286
322
 
287
323
  ---
288
324
 
325
+ ### ⏰ Traceable - Audit Trail with Time-Travel
326
+
327
+ Track all changes to your records with complete audit trail, time-travel capabilities, and rollback support.
328
+
329
+ **🎯 Key Benefits:**
330
+ - πŸŽ›οΈ Opt-in activation: only enabled when explicitly configured
331
+ - πŸ“ Automatic change tracking on create, update, and destroy
332
+ - πŸ‘€ User attribution: track who made each change
333
+ - πŸ’¬ Change reasons: optional context for changes
334
+ - ⏰ Time-travel: reconstruct object state at any point in history
335
+ - ↩️ Rollback support: restore records to previous versions
336
+ - πŸ” Rich query API: find changes by user, time, or field transitions
337
+ - πŸ“Š Flexible table naming: per-model, shared, or custom tables
338
+ - πŸ”— Polymorphic association for efficient storage
339
+ - πŸ’Ύ Database adapter safety: PostgreSQL, MySQL, SQLite support
340
+ - πŸ”’ Thread-safe dynamic class creation
341
+
342
+ **[πŸ“– Full Documentation β†’](docs/traceable.md)**
343
+
344
+ ---
345
+
289
346
  ### ⬆️ Sortable - Type-Aware Sorting Scopes
290
347
 
291
348
  Generate intelligent sorting scopes automatically with database-specific optimizations and NULL handling.
@@ -487,6 +544,38 @@ See [CHANGELOG.md](CHANGELOG.md) for version history and release notes.
487
544
  - πŸ’» **Source Code:** [GitHub Repository](https://github.com/alessiobussolari/better_model)
488
545
  - πŸ“– **Documentation:** This README and detailed docs in `docs/` directory
489
546
 
547
+ ## πŸ“š Complete Documentation
548
+
549
+ ### πŸ“– Feature Guides
550
+
551
+ Detailed documentation for each BetterModel concern:
552
+
553
+ - [**Statusable**](docs/statusable.md) - Status management with derived conditions
554
+ - [**Permissible**](docs/permissible.md) - Permission system based on state
555
+ - [**Archivable**](docs/archivable.md) - Soft delete with comprehensive tracking
556
+ - [**Traceable**](docs/traceable.md) πŸ†• - Audit trail, time-travel, and rollback
557
+ - [**Sortable**](docs/sortable.md) - Type-aware sorting system
558
+ - [**Predicable**](docs/predicable.md) - Advanced filtering and predicates
559
+ - [**Searchable**](docs/searchable.md) - Unified search interface
560
+ - [**Validatable**](docs/validatable.md) - Declarative validation system
561
+ - [**Stateable**](docs/stateable.md) πŸ†• - State machine with transitions
562
+
563
+ ### πŸŽ“ Advanced Guides
564
+
565
+ Learn how to master BetterModel in production:
566
+
567
+ - [**Integration Guide**](docs/integration_guide.md) πŸ†• - Combining multiple concerns effectively
568
+ - [**Performance Guide**](docs/performance_guide.md) πŸ†• - Optimization strategies and indexing
569
+ - [**Migration Guide**](docs/migration_guide.md) πŸ†• - Adding BetterModel to existing apps
570
+
571
+ ### πŸ’‘ Quick Links
572
+
573
+ - [Installation](#-installation)
574
+ - [Quick Start](#-quick-start)
575
+ - [Features Overview](#-features-overview)
576
+ - [Requirements](#%EF%B8%8F-requirements)
577
+ - [Contributing](#-contributing)
578
+
490
579
  ## 🀝 Contributing
491
580
 
492
581
  We welcome contributions! Here's how you can help:
@@ -532,6 +621,27 @@ bundle exec rake test # Coverage report in coverage/index.html
532
621
  bundle exec rubocop
533
622
  ```
534
623
 
624
+ ### πŸ“Š Test Coverage Notes
625
+
626
+ The test suite runs on **SQLite** for performance and portability. Current coverage: **91.45%** (1272 / 1391 lines).
627
+
628
+ **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`
632
+
633
+ These features are fully implemented with proper SQL sanitization but require manual testing on PostgreSQL/MySQL:
634
+
635
+ ```bash
636
+ # Test on PostgreSQL
637
+ RAILS_ENV=test DATABASE_URL=postgresql://user:pass@localhost/better_model_test rails console
638
+
639
+ # Test on MySQL
640
+ RAILS_ENV=test DATABASE_URL=mysql2://user:pass@localhost/better_model_test rails console
641
+ ```
642
+
643
+ All code has inline comments marking database-specific sections for maintainability.
644
+
535
645
  ### πŸ“ Code Guidelines
536
646
 
537
647
  - ✨ 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
@@ -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)
@@ -175,25 +178,36 @@ module BetterModel
175
178
 
176
179
  # Create or retrieve a Version class for the given table name
177
180
  #
181
+ # Thread-safe implementation using mutex to prevent race conditions
182
+ # when multiple threads try to create the same class simultaneously.
183
+ #
178
184
  # @param table_name [String] Table name for versions
179
185
  # @return [Class] Version class
180
186
  def create_version_class_for_table(table_name)
181
187
  # Create a unique class name based on table name
182
188
  class_name = "#{table_name.camelize.singularize}"
183
189
 
184
- # Check if class already exists in BetterModel namespace
190
+ # Fast path: check if class already exists (no lock needed)
185
191
  if BetterModel.const_defined?(class_name, false)
186
192
  return BetterModel.const_get(class_name)
187
193
  end
188
194
 
189
- # Create new Version class dynamically
190
- version_class = Class.new(BetterModel::Version) do
191
- self.table_name = table_name
195
+ # Slow path: acquire lock and create class
196
+ CLASS_CREATION_MUTEX.synchronize do
197
+ # Double-check after acquiring lock (another thread may have created it)
198
+ if BetterModel.const_defined?(class_name, false)
199
+ return BetterModel.const_get(class_name)
200
+ end
201
+
202
+ # Create new Version class dynamically
203
+ version_class = Class.new(BetterModel::Version) do
204
+ self.table_name = table_name
205
+ end
206
+
207
+ # Register the class in BetterModel namespace
208
+ BetterModel.const_set(class_name, version_class)
209
+ version_class
192
210
  end
193
-
194
- # Register the class in BetterModel namespace
195
- BetterModel.const_set(class_name, version_class)
196
- version_class
197
211
  end
198
212
  end
199
213
 
@@ -271,14 +285,10 @@ module BetterModel
271
285
  def rollback_to(version, updated_by_id: nil, updated_reason: nil)
272
286
  raise NotEnabledError unless self.class.traceable_enabled?
273
287
 
274
- begin
275
- version = versions.find(version) if version.is_a?(Integer)
276
- rescue ActiveRecord::RecordNotFound
277
- raise ArgumentError, "Version not found"
278
- end
288
+ version = versions.find(version) if version.is_a?(Integer)
279
289
 
280
- raise ArgumentError, "Version not found" unless version
281
- raise ArgumentError, "Version does not belong to this record" unless version.item == self
290
+ raise ActiveRecord::RecordNotFound, "Version not found" unless version
291
+ raise ActiveRecord::RecordNotFound, "Version does not belong to this record" unless version.item == self
282
292
 
283
293
  # Apply changes from version
284
294
  if version.object_changes
@@ -352,7 +362,7 @@ module BetterModel
352
362
  # Get final state for destroyed records
353
363
  def tracked_final_state
354
364
  self.class.traceable_fields.each_with_object({}) do |field, hash|
355
- hash[field.to_s] = [send(field), nil]
365
+ hash[field.to_s] = [ send(field), nil ]
356
366
  end
357
367
  end
358
368
 
@@ -427,17 +437,59 @@ module BetterModel
427
437
 
428
438
  private
429
439
 
440
+ # Check if database supports JSON/JSONB queries
441
+ def postgres?
442
+ ActiveRecord::Base.connection.adapter_name.downcase == "postgresql"
443
+ end
444
+
445
+ def mysql?
446
+ adapter = ActiveRecord::Base.connection.adapter_name.downcase
447
+ adapter.include?("mysql") || adapter == "trilogy"
448
+ end
449
+
430
450
  def execute_query
431
- query = @model_class
432
- .joins(:versions)
433
- .where("#{@table_name}.object_changes->>'#{@field}' IS NOT NULL")
451
+ # Base query
452
+ query = @model_class.joins(:versions)
434
453
 
435
- if @from_value
436
- query = query.where("#{@table_name}.object_changes->>'#{@field}' LIKE ?", "%#{@from_value}%")
437
- end
454
+ # NOTA: I blocchi PostgreSQL e MySQL qui sotto non sono coperti da test
455
+ # automatici perchΓ© i test vengono eseguiti su SQLite per performance.
456
+ # Testare manualmente su PostgreSQL/MySQL con: rails console RAILS_ENV=test
457
+
458
+ # PostgreSQL: Use JSONB operators for better performance
459
+ if postgres?
460
+ query = query.where("#{@table_name}.object_changes ? :field", field: @field)
461
+
462
+ if @from_value
463
+ query = query.where("#{@table_name}.object_changes->>'#{@field}' LIKE ?", "%#{@from_value}%")
464
+ end
438
465
 
439
- if @to_value
440
- query = query.where("#{@table_name}.object_changes->>'#{@field}' LIKE ?", "%#{@to_value}%")
466
+ if @to_value
467
+ query = query.where("#{@table_name}.object_changes->>'#{@field}' LIKE ?", "%#{@to_value}%")
468
+ end
469
+ # MySQL 5.7+: Use JSON_EXTRACT
470
+ elsif mysql?
471
+ query = query.where("JSON_EXTRACT(#{@table_name}.object_changes, '$.#{@field}') IS NOT NULL")
472
+
473
+ if @from_value
474
+ query = query.where("JSON_EXTRACT(#{@table_name}.object_changes, '$.#{@field}') LIKE ?", "%#{@from_value}%")
475
+ end
476
+
477
+ if @to_value
478
+ query = query.where("JSON_EXTRACT(#{@table_name}.object_changes, '$.#{@field}') LIKE ?", "%#{@to_value}%")
479
+ end
480
+ # SQLite or fallback: Use text-based search (limited functionality)
481
+ else
482
+ Rails.logger.warn "Traceable field-specific queries may have limited functionality on #{ActiveRecord::Base.connection.adapter_name}"
483
+
484
+ query = query.where("#{@table_name}.object_changes LIKE ?", "%\"#{@field}\"%")
485
+
486
+ if @from_value
487
+ query = query.where("#{@table_name}.object_changes LIKE ?", "%#{@from_value}%")
488
+ end
489
+
490
+ if @to_value
491
+ query = query.where("#{@table_name}.object_changes LIKE ?", "%#{@to_value}%")
492
+ end
441
493
  end
442
494
 
443
495
  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.2.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - alessiobussolari