better_model 1.3.0 → 2.0.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 +155 -1
- data/lib/better_model/archivable.rb +3 -2
- data/lib/better_model/predicable.rb +73 -52
- data/lib/better_model/schedulable/occurrence_calculator.rb +1034 -0
- data/lib/better_model/schedulable/schedule_builder.rb +269 -0
- data/lib/better_model/schedulable.rb +356 -0
- data/lib/better_model/searchable.rb +4 -4
- data/lib/better_model/version.rb +1 -1
- data/lib/better_model.rb +4 -0
- data/lib/generators/better_model/taggable/taggable_generator.rb +129 -0
- data/lib/generators/better_model/taggable/templates/README.tt +62 -0
- data/lib/generators/better_model/taggable/templates/migration.rb.tt +21 -0
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 56752c6458384a8ebb0bf5eb0e6e02adbd8bc822ce817705cc85d0502abce8aa
|
|
4
|
+
data.tar.gz: 8b8ce273a9b81d572f044154dd46486d1ad06086a7585b302875cd34069d28fc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a509b30bdb21978f8f75cced201a5b4bc6bc77b96dfd7a28dba6242c3d8b274984d6bf7e1d2f73902d404fab47eda51edb7b67cd8fcca918e3f85f5ad745f9c9
|
|
7
|
+
data.tar.gz: 69a04f82516a2de9bfa837091ee4823c7c4036f764d9cd8ad1e12615cdeac291929a0f491320c1a698fd2f9d6988e904716b7c08ad942d2683f16a4e99a3eb3c
|
data/README.md
CHANGED
|
@@ -94,6 +94,9 @@ class Article < ApplicationRecord
|
|
|
94
94
|
# 8. TRACEABLE - Audit trail with time-travel (opt-in)
|
|
95
95
|
traceable do
|
|
96
96
|
track :title, :content, :status, :published_at
|
|
97
|
+
track :password_hash, sensitive: :full # Complete redaction
|
|
98
|
+
track :credit_card, sensitive: :partial # Pattern-based masking
|
|
99
|
+
track :api_token, sensitive: :hash # SHA256 hashing
|
|
97
100
|
versions_table :article_versions # Optional: custom table
|
|
98
101
|
end
|
|
99
102
|
|
|
@@ -222,6 +225,146 @@ class Article < ApplicationRecord
|
|
|
222
225
|
end
|
|
223
226
|
```
|
|
224
227
|
|
|
228
|
+
## 🛠️ Generators
|
|
229
|
+
|
|
230
|
+
Better Model provides Rails generators to help you quickly set up migrations for features that require database tables or columns.
|
|
231
|
+
|
|
232
|
+
### Traceable Generator
|
|
233
|
+
|
|
234
|
+
Create migrations for audit trail version tables:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
# Basic usage - shows setup instructions
|
|
238
|
+
rails g better_model:traceable Article
|
|
239
|
+
|
|
240
|
+
# Create migration for versions table
|
|
241
|
+
rails g better_model:traceable Article --create-table
|
|
242
|
+
|
|
243
|
+
# Custom table name
|
|
244
|
+
rails g better_model:traceable Article --create-table --table-name=audit_log
|
|
245
|
+
|
|
246
|
+
# Run migrations
|
|
247
|
+
rails db:migrate
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
**Generated migration includes:**
|
|
251
|
+
- Polymorphic association (`item_type`, `item_id`)
|
|
252
|
+
- Event tracking (`created`, `updated`, `destroyed`)
|
|
253
|
+
- Change tracking (`object_changes` as JSON)
|
|
254
|
+
- User attribution (`updated_by_id`)
|
|
255
|
+
- Change reason (`updated_reason`)
|
|
256
|
+
- Optimized indexes
|
|
257
|
+
|
|
258
|
+
### Archivable Generator
|
|
259
|
+
|
|
260
|
+
Add soft-delete columns to existing models:
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
# Basic usage - shows setup instructions
|
|
264
|
+
rails g better_model:archivable Article
|
|
265
|
+
|
|
266
|
+
# Add archivable columns to articles table
|
|
267
|
+
rails g better_model:archivable Article --create-columns
|
|
268
|
+
|
|
269
|
+
# Run migrations
|
|
270
|
+
rails db:migrate
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
**Generated migration adds:**
|
|
274
|
+
- `archived_at` (datetime) - when archived
|
|
275
|
+
- `archived_by_id` (integer) - who archived it
|
|
276
|
+
- `archive_reason` (string) - why archived
|
|
277
|
+
- Index on `archived_at`
|
|
278
|
+
|
|
279
|
+
### Stateable Generator
|
|
280
|
+
|
|
281
|
+
Create state machine with state column and transitions tracking:
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
# Basic usage - shows setup instructions
|
|
285
|
+
rails g better_model:stateable Article
|
|
286
|
+
|
|
287
|
+
# Create both state column and transitions table
|
|
288
|
+
rails g better_model:stateable Article --create-tables
|
|
289
|
+
|
|
290
|
+
# Custom initial state (default: draft)
|
|
291
|
+
rails g better_model:stateable Article --create-tables --initial-state=pending
|
|
292
|
+
|
|
293
|
+
# Custom transitions table name
|
|
294
|
+
rails g better_model:stateable Article --create-tables --table-name=article_state_history
|
|
295
|
+
|
|
296
|
+
# Run migrations
|
|
297
|
+
rails db:migrate
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
**Generated migrations include:**
|
|
301
|
+
1. **State column migration:**
|
|
302
|
+
- `state` (string) with default value and index
|
|
303
|
+
|
|
304
|
+
2. **Transitions table migration:**
|
|
305
|
+
- Polymorphic association (`transitionable_type`, `transitionable_id`)
|
|
306
|
+
- Event name and state tracking
|
|
307
|
+
- Optional metadata (JSON)
|
|
308
|
+
- Optimized indexes
|
|
309
|
+
|
|
310
|
+
### Taggable Generator
|
|
311
|
+
|
|
312
|
+
Add tags column with database-specific optimizations:
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
# Basic usage - adds tags column
|
|
316
|
+
rails g better_model:taggable Article
|
|
317
|
+
|
|
318
|
+
# Custom column name
|
|
319
|
+
rails g better_model:taggable Article --column-name=categories
|
|
320
|
+
|
|
321
|
+
# Skip GIN index (PostgreSQL only)
|
|
322
|
+
rails g better_model:taggable Article --skip-index
|
|
323
|
+
|
|
324
|
+
# Run migrations
|
|
325
|
+
rails db:migrate
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
**Generated migration includes:**
|
|
329
|
+
- **PostgreSQL**: Native array column (`array: true, default: []`) with GIN index for optimal performance
|
|
330
|
+
- **SQLite/MySQL**: Text column with serialization instructions
|
|
331
|
+
- Database-specific detection and optimization
|
|
332
|
+
- Automatic index creation for PostgreSQL (GIN index)
|
|
333
|
+
|
|
334
|
+
**Database-specific behavior:**
|
|
335
|
+
- PostgreSQL: Ready to use immediately with native array support
|
|
336
|
+
- SQLite/MySQL: Requires adding `serialize :tags, coder: JSON, type: Array` to model
|
|
337
|
+
|
|
338
|
+
### Generator Options
|
|
339
|
+
|
|
340
|
+
All generators support these common options:
|
|
341
|
+
|
|
342
|
+
- `--pretend` - Dry run, show what would be generated
|
|
343
|
+
- `--skip-model` - Only generate migrations, don't show model setup instructions
|
|
344
|
+
- `--force` - Overwrite existing files
|
|
345
|
+
|
|
346
|
+
**Example workflow:**
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
# 1. Generate migrations (dry-run first to preview)
|
|
350
|
+
rails g better_model:traceable Article --create-table --pretend
|
|
351
|
+
rails g better_model:archivable Article --create-columns --pretend
|
|
352
|
+
rails g better_model:stateable Article --create-tables --pretend
|
|
353
|
+
rails g better_model:taggable Article --pretend
|
|
354
|
+
|
|
355
|
+
# 2. Generate for real
|
|
356
|
+
rails g better_model:traceable Article --create-table
|
|
357
|
+
rails g better_model:archivable Article --create-columns
|
|
358
|
+
rails g better_model:stateable Article --create-tables
|
|
359
|
+
rails g better_model:taggable Article
|
|
360
|
+
|
|
361
|
+
# 3. Run migrations
|
|
362
|
+
rails db:migrate
|
|
363
|
+
|
|
364
|
+
# 4. Enable in your model (generators show you the code)
|
|
365
|
+
# See model setup instructions after running each generator
|
|
366
|
+
```
|
|
367
|
+
|
|
225
368
|
## 📋 Features Overview
|
|
226
369
|
|
|
227
370
|
BetterModel provides ten powerful concerns that work seamlessly together:
|
|
@@ -358,10 +501,11 @@ Track all changes to your records with complete audit trail, time-travel capabil
|
|
|
358
501
|
**🎯 Key Benefits:**
|
|
359
502
|
- 🎛️ Opt-in activation: only enabled when explicitly configured
|
|
360
503
|
- 📝 Automatic change tracking on create, update, and destroy
|
|
504
|
+
- 🔐 Sensitive data protection: 3-level redaction system (full, partial, hash)
|
|
361
505
|
- 👤 User attribution: track who made each change
|
|
362
506
|
- 💬 Change reasons: optional context for changes
|
|
363
507
|
- ⏰ Time-travel: reconstruct object state at any point in history
|
|
364
|
-
- ↩️ Rollback support: restore records to previous versions
|
|
508
|
+
- ↩️ Rollback support: restore records to previous versions (with sensitive field protection)
|
|
365
509
|
- 🔍 Rich query API: find changes by user, time, or field transitions
|
|
366
510
|
- 📊 Flexible table naming: per-model, shared, or custom tables
|
|
367
511
|
- 🔗 Polymorphic association for efficient storage
|
|
@@ -698,3 +842,13 @@ All code has inline comments marking database-specific sections for maintainabil
|
|
|
698
842
|
## 📝 License
|
|
699
843
|
|
|
700
844
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
845
|
+
|
|
846
|
+
---
|
|
847
|
+
|
|
848
|
+
<div align="center">
|
|
849
|
+
|
|
850
|
+
**Made with ❤️ by [Alessio Bussolari](https://github.com/alessiobussolari)**
|
|
851
|
+
|
|
852
|
+
[Report Bug](https://github.com/alessiobussolari/better_model/issues) · [Request Feature](https://github.com/alessiobussolari/better_model/issues) · [Documentation](https://github.com/alessiobussolari/better_model)
|
|
853
|
+
|
|
854
|
+
</div>
|
|
@@ -104,8 +104,9 @@ module BetterModel
|
|
|
104
104
|
sort :archived_at unless sortable_field?(:archived_at)
|
|
105
105
|
|
|
106
106
|
# Definisci gli scope alias (approccio ibrido)
|
|
107
|
-
|
|
108
|
-
scope :
|
|
107
|
+
# v2.0.0: predicates require parameters
|
|
108
|
+
scope :archived, -> { archived_at_present(true) }
|
|
109
|
+
scope :not_archived, -> { archived_at_null(true) }
|
|
109
110
|
|
|
110
111
|
# Configura se passato un blocco
|
|
111
112
|
if block_given?
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
# Article.title_i_cont("rails") # WHERE LOWER(title) LIKE '%rails%'
|
|
18
18
|
# Article.view_count_gt(100) # WHERE view_count > 100
|
|
19
19
|
# Article.published_at_lteq(Date.today) # WHERE published_at <= '2025-10-29'
|
|
20
|
-
# Article.
|
|
20
|
+
# Article.featured_eq(true) # WHERE featured = TRUE
|
|
21
21
|
# Article.status_in(["draft", "published"]) # WHERE status IN ('draft', 'published')
|
|
22
22
|
#
|
|
23
23
|
module BetterModel
|
|
@@ -44,7 +44,7 @@ module BetterModel
|
|
|
44
44
|
# Genera automaticamente scope di filtro basati sul tipo di colonna:
|
|
45
45
|
# - String: _eq, _not_eq, _matches, _start, _end, _cont, _not_cont, _i_cont, _not_i_cont, _in, _not_in, _present, _blank, _null
|
|
46
46
|
# - Numeric: _eq, _not_eq, _lt, _lteq, _gt, _gteq, _in, _not_in, _present
|
|
47
|
-
# - Boolean: _eq, _not_eq, _true
|
|
47
|
+
# - Boolean: _eq, _not_eq, _present (Note: _true and _false removed in v2.0.0 - use _eq(true/false))
|
|
48
48
|
# - Date: _eq, _not_eq, _lt, _lteq, _gt, _gteq, _in, _not_in, _present, _blank, _null, _not_null
|
|
49
49
|
#
|
|
50
50
|
# Esempio:
|
|
@@ -151,9 +151,17 @@ module BetterModel
|
|
|
151
151
|
scope :"#{field_name}_not_eq", ->(value) { where(field.not_eq(value)) }
|
|
152
152
|
|
|
153
153
|
# Presence - skip for string/text types as they get specialized version
|
|
154
|
-
#
|
|
154
|
+
# Required boolean parameter:
|
|
155
|
+
# - true: returns present records (NOT NULL)
|
|
156
|
+
# - false: returns null records (NULL)
|
|
155
157
|
unless [ :string, :text ].include?(column_type)
|
|
156
|
-
scope :"#{field_name}_present", ->
|
|
158
|
+
scope :"#{field_name}_present", ->(condition) {
|
|
159
|
+
if condition
|
|
160
|
+
where(field.not_eq(nil))
|
|
161
|
+
else
|
|
162
|
+
where(field.eq(nil))
|
|
163
|
+
end
|
|
164
|
+
}
|
|
157
165
|
end
|
|
158
166
|
|
|
159
167
|
scopes_to_register = [ :"#{field_name}_eq", :"#{field_name}_not_eq" ]
|
|
@@ -170,7 +178,16 @@ module BetterModel
|
|
|
170
178
|
field = table[field_name]
|
|
171
179
|
|
|
172
180
|
# String-specific presence check (checks both nil and empty string)
|
|
173
|
-
|
|
181
|
+
# Required boolean parameter:
|
|
182
|
+
# - true: returns present records (NOT NULL AND NOT '')
|
|
183
|
+
# - false: returns blank records (NULL OR '')
|
|
184
|
+
scope :"#{field_name}_present", ->(condition) {
|
|
185
|
+
if condition
|
|
186
|
+
where(field.not_eq(nil).and(field.not_eq("")))
|
|
187
|
+
else
|
|
188
|
+
where(field.eq(nil).or(field.eq("")))
|
|
189
|
+
end
|
|
190
|
+
}
|
|
174
191
|
|
|
175
192
|
# Pattern matching (4)
|
|
176
193
|
scope :"#{field_name}_matches", ->(pattern) { where(field.matches(pattern)) }
|
|
@@ -205,9 +222,24 @@ module BetterModel
|
|
|
205
222
|
scope :"#{field_name}_in", ->(values) { where(field.in(Array(values))) }
|
|
206
223
|
scope :"#{field_name}_not_in", ->(values) { where.not(field.in(Array(values))) }
|
|
207
224
|
|
|
208
|
-
# Presence (2)
|
|
209
|
-
|
|
210
|
-
|
|
225
|
+
# Presence (2)
|
|
226
|
+
# Required boolean parameter:
|
|
227
|
+
# - true: returns blank/null records
|
|
228
|
+
# - false: returns present/not-null records
|
|
229
|
+
scope :"#{field_name}_blank", ->(condition) {
|
|
230
|
+
if condition
|
|
231
|
+
where(field.eq(nil).or(field.eq("")))
|
|
232
|
+
else
|
|
233
|
+
where(field.not_eq(nil).and(field.not_eq("")))
|
|
234
|
+
end
|
|
235
|
+
}
|
|
236
|
+
scope :"#{field_name}_null", ->(condition) {
|
|
237
|
+
if condition
|
|
238
|
+
where(field.eq(nil))
|
|
239
|
+
else
|
|
240
|
+
where(field.not_eq(nil))
|
|
241
|
+
end
|
|
242
|
+
}
|
|
211
243
|
|
|
212
244
|
register_predicable_scopes(
|
|
213
245
|
:"#{field_name}_matches",
|
|
@@ -258,20 +290,16 @@ module BetterModel
|
|
|
258
290
|
)
|
|
259
291
|
end
|
|
260
292
|
|
|
261
|
-
# Genera predicati per campi booleani
|
|
293
|
+
# Genera predicati per campi booleani
|
|
262
294
|
# Base predicates (_eq, _not_eq, _present) are defined separately
|
|
295
|
+
#
|
|
296
|
+
# Note: In v2.0.0, we removed _true and _false predicates as they were
|
|
297
|
+
# redundant with the base _eq predicate. Use these instead:
|
|
298
|
+
# field_eq(true) # instead of field_true
|
|
299
|
+
# field_eq(false) # instead of field_false
|
|
263
300
|
def define_boolean_predicates(field_name)
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
# Boolean shortcuts (2)
|
|
268
|
-
scope :"#{field_name}_true", -> { where(field.eq(true)) }
|
|
269
|
-
scope :"#{field_name}_false", -> { where(field.eq(false)) }
|
|
270
|
-
|
|
271
|
-
register_predicable_scopes(
|
|
272
|
-
:"#{field_name}_true",
|
|
273
|
-
:"#{field_name}_false"
|
|
274
|
-
)
|
|
301
|
+
# Boolean fields only use base predicates (_eq, _not_eq, _present)
|
|
302
|
+
# No additional predicates needed
|
|
275
303
|
end
|
|
276
304
|
|
|
277
305
|
# Genera predicati per campi array PostgreSQL (3 scope)
|
|
@@ -403,28 +431,7 @@ module BetterModel
|
|
|
403
431
|
scope :"#{field_name}_in", ->(values) { where(field.in(Array(values))) }
|
|
404
432
|
scope :"#{field_name}_not_in", ->(values) { where.not(field.in(Array(values))) }
|
|
405
433
|
|
|
406
|
-
# Date
|
|
407
|
-
scope :"#{field_name}_today", -> {
|
|
408
|
-
where(field.between(Date.current.beginning_of_day..Date.current.end_of_day))
|
|
409
|
-
}
|
|
410
|
-
scope :"#{field_name}_yesterday", -> {
|
|
411
|
-
where(field.between(1.day.ago.beginning_of_day..1.day.ago.end_of_day))
|
|
412
|
-
}
|
|
413
|
-
scope :"#{field_name}_this_week", -> {
|
|
414
|
-
where(field.gteq(Date.current.beginning_of_week))
|
|
415
|
-
}
|
|
416
|
-
scope :"#{field_name}_this_month", -> {
|
|
417
|
-
where(field.gteq(Date.current.beginning_of_month))
|
|
418
|
-
}
|
|
419
|
-
scope :"#{field_name}_this_year", -> {
|
|
420
|
-
where(field.gteq(Date.current.beginning_of_year))
|
|
421
|
-
}
|
|
422
|
-
scope :"#{field_name}_past", -> {
|
|
423
|
-
where(field.lt(Time.current))
|
|
424
|
-
}
|
|
425
|
-
scope :"#{field_name}_future", -> {
|
|
426
|
-
where(field.gt(Time.current))
|
|
427
|
-
}
|
|
434
|
+
# Date time-based filter (1)
|
|
428
435
|
scope :"#{field_name}_within", ->(duration) {
|
|
429
436
|
# Auto-detect: ActiveSupport::Duration or numeric (days)
|
|
430
437
|
time_ago = duration.respond_to?(:ago) ? duration.ago : duration.to_i.days.ago
|
|
@@ -432,9 +439,30 @@ module BetterModel
|
|
|
432
439
|
}
|
|
433
440
|
|
|
434
441
|
# Presence (3) - _present is defined in base predicates
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
442
|
+
# Required boolean parameter:
|
|
443
|
+
# - true: returns blank/null records
|
|
444
|
+
# - false: returns not-blank/not-null records
|
|
445
|
+
scope :"#{field_name}_blank", ->(condition) {
|
|
446
|
+
if condition
|
|
447
|
+
where(field.eq(nil))
|
|
448
|
+
else
|
|
449
|
+
where(field.not_eq(nil))
|
|
450
|
+
end
|
|
451
|
+
}
|
|
452
|
+
scope :"#{field_name}_null", ->(condition) {
|
|
453
|
+
if condition
|
|
454
|
+
where(field.eq(nil))
|
|
455
|
+
else
|
|
456
|
+
where(field.not_eq(nil))
|
|
457
|
+
end
|
|
458
|
+
}
|
|
459
|
+
scope :"#{field_name}_not_null", ->(condition) {
|
|
460
|
+
if condition
|
|
461
|
+
where(field.not_eq(nil))
|
|
462
|
+
else
|
|
463
|
+
where(field.eq(nil))
|
|
464
|
+
end
|
|
465
|
+
}
|
|
438
466
|
|
|
439
467
|
register_predicable_scopes(
|
|
440
468
|
:"#{field_name}_lt",
|
|
@@ -445,13 +473,6 @@ module BetterModel
|
|
|
445
473
|
:"#{field_name}_not_between",
|
|
446
474
|
:"#{field_name}_in",
|
|
447
475
|
:"#{field_name}_not_in",
|
|
448
|
-
:"#{field_name}_today",
|
|
449
|
-
:"#{field_name}_yesterday",
|
|
450
|
-
:"#{field_name}_this_week",
|
|
451
|
-
:"#{field_name}_this_month",
|
|
452
|
-
:"#{field_name}_this_year",
|
|
453
|
-
:"#{field_name}_past",
|
|
454
|
-
:"#{field_name}_future",
|
|
455
476
|
:"#{field_name}_within",
|
|
456
477
|
:"#{field_name}_blank",
|
|
457
478
|
:"#{field_name}_null",
|