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