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 +4 -4
- data/README.md +160 -4
- data/lib/better_model/predicable.rb +21 -11
- data/lib/better_model/sortable.rb +4 -0
- data/lib/better_model/stateable.rb +22 -8
- data/lib/better_model/taggable.rb +466 -0
- data/lib/better_model/traceable.rb +199 -33
- data/lib/better_model/validatable.rb +3 -3
- data/lib/better_model/version.rb +1 -1
- data/lib/better_model/version_record.rb +6 -3
- data/lib/better_model.rb +2 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 93cc27ca4bb13c22c5d3ac1f0aa96010f7377bf687805b5f76bee89d44b83f5f
|
|
4
|
+
data.tar.gz: 5234fde47b3b13fc0ccc2da3fc3948f094ccd0f7eaa2cb4d8d219e6cf291a1c3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
## π
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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 (
|
|
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
|
-
#
|
|
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
|
-
#
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
|
281
|
-
raise
|
|
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,
|
|
286
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
.where("#{@table_name}.object_changes->>'#{@field}' IS NOT NULL")
|
|
565
|
+
# Base query
|
|
566
|
+
query = @model_class.joins(:versions)
|
|
434
567
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
440
|
-
|
|
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
|
-
|
|
138
|
+
elsif condition.is_a?(Proc)
|
|
139
139
|
instance_exec(&condition)
|
|
140
|
-
|
|
140
|
+
else
|
|
141
141
|
raise ArgumentError, "Condition must be a Symbol or Proc"
|
|
142
|
-
|
|
142
|
+
end
|
|
143
143
|
|
|
144
144
|
condition_met = !condition_met if negate
|
|
145
145
|
|
data/lib/better_model/version.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|