better_model 1.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +312 -0
- data/Rakefile +12 -0
- data/lib/better_model/archivable.rb +272 -0
- data/lib/better_model/permissible.rb +151 -0
- data/lib/better_model/predicable.rb +481 -0
- data/lib/better_model/railtie.rb +4 -0
- data/lib/better_model/searchable.rb +524 -0
- data/lib/better_model/sortable.rb +217 -0
- data/lib/better_model/statusable.rb +154 -0
- data/lib/better_model/version.rb +3 -0
- data/lib/better_model.rb +24 -0
- data/lib/generators/better_model/archivable/archivable_generator.rb +55 -0
- data/lib/generators/better_model/archivable/templates/migration.rb.tt +26 -0
- data/lib/tasks/better_model_tasks.rake +4 -0
- metadata +82 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6ba3b1688c75e16f9499780eebd533c307cd8ba18f9c37a03597c8d62310fae7
|
|
4
|
+
data.tar.gz: '0835904e4ea513187668f8f9c36e3d9855096bc46a2150555fae900b320b764d'
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d42074244a7e4af9321b68e9657f343ac4c087956856cfd0cbfe68e72adc1fce5200fc051cdfe1badd0e6bc753dfa3abcf0603113e3dede3cd7ce083b3ba932c
|
|
7
|
+
data.tar.gz: e5305841ab87d40047b6bc458bdac8a163a96d0d9e864ead0a0335ab84b53759a5da837cb6b95f1c2bdc3596fcd74dc7281d77d370a3db0aa9cc98f72c281708
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright alessiobussolari
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# BetterModel
|
|
2
|
+
|
|
3
|
+
BetterModel is a Rails engine gem (Rails 8.1+) that provides powerful extensions for ActiveRecord models, including declarative status management, permissions, archiving, sorting, filtering, and unified search capabilities.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add this line to your application's Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "better_model"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
And then execute:
|
|
14
|
+
```bash
|
|
15
|
+
$ bundle install
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or install it yourself as:
|
|
19
|
+
```bash
|
|
20
|
+
$ gem install better_model
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
Simply include `BetterModel` in your model to get all features:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
class Article < ApplicationRecord
|
|
29
|
+
include BetterModel # Includes all BetterModel concerns
|
|
30
|
+
|
|
31
|
+
# 1. STATUSABLE - Define statuses with lambdas
|
|
32
|
+
is :draft, -> { status == "draft" }
|
|
33
|
+
is :published, -> { status == "published" && published_at.present? }
|
|
34
|
+
is :expired, -> { expires_at.present? && expires_at <= Time.current }
|
|
35
|
+
is :popular, -> { view_count >= 100 }
|
|
36
|
+
is :active, -> { is?(:published) && !is?(:expired) }
|
|
37
|
+
|
|
38
|
+
# 2. PERMISSIBLE - Define permissions based on statuses
|
|
39
|
+
permit :edit, -> { is?(:draft) || (is?(:published) && !is?(:expired)) }
|
|
40
|
+
permit :delete, -> { is?(:draft) }
|
|
41
|
+
permit :publish, -> { is?(:draft) }
|
|
42
|
+
permit :unpublish, -> { is?(:published) }
|
|
43
|
+
|
|
44
|
+
# 3. SORTABLE - Define sortable fields
|
|
45
|
+
sort :title, :view_count, :published_at, :created_at
|
|
46
|
+
|
|
47
|
+
# 4. PREDICABLE - Define searchable/filterable fields
|
|
48
|
+
predicates :title, :status, :view_count, :published_at, :created_at, :featured
|
|
49
|
+
|
|
50
|
+
# 5. ARCHIVABLE - Soft delete with tracking (opt-in)
|
|
51
|
+
archivable do
|
|
52
|
+
skip_archived_by_default true # Hide archived records by default
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# 6. SEARCHABLE - Configure unified search interface
|
|
56
|
+
searchable do
|
|
57
|
+
per_page 25
|
|
58
|
+
max_per_page 100
|
|
59
|
+
default_order [:sort_published_at_desc]
|
|
60
|
+
security :status_required, [:status_eq]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Now you can use all the features:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
# Check statuses
|
|
69
|
+
article.is?(:draft) # => true/false
|
|
70
|
+
article.is_published? # => true/false
|
|
71
|
+
article.statuses # => { draft: true, published: false, ... }
|
|
72
|
+
|
|
73
|
+
# Check permissions
|
|
74
|
+
article.permit?(:edit) # => true/false
|
|
75
|
+
article.permit_delete? # => true/false
|
|
76
|
+
article.permissions # => { edit: true, delete: true, ... }
|
|
77
|
+
|
|
78
|
+
# Sort
|
|
79
|
+
Article.sort_title_asc
|
|
80
|
+
Article.sort_view_count_desc
|
|
81
|
+
Article.sort_published_at_desc
|
|
82
|
+
|
|
83
|
+
# Filter with predicates
|
|
84
|
+
Article.status_eq("published")
|
|
85
|
+
Article.title_cont("Rails")
|
|
86
|
+
Article.view_count_gteq(100)
|
|
87
|
+
Article.published_at_present
|
|
88
|
+
|
|
89
|
+
# Archive records
|
|
90
|
+
article.archive!(by: current_user, reason: "Outdated")
|
|
91
|
+
article.archived? # => true
|
|
92
|
+
article.restore!
|
|
93
|
+
|
|
94
|
+
# Query archived records
|
|
95
|
+
Article.archived
|
|
96
|
+
Article.not_archived
|
|
97
|
+
Article.archived_recently(7.days)
|
|
98
|
+
|
|
99
|
+
# Unified search with filters, sorting, and pagination
|
|
100
|
+
Article.search(
|
|
101
|
+
{ status_eq: "published", view_count_gteq: 50 },
|
|
102
|
+
orders: [:sort_published_at_desc],
|
|
103
|
+
pagination: { page: 1, per_page: 25 }
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Including Individual Concerns (Advanced)
|
|
108
|
+
|
|
109
|
+
If you only need specific features, you can include individual concerns:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
class Article < ApplicationRecord
|
|
113
|
+
include BetterModel::Statusable # Only status management
|
|
114
|
+
include BetterModel::Permissible # Only permissions
|
|
115
|
+
include BetterModel::Archivable # Only archiving
|
|
116
|
+
include BetterModel::Sortable # Only sorting
|
|
117
|
+
include BetterModel::Predicable # Only filtering
|
|
118
|
+
include BetterModel::Searchable # Only search (requires Predicable & Sortable)
|
|
119
|
+
|
|
120
|
+
# Define your features...
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Requirements
|
|
125
|
+
|
|
126
|
+
- **Ruby:** 3.0 or higher
|
|
127
|
+
- **Rails:** 8.1 or higher
|
|
128
|
+
- **ActiveRecord:** Included with Rails
|
|
129
|
+
|
|
130
|
+
## Database Compatibility
|
|
131
|
+
|
|
132
|
+
BetterModel works with all databases supported by ActiveRecord:
|
|
133
|
+
|
|
134
|
+
| Database | Status | Notes |
|
|
135
|
+
|----------|--------|-------|
|
|
136
|
+
| **PostgreSQL** | ✅ Full support | Recommended. Includes array and JSONB predicates |
|
|
137
|
+
| **MySQL/MariaDB** | ✅ Full support | NULLS emulation for sorting |
|
|
138
|
+
| **SQLite** | ✅ Full support | Great for development and testing |
|
|
139
|
+
| **SQL Server** | ✅ Full support | Standard features work |
|
|
140
|
+
| **Oracle** | ✅ Full support | Standard features work |
|
|
141
|
+
|
|
142
|
+
**PostgreSQL-Specific Features:**
|
|
143
|
+
- Array predicates: `overlaps`, `contains`, `contained_by`
|
|
144
|
+
- JSONB predicates: `has_key`, `has_any_key`, `has_all_keys`, `jsonb_contains`
|
|
145
|
+
|
|
146
|
+
## Features
|
|
147
|
+
|
|
148
|
+
BetterModel provides six powerful concerns that work together seamlessly:
|
|
149
|
+
|
|
150
|
+
### 📋 Statusable - Declarative Status Management
|
|
151
|
+
|
|
152
|
+
Define derived statuses dynamically based on model attributes - no database columns needed!
|
|
153
|
+
|
|
154
|
+
**Key Benefits:**
|
|
155
|
+
- Declarative DSL with clear, readable conditions
|
|
156
|
+
- Statuses calculated in real-time from model attributes
|
|
157
|
+
- Reference other statuses in conditions
|
|
158
|
+
- Automatic method generation (`is_draft?`, `is_published?`)
|
|
159
|
+
- Thread-safe with immutable registry
|
|
160
|
+
|
|
161
|
+
**[📖 Full Documentation →](docs/statusable.md)**
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
### 🔐 Permissible - Declarative Permission Management
|
|
166
|
+
|
|
167
|
+
Define permissions dynamically based on model state and statuses - perfect for authorization logic!
|
|
168
|
+
|
|
169
|
+
**Key Benefits:**
|
|
170
|
+
- Declarative DSL following Statusable pattern
|
|
171
|
+
- Permissions calculated from model state
|
|
172
|
+
- Reference statuses in permission logic
|
|
173
|
+
- Automatic method generation (`permit_edit?`, `permit_delete?`)
|
|
174
|
+
- Thread-safe with immutable registry
|
|
175
|
+
|
|
176
|
+
**[📖 Full Documentation →](docs/permissible.md)**
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
### 🗄️ Archivable - Soft Delete with Archive Management
|
|
181
|
+
|
|
182
|
+
Soft-delete records with archive tracking, audit trails, and restoration capabilities.
|
|
183
|
+
|
|
184
|
+
**Key Benefits:**
|
|
185
|
+
- Opt-in activation: only enabled when explicitly configured
|
|
186
|
+
- Archive and restore methods with optional tracking
|
|
187
|
+
- Status methods: `archived?` and `active?`
|
|
188
|
+
- Semantic scopes: `archived`, `not_archived`, `archived_only`
|
|
189
|
+
- Helper predicates: `archived_today`, `archived_this_week`, `archived_recently`
|
|
190
|
+
- Optional default scope to hide archived records
|
|
191
|
+
- Migration generator with flexible options
|
|
192
|
+
- Thread-safe with immutable configuration
|
|
193
|
+
|
|
194
|
+
**[📖 Full Documentation →](docs/archivable.md)**
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
### ⬆️ Sortable - Type-Aware Sorting Scopes
|
|
199
|
+
|
|
200
|
+
Generate intelligent sorting scopes automatically with database-specific optimizations and NULL handling.
|
|
201
|
+
|
|
202
|
+
**Key Benefits:**
|
|
203
|
+
- Type-aware scope generation (string, numeric, datetime, boolean)
|
|
204
|
+
- Case-insensitive sorting for strings
|
|
205
|
+
- Database-specific NULLS FIRST/LAST support
|
|
206
|
+
- Sort by multiple fields with chaining
|
|
207
|
+
- Optimized queries with proper indexing support
|
|
208
|
+
|
|
209
|
+
**[📖 Full Documentation →](docs/sortable.md)**
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
### 🔍 Predicable - Advanced Query Scopes
|
|
214
|
+
|
|
215
|
+
Generate comprehensive predicate scopes for filtering and searching with support for all data types.
|
|
216
|
+
|
|
217
|
+
**Key Benefits:**
|
|
218
|
+
- Complete coverage: string, numeric, datetime, boolean, null predicates
|
|
219
|
+
- Type-safe predicates based on column type
|
|
220
|
+
- Case-insensitive string matching
|
|
221
|
+
- Range queries (between) for numerics and dates
|
|
222
|
+
- PostgreSQL array and JSONB support
|
|
223
|
+
- Chainable with standard ActiveRecord queries
|
|
224
|
+
|
|
225
|
+
**[📖 Full Documentation →](docs/predicable.md)**
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
### 🔎 Searchable - Unified Search Interface
|
|
230
|
+
|
|
231
|
+
Orchestrate Predicable and Sortable into a powerful, secure search interface with pagination and security.
|
|
232
|
+
|
|
233
|
+
**Key Benefits:**
|
|
234
|
+
- Unified API: single `search()` method for all operations
|
|
235
|
+
- OR conditions for complex logic
|
|
236
|
+
- Built-in pagination with DoS protection (max_per_page)
|
|
237
|
+
- Security enforcement with required predicates
|
|
238
|
+
- Default ordering configuration
|
|
239
|
+
- Strong parameters integration
|
|
240
|
+
- Type-safe validation of all parameters
|
|
241
|
+
|
|
242
|
+
**[📖 Full Documentation →](docs/searchable.md)**
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Version & Changelog
|
|
247
|
+
|
|
248
|
+
**Current Version:** 1.0.0
|
|
249
|
+
|
|
250
|
+
See [CHANGELOG.md](CHANGELOG.md) for version history and release notes.
|
|
251
|
+
|
|
252
|
+
## Support & Community
|
|
253
|
+
|
|
254
|
+
- **Issues & Bugs:** [GitHub Issues](https://github.com/alessiobussolari/better_model/issues)
|
|
255
|
+
- **Source Code:** [GitHub Repository](https://github.com/alessiobussolari/better_model)
|
|
256
|
+
- **Documentation:** This README and detailed docs in `docs/` directory
|
|
257
|
+
|
|
258
|
+
## Contributing
|
|
259
|
+
|
|
260
|
+
We welcome contributions! Here's how you can help:
|
|
261
|
+
|
|
262
|
+
### Reporting Bugs
|
|
263
|
+
|
|
264
|
+
1. Check if the issue already exists in [GitHub Issues](https://github.com/alessiobussolari/better_model/issues)
|
|
265
|
+
2. Create a new issue with:
|
|
266
|
+
- Clear description of the problem
|
|
267
|
+
- Steps to reproduce
|
|
268
|
+
- Expected vs actual behavior
|
|
269
|
+
- Ruby/Rails versions
|
|
270
|
+
- Database adapter
|
|
271
|
+
|
|
272
|
+
### Submitting Pull Requests
|
|
273
|
+
|
|
274
|
+
1. Fork the repository
|
|
275
|
+
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
|
276
|
+
3. Make your changes with tests
|
|
277
|
+
4. Run the test suite (`bundle exec rake test`)
|
|
278
|
+
5. Ensure RuboCop passes (`bundle exec rubocop`)
|
|
279
|
+
6. Commit your changes (`git commit -m 'Add amazing feature'`)
|
|
280
|
+
7. Push to the branch (`git push origin feature/amazing-feature`)
|
|
281
|
+
8. Open a Pull Request
|
|
282
|
+
|
|
283
|
+
### Development Setup
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
# Clone your fork
|
|
287
|
+
git clone https://github.com/YOUR_USERNAME/better_model.git
|
|
288
|
+
cd better_model
|
|
289
|
+
|
|
290
|
+
# Install dependencies
|
|
291
|
+
bundle install
|
|
292
|
+
|
|
293
|
+
# Run tests
|
|
294
|
+
bundle exec rake test
|
|
295
|
+
|
|
296
|
+
# Run SimpleCov for coverage
|
|
297
|
+
bundle exec rake test # Coverage report in coverage/index.html
|
|
298
|
+
|
|
299
|
+
# Run RuboCop
|
|
300
|
+
bundle exec rubocop
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### Code Guidelines
|
|
304
|
+
|
|
305
|
+
- Follow the existing code style (enforced by RuboCop Omakase)
|
|
306
|
+
- Write tests for new features
|
|
307
|
+
- Update documentation (README) for user-facing changes
|
|
308
|
+
- Keep pull requests focused (one feature/fix per PR)
|
|
309
|
+
|
|
310
|
+
## License
|
|
311
|
+
|
|
312
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Archivable - Sistema di archiviazione dichiarativa per modelli Rails
|
|
4
|
+
#
|
|
5
|
+
# Questo concern permette di archiviare e ripristinare record utilizzando un DSL
|
|
6
|
+
# semplice e dichiarativo, con supporto per predicati e scopes.
|
|
7
|
+
#
|
|
8
|
+
# SETUP RAPIDO:
|
|
9
|
+
# # Opzione 1: Generator automatico (raccomandato)
|
|
10
|
+
# rails g better_model:archivable Article --with-tracking
|
|
11
|
+
# rails db:migrate
|
|
12
|
+
#
|
|
13
|
+
# # Opzione 2: Migration manuale
|
|
14
|
+
# rails g migration AddArchivableToArticles archived_at:datetime archived_by_id:integer archive_reason:string
|
|
15
|
+
# rails db:migrate
|
|
16
|
+
#
|
|
17
|
+
# APPROCCIO OPT-IN: L'archiviazione non è attiva automaticamente. Devi chiamare
|
|
18
|
+
# esplicitamente `archivable do...end` nel tuo modello per attivarla.
|
|
19
|
+
#
|
|
20
|
+
# APPROCCIO IBRIDO: Usa i predicati esistenti (archived_at_present, archived_at_null, etc.)
|
|
21
|
+
# e fornisce alias semantici (archived, not_archived) per una migliore leggibilità.
|
|
22
|
+
#
|
|
23
|
+
# REQUISITI DATABASE:
|
|
24
|
+
# - archived_at (datetime) - REQUIRED (obbligatorio)
|
|
25
|
+
# - archived_by_id (integer) - OPTIONAL (per tracking utente)
|
|
26
|
+
# - archive_reason (string) - OPTIONAL (per motivazione)
|
|
27
|
+
#
|
|
28
|
+
# Esempio di utilizzo:
|
|
29
|
+
# class Article < ApplicationRecord
|
|
30
|
+
# include BetterModel
|
|
31
|
+
#
|
|
32
|
+
# # Attiva archivable (opt-in)
|
|
33
|
+
# archivable do
|
|
34
|
+
# skip_archived_by_default true # Opzionale: nascondi archiviati di default
|
|
35
|
+
# end
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# Utilizzo:
|
|
39
|
+
# article.archive! # Archive the record
|
|
40
|
+
# article.archive!(by: user) # Archive with user tracking
|
|
41
|
+
# article.archive!(reason: "Outdated") # Archive with reason
|
|
42
|
+
# article.restore! # Restore archived record
|
|
43
|
+
# article.archived? # Check if archived
|
|
44
|
+
# article.active? # Check if not archived
|
|
45
|
+
#
|
|
46
|
+
# # Scopes semantici
|
|
47
|
+
# Article.archived # Find archived records
|
|
48
|
+
# Article.not_archived # Find active records
|
|
49
|
+
# Article.archived_only # Bypass default scope
|
|
50
|
+
#
|
|
51
|
+
# # Predicati potenti (generati automaticamente)
|
|
52
|
+
# Article.archived_at_within(7.days) # Archived in last 7 days
|
|
53
|
+
# Article.archived_at_today # Archived today
|
|
54
|
+
# Article.archived_at_between(start, end) # Archived in range
|
|
55
|
+
#
|
|
56
|
+
# # Helper methods
|
|
57
|
+
# Article.archived_today # Alias for archived_at_today
|
|
58
|
+
# Article.archived_this_week # Alias for archived_at_this_week
|
|
59
|
+
# Article.archived_recently(7.days) # Alias for archived_at_within
|
|
60
|
+
#
|
|
61
|
+
# # Con Searchable
|
|
62
|
+
# Article.search({ archived_at_null: true, status_eq: "published" })
|
|
63
|
+
#
|
|
64
|
+
module BetterModel
|
|
65
|
+
module Archivable
|
|
66
|
+
extend ActiveSupport::Concern
|
|
67
|
+
|
|
68
|
+
included do
|
|
69
|
+
# Validazione ActiveRecord
|
|
70
|
+
unless ancestors.include?(ActiveRecord::Base)
|
|
71
|
+
raise ArgumentError, "BetterModel::Archivable can only be included in ActiveRecord models"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Configurazione archivable (opt-in)
|
|
75
|
+
class_attribute :archivable_enabled, default: false
|
|
76
|
+
class_attribute :archivable_config, default: {}.freeze
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class_methods do
|
|
80
|
+
# DSL per attivare e configurare archivable (OPT-IN)
|
|
81
|
+
#
|
|
82
|
+
# @example Attivazione base
|
|
83
|
+
# archivable
|
|
84
|
+
#
|
|
85
|
+
# @example Con configurazione
|
|
86
|
+
# archivable do
|
|
87
|
+
# skip_archived_by_default true
|
|
88
|
+
# end
|
|
89
|
+
def archivable(&block)
|
|
90
|
+
# Valida che archived_at esista
|
|
91
|
+
unless column_names.include?("archived_at")
|
|
92
|
+
raise ArgumentError,
|
|
93
|
+
"Archivable requires an 'archived_at' datetime column. " \
|
|
94
|
+
"Add it with: rails g migration AddArchivedAtTo#{table_name.classify.pluralize} archived_at:datetime"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Attiva archivable
|
|
98
|
+
self.archivable_enabled = true
|
|
99
|
+
|
|
100
|
+
# Definisci predicati su archived_at (opt-in!)
|
|
101
|
+
predicates :archived_at unless predicable_field?(:archived_at)
|
|
102
|
+
|
|
103
|
+
# Definisci anche sort su archived_at (opt-in!)
|
|
104
|
+
sort :archived_at unless sortable_field?(:archived_at)
|
|
105
|
+
|
|
106
|
+
# Definisci gli scope alias (approccio ibrido)
|
|
107
|
+
scope :archived, -> { archived_at_present }
|
|
108
|
+
scope :not_archived, -> { archived_at_null }
|
|
109
|
+
|
|
110
|
+
# Configura se passato un blocco
|
|
111
|
+
if block_given?
|
|
112
|
+
configurator = ArchivableConfigurator.new(self)
|
|
113
|
+
configurator.instance_eval(&block)
|
|
114
|
+
self.archivable_config = configurator.to_h.freeze
|
|
115
|
+
|
|
116
|
+
# Applica default scope SOLO se configurato
|
|
117
|
+
if archivable_config[:skip_archived_by_default]
|
|
118
|
+
default_scope -> { not_archived }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Trova SOLO record archiviati, bypassando default scope
|
|
124
|
+
#
|
|
125
|
+
# @return [ActiveRecord::Relation]
|
|
126
|
+
def archived_only
|
|
127
|
+
raise NotEnabledError unless archivable_enabled?
|
|
128
|
+
unscoped.archived
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Helper: alias per archived_at_today
|
|
132
|
+
def archived_today
|
|
133
|
+
raise NotEnabledError unless archivable_enabled?
|
|
134
|
+
archived_at_today
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Helper: alias per archived_at_this_week
|
|
138
|
+
def archived_this_week
|
|
139
|
+
raise NotEnabledError unless archivable_enabled?
|
|
140
|
+
archived_at_this_week
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Helper: alias per archived_at_within
|
|
144
|
+
#
|
|
145
|
+
# @param duration [ActiveSupport::Duration] Durata (es: 7.days)
|
|
146
|
+
# @return [ActiveRecord::Relation]
|
|
147
|
+
def archived_recently(duration = 7.days)
|
|
148
|
+
raise NotEnabledError unless archivable_enabled?
|
|
149
|
+
archived_at_within(duration)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Verifica se archivable è attivo
|
|
153
|
+
#
|
|
154
|
+
# @return [Boolean]
|
|
155
|
+
def archivable_enabled?
|
|
156
|
+
archivable_enabled == true
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Metodi di istanza
|
|
161
|
+
|
|
162
|
+
# Archivia il record
|
|
163
|
+
#
|
|
164
|
+
# @param by [Integer, Object] ID utente o oggetto user (opzionale)
|
|
165
|
+
# @param reason [String] Motivo dell'archiviazione (opzionale)
|
|
166
|
+
# @return [self]
|
|
167
|
+
# @raise [NotEnabledError] se archivable non è attivo
|
|
168
|
+
# @raise [AlreadyArchivedError] se già archiviato
|
|
169
|
+
def archive!(by: nil, reason: nil)
|
|
170
|
+
raise NotEnabledError unless self.class.archivable_enabled?
|
|
171
|
+
raise AlreadyArchivedError, "Record is already archived" if archived?
|
|
172
|
+
|
|
173
|
+
self.archived_at = Time.current
|
|
174
|
+
|
|
175
|
+
# Set archived_by_id: accetta sia ID che oggetti con .id
|
|
176
|
+
if respond_to?(:archived_by_id=) && by.present?
|
|
177
|
+
self.archived_by_id = by.respond_to?(:id) ? by.id : by
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
self.archive_reason = reason if respond_to?(:archive_reason=)
|
|
181
|
+
|
|
182
|
+
save!(validate: false)
|
|
183
|
+
self
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Ripristina record archiviato
|
|
187
|
+
#
|
|
188
|
+
# @return [self]
|
|
189
|
+
# @raise [NotEnabledError] se archivable non è attivo
|
|
190
|
+
# @raise [NotArchivedError] se non archiviato
|
|
191
|
+
def restore!
|
|
192
|
+
raise NotEnabledError unless self.class.archivable_enabled?
|
|
193
|
+
raise NotArchivedError, "Record is not archived" unless archived?
|
|
194
|
+
|
|
195
|
+
self.archived_at = nil
|
|
196
|
+
self.archived_by_id = nil if respond_to?(:archived_by_id=)
|
|
197
|
+
self.archive_reason = nil if respond_to?(:archive_reason=)
|
|
198
|
+
|
|
199
|
+
save!(validate: false)
|
|
200
|
+
self
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Verifica se il record è archiviato
|
|
204
|
+
#
|
|
205
|
+
# @return [Boolean]
|
|
206
|
+
def archived?
|
|
207
|
+
return false unless self.class.archivable_enabled?
|
|
208
|
+
archived_at.present?
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Verifica se il record è attivo (non archiviato)
|
|
212
|
+
#
|
|
213
|
+
# @return [Boolean]
|
|
214
|
+
def active?
|
|
215
|
+
!archived?
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Override as_json per includere info archivio
|
|
219
|
+
#
|
|
220
|
+
# @param options [Hash] Opzioni as_json
|
|
221
|
+
# @option options [Boolean] :include_archive_info Include archive metadata
|
|
222
|
+
# @return [Hash]
|
|
223
|
+
def as_json(options = {})
|
|
224
|
+
result = super
|
|
225
|
+
|
|
226
|
+
if options[:include_archive_info] && self.class.archivable_enabled?
|
|
227
|
+
result["archive_info"] = {
|
|
228
|
+
"archived" => archived?,
|
|
229
|
+
"archived_at" => archived_at,
|
|
230
|
+
"archived_by_id" => (respond_to?(:archived_by_id) ? archived_by_id : nil),
|
|
231
|
+
"archive_reason" => (respond_to?(:archive_reason) ? archive_reason : nil)
|
|
232
|
+
}
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
result
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Errori custom
|
|
240
|
+
class ArchivableError < StandardError; end
|
|
241
|
+
class AlreadyArchivedError < ArchivableError; end
|
|
242
|
+
class NotArchivedError < ArchivableError; end
|
|
243
|
+
|
|
244
|
+
class NotEnabledError < ArchivableError
|
|
245
|
+
def initialize(msg = nil)
|
|
246
|
+
super(msg || "Archivable is not enabled. Add 'archivable do...end' to your model.")
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Configurator per archivable DSL
|
|
251
|
+
class ArchivableConfigurator
|
|
252
|
+
attr_reader :config
|
|
253
|
+
|
|
254
|
+
def initialize(model_class)
|
|
255
|
+
@model_class = model_class
|
|
256
|
+
@config = {
|
|
257
|
+
skip_archived_by_default: false
|
|
258
|
+
}
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Configura default scope per nascondere archiviati
|
|
262
|
+
#
|
|
263
|
+
# @param value [Boolean] true per nascondere archiviati di default
|
|
264
|
+
def skip_archived_by_default(value)
|
|
265
|
+
@config[:skip_archived_by_default] = !!value
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def to_h
|
|
269
|
+
@config
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|