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.
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Sortable - Sistema di ordinamento dichiarativo per modelli Rails
4
+ #
5
+ # Questo concern permette di definire ordinamenti sui modelli utilizzando un DSL
6
+ # semplice e dichiarativo che genera automaticamente scope basati sul tipo di colonna.
7
+ #
8
+ # Esempio di utilizzo:
9
+ # class Article < ApplicationRecord
10
+ # include BetterModel::Sortable
11
+ #
12
+ # sort :title, :view_count, :published_at
13
+ # end
14
+ #
15
+ # Utilizzo:
16
+ # Article.sort_title_asc # ORDER BY title ASC
17
+ # Article.sort_title_desc_i # ORDER BY LOWER(title) DESC
18
+ # Article.sort_view_count_desc_nulls_last # ORDER BY view_count DESC NULLS LAST
19
+ # Article.sort_published_at_newest # ORDER BY published_at DESC
20
+ #
21
+ module BetterModel
22
+ module Sortable
23
+ extend ActiveSupport::Concern
24
+
25
+ included do
26
+ # Valida che sia incluso solo in modelli ActiveRecord
27
+ unless ancestors.include?(ActiveRecord::Base)
28
+ raise ArgumentError, "BetterModel::Sortable can only be included in ActiveRecord models"
29
+ end
30
+
31
+ # Registry dei campi sortable definiti per questa classe
32
+ class_attribute :sortable_fields, default: Set.new
33
+ # Registry degli scope sortable generati
34
+ class_attribute :sortable_scopes, default: Set.new
35
+ end
36
+
37
+ class_methods do
38
+ # DSL per definire campi sortable
39
+ #
40
+ # Genera automaticamente scope di ordinamento basati sul tipo di colonna:
41
+ # - String: _asc, _desc, _asc_i, _desc_i (case-insensitive)
42
+ # - Numeric: _asc, _desc, _asc_nulls_last, _desc_nulls_last, etc.
43
+ # - Date: _asc, _desc, _newest, _oldest
44
+ #
45
+ # Esempio:
46
+ # sort :title, :view_count, :published_at
47
+ def sort(*field_names)
48
+ field_names.each do |field_name|
49
+ validate_sortable_field!(field_name)
50
+ register_sortable_field(field_name)
51
+
52
+ # Auto-rileva tipo e genera scope appropriati
53
+ column = columns_hash[field_name.to_s]
54
+ next unless column
55
+
56
+ case column.type
57
+ when :string, :text
58
+ define_string_sorting(field_name)
59
+ when :integer, :decimal, :float, :bigint
60
+ define_numeric_sorting(field_name)
61
+ when :date, :datetime, :time, :timestamp
62
+ define_date_sorting(field_name)
63
+ else
64
+ # Default: genera solo scope base
65
+ define_base_sorting(field_name)
66
+ end
67
+ end
68
+ end
69
+
70
+ # Verifica se un campo è stato registrato come sortable
71
+ def sortable_field?(field_name)
72
+ sortable_fields.include?(field_name.to_sym)
73
+ end
74
+
75
+ # Verifica se uno scope sortable è stato generato
76
+ def sortable_scope?(scope_name)
77
+ sortable_scopes.include?(scope_name.to_sym)
78
+ end
79
+
80
+ private
81
+
82
+ # Valida che il campo esista nella tabella
83
+ def validate_sortable_field!(field_name)
84
+ unless column_names.include?(field_name.to_s)
85
+ raise ArgumentError, "Invalid field name: #{field_name}. Field does not exist in #{table_name}"
86
+ end
87
+ end
88
+
89
+ # Registra un campo nel registry sortable_fields
90
+ def register_sortable_field(field_name)
91
+ self.sortable_fields = (sortable_fields + [ field_name.to_sym ]).to_set.freeze
92
+ end
93
+
94
+ # Registra scope nel registry sortable_scopes
95
+ def register_sortable_scopes(*scope_names)
96
+ self.sortable_scopes = (sortable_scopes + scope_names.map(&:to_sym)).to_set.freeze
97
+ end
98
+
99
+ # Genera scope base: sort_field_asc e sort_field_desc
100
+ def define_base_sorting(field_name)
101
+ quoted_field = connection.quote_column_name(field_name)
102
+
103
+ scope :"sort_#{field_name}_asc", -> { order(Arel.sql("#{quoted_field} ASC")) }
104
+ scope :"sort_#{field_name}_desc", -> { order(Arel.sql("#{quoted_field} DESC")) }
105
+
106
+ register_sortable_scopes(
107
+ :"sort_#{field_name}_asc",
108
+ :"sort_#{field_name}_desc"
109
+ )
110
+ end
111
+
112
+ # Genera scope per campi stringa (include case-insensitive)
113
+ def define_string_sorting(field_name)
114
+ quoted_field = connection.quote_column_name(field_name)
115
+
116
+ # Scope base
117
+ scope :"sort_#{field_name}_asc", -> { order(Arel.sql("#{quoted_field} ASC")) }
118
+ scope :"sort_#{field_name}_desc", -> { order(Arel.sql("#{quoted_field} DESC")) }
119
+
120
+ # Scope case-insensitive
121
+ scope :"sort_#{field_name}_asc_i", -> { order(Arel.sql("LOWER(#{quoted_field}) ASC")) }
122
+ scope :"sort_#{field_name}_desc_i", -> { order(Arel.sql("LOWER(#{quoted_field}) DESC")) }
123
+
124
+ register_sortable_scopes(
125
+ :"sort_#{field_name}_asc",
126
+ :"sort_#{field_name}_desc",
127
+ :"sort_#{field_name}_asc_i",
128
+ :"sort_#{field_name}_desc_i"
129
+ )
130
+ end
131
+
132
+ # Genera scope per campi numerici (include gestione NULL)
133
+ def define_numeric_sorting(field_name)
134
+ quoted_field = connection.quote_column_name(field_name)
135
+
136
+ # Scope base
137
+ scope :"sort_#{field_name}_asc", -> { order(Arel.sql("#{quoted_field} ASC")) }
138
+ scope :"sort_#{field_name}_desc", -> { order(Arel.sql("#{quoted_field} DESC")) }
139
+
140
+ # Pre-calcola SQL per gestione NULL (necessario perché lo scope non ha accesso ai metodi privati)
141
+ sql_asc_nulls_last = nulls_order_sql(field_name, "ASC", "LAST")
142
+ sql_desc_nulls_last = nulls_order_sql(field_name, "DESC", "LAST")
143
+ sql_asc_nulls_first = nulls_order_sql(field_name, "ASC", "FIRST")
144
+ sql_desc_nulls_first = nulls_order_sql(field_name, "DESC", "FIRST")
145
+
146
+ # Scope con gestione NULL
147
+ scope :"sort_#{field_name}_asc_nulls_last", -> {
148
+ order(Arel.sql(sql_asc_nulls_last))
149
+ }
150
+ scope :"sort_#{field_name}_desc_nulls_last", -> {
151
+ order(Arel.sql(sql_desc_nulls_last))
152
+ }
153
+ scope :"sort_#{field_name}_asc_nulls_first", -> {
154
+ order(Arel.sql(sql_asc_nulls_first))
155
+ }
156
+ scope :"sort_#{field_name}_desc_nulls_first", -> {
157
+ order(Arel.sql(sql_desc_nulls_first))
158
+ }
159
+
160
+ register_sortable_scopes(
161
+ :"sort_#{field_name}_asc",
162
+ :"sort_#{field_name}_desc",
163
+ :"sort_#{field_name}_asc_nulls_last",
164
+ :"sort_#{field_name}_desc_nulls_last",
165
+ :"sort_#{field_name}_asc_nulls_first",
166
+ :"sort_#{field_name}_desc_nulls_first"
167
+ )
168
+ end
169
+
170
+ # Genera scope per campi data/datetime (include shortcuts semantici)
171
+ def define_date_sorting(field_name)
172
+ quoted_field = connection.quote_column_name(field_name)
173
+
174
+ # Scope base
175
+ scope :"sort_#{field_name}_asc", -> { order(Arel.sql("#{quoted_field} ASC")) }
176
+ scope :"sort_#{field_name}_desc", -> { order(Arel.sql("#{quoted_field} DESC")) }
177
+
178
+ # Shortcuts semantici
179
+ scope :"sort_#{field_name}_newest", -> { order(Arel.sql("#{quoted_field} DESC")) }
180
+ scope :"sort_#{field_name}_oldest", -> { order(Arel.sql("#{quoted_field} ASC")) }
181
+
182
+ register_sortable_scopes(
183
+ :"sort_#{field_name}_asc",
184
+ :"sort_#{field_name}_desc",
185
+ :"sort_#{field_name}_newest",
186
+ :"sort_#{field_name}_oldest"
187
+ )
188
+ end
189
+
190
+ # Genera SQL per gestione NULL multi-database
191
+ def nulls_order_sql(field_name, direction, nulls_position)
192
+ quoted_field = connection.quote_column_name(field_name)
193
+
194
+ # PostgreSQL e SQLite 3.30+ supportano NULLS LAST/FIRST nativamente
195
+ if connection.adapter_name.match?(/PostgreSQL|SQLite/)
196
+ "#{quoted_field} #{direction} NULLS #{nulls_position}"
197
+ else
198
+ # MySQL/MariaDB: emulazione con CASE
199
+ if nulls_position == "LAST"
200
+ "CASE WHEN #{quoted_field} IS NULL THEN 1 ELSE 0 END, #{quoted_field} #{direction}"
201
+ else # FIRST
202
+ "CASE WHEN #{quoted_field} IS NULL THEN 0 ELSE 1 END, #{quoted_field} #{direction}"
203
+ end
204
+ end
205
+ end
206
+ end
207
+
208
+ # Metodi di istanza
209
+
210
+ # Ritorna lista di attributi sortable (esclude campi sensibili)
211
+ def sortable_attributes
212
+ self.class.column_names.reject do |attr|
213
+ attr.start_with?("password", "encrypted_")
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Statusable - Sistema di stati dichiarativi per modelli Rails
4
+ #
5
+ # Questo concern permette di definire stati sui modelli utilizzando un DSL
6
+ # semplice e dichiarativo, simile al pattern Enrichable ma per gli stati.
7
+ #
8
+ # Esempio di utilizzo:
9
+ # class Communications::Consult < ApplicationRecord
10
+ # include BetterModel::Statusable
11
+ #
12
+ # is :pending, -> { status == 'initialized' }
13
+ # is :active_session, -> { status == 'active' && !expired? }
14
+ # is :expired, -> { expires_at.present? && expires_at <= Time.current }
15
+ # is :scheduled, -> { scheduled_at.present? }
16
+ # is :immediate, -> { scheduled_at.blank? }
17
+ # is :ready_to_start, -> { scheduled? && scheduled_at <= Time.current }
18
+ # end
19
+ #
20
+ # Utilizzo:
21
+ # consult.is?(:pending) # => true/false
22
+ # consult.is_pending? # => true/false
23
+ # consult.is_active_session? # => true/false
24
+ # consult.is_expired? # => true/false
25
+ # consult.is_scheduled? # => true/false
26
+ #
27
+ module BetterModel
28
+ module Statusable
29
+ extend ActiveSupport::Concern
30
+
31
+ included do
32
+ # Registry degli stati definiti per questa classe
33
+ class_attribute :is_definitions
34
+ self.is_definitions = {}
35
+ end
36
+
37
+ class_methods do
38
+ # DSL per definire stati
39
+ #
40
+ # Parametri:
41
+ # - status_name: simbolo che rappresenta lo stato (es. :pending, :active)
42
+ # - condition_proc: lambda o proc che definisce la condizione
43
+ # - block: blocco alternativo alla condition_proc
44
+ #
45
+ # Esempi:
46
+ # is :pending, -> { status == 'initialized' }
47
+ # is :expired, -> { expires_at.present? && expires_at <= Time.current }
48
+ # is :ready do
49
+ # scheduled_at.present? && scheduled_at <= Time.current
50
+ # end
51
+ def is(status_name, condition_proc = nil, &block)
52
+ # Valida i parametri prima di convertire
53
+ raise ArgumentError, "Status name cannot be blank" if status_name.blank?
54
+
55
+ status_name = status_name.to_sym
56
+ condition = condition_proc || block
57
+ raise ArgumentError, "Condition proc or block is required" unless condition
58
+ raise ArgumentError, "Condition must respond to call" unless condition.respond_to?(:call)
59
+
60
+ # Registra lo stato nel registry
61
+ self.is_definitions = is_definitions.merge(status_name => condition.freeze).freeze
62
+
63
+ # Genera il metodo dinamico is_#{status_name}?
64
+ define_is_method(status_name)
65
+ end
66
+
67
+ # Lista di tutti gli stati definiti per questa classe
68
+ def defined_statuses
69
+ is_definitions.keys
70
+ end
71
+
72
+ # Verifica se uno stato è definito
73
+ def status_defined?(status_name)
74
+ is_definitions.key?(status_name.to_sym)
75
+ end
76
+
77
+ private
78
+
79
+ # Genera dinamicamente il metodo is_#{status_name}? per ogni stato definito
80
+ def define_is_method(status_name)
81
+ method_name = "is_#{status_name}?"
82
+
83
+ # Evita di ridefinire metodi se già esistono
84
+ return if method_defined?(method_name)
85
+
86
+ define_method(method_name) do
87
+ is?(status_name)
88
+ end
89
+ end
90
+ end
91
+
92
+ # Metodo generico per verificare se uno stato è attivo
93
+ #
94
+ # Parametri:
95
+ # - status_name: simbolo dello stato da verificare
96
+ #
97
+ # Ritorna:
98
+ # - true se lo stato è attivo
99
+ # - false se lo stato non è attivo o non è definito
100
+ #
101
+ # Esempio:
102
+ # consult.is?(:pending)
103
+ def is?(status_name)
104
+ status_name = status_name.to_sym
105
+ condition = self.class.is_definitions[status_name]
106
+
107
+ # Se lo stato non è definito, ritorna false (secure by default)
108
+ return false unless condition
109
+
110
+ # Valuta la condizione nel contesto dell'istanza del modello
111
+ # Gli errori si propagano naturalmente - fail fast
112
+ instance_exec(&condition)
113
+ end
114
+
115
+ # Ritorna tutti gli stati disponibili per questa istanza con i loro valori
116
+ #
117
+ # Ritorna:
118
+ # - Hash con chiavi simbolo (stati) e valori booleani (attivi/inattivi)
119
+ #
120
+ # Esempio:
121
+ # consult.statuses
122
+ # # => { pending: true, active: false, expired: false, scheduled: true }
123
+ def statuses
124
+ self.class.is_definitions.each_with_object({}) do |(status_name, _condition), result|
125
+ result[status_name] = is?(status_name)
126
+ end
127
+ end
128
+
129
+ # Verifica se l'istanza ha almeno uno stato attivo
130
+ def has_any_status?
131
+ statuses.values.any?
132
+ end
133
+
134
+ # Verifica se l'istanza ha tutti gli stati specificati attivi
135
+ def has_all_statuses?(status_names)
136
+ Array(status_names).all? { |status_name| is?(status_name) }
137
+ end
138
+
139
+ # Filtra una lista di stati restituendo solo quelli attivi
140
+ def active_statuses(status_names)
141
+ Array(status_names).select { |status_name| is?(status_name) }
142
+ end
143
+
144
+ # Override di as_json per includere automaticamente gli stati se richiesto
145
+ def as_json(options = {})
146
+ result = super
147
+
148
+ # Include gli stati se esplicitamente richiesto, converting symbol keys to strings
149
+ result["statuses"] = statuses.transform_keys(&:to_s) if options[:include_statuses]
150
+
151
+ result
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,3 @@
1
+ module BetterModel
2
+ VERSION = "1.0.0"
3
+ end
@@ -0,0 +1,24 @@
1
+ require "better_model/version"
2
+ require "better_model/railtie"
3
+ require "better_model/statusable"
4
+ require "better_model/permissible"
5
+ require "better_model/sortable"
6
+ require "better_model/predicable"
7
+ require "better_model/searchable"
8
+ require "better_model/archivable"
9
+
10
+ module BetterModel
11
+ extend ActiveSupport::Concern
12
+
13
+ # When BetterModel is included, automatically include all sub-concerns
14
+ included do
15
+ include BetterModel::Statusable
16
+ include BetterModel::Permissible
17
+ include BetterModel::Sortable
18
+ include BetterModel::Predicable
19
+ include BetterModel::Searchable
20
+ include BetterModel::Archivable
21
+ # Future concerns will be added here:
22
+ # include BetterModel::Validatable
23
+ end
24
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+
6
+ module BetterModel
7
+ module Generators
8
+ class ArchivableGenerator < Rails::Generators::NamedBase
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ class_option :with_tracking, type: :boolean, default: false,
14
+ desc: "Add archived_by_id and archive_reason columns"
15
+ class_option :with_by, type: :boolean, default: false,
16
+ desc: "Add archived_by_id column only"
17
+ class_option :with_reason, type: :boolean, default: false,
18
+ desc: "Add archive_reason column only"
19
+ class_option :skip_indexes, type: :boolean, default: false,
20
+ desc: "Skip adding indexes"
21
+
22
+ def self.next_migration_number(dirname)
23
+ next_migration_number = current_migration_number(dirname) + 1
24
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
25
+ end
26
+
27
+ def create_migration_file
28
+ migration_template "migration.rb.tt",
29
+ "db/migrate/add_archivable_to_#{table_name}.rb"
30
+ end
31
+
32
+ private
33
+
34
+ def table_name
35
+ name.tableize
36
+ end
37
+
38
+ def migration_class_name
39
+ "AddArchivableTo#{name.camelize.pluralize}"
40
+ end
41
+
42
+ def with_by_column?
43
+ options[:with_tracking] || options[:with_by]
44
+ end
45
+
46
+ def with_reason_column?
47
+ options[:with_tracking] || options[:with_reason]
48
+ end
49
+
50
+ def add_indexes?
51
+ !options[:skip_indexes]
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,26 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ change_table :<%= table_name %> do |t|
4
+ # Required column for archivable
5
+ t.datetime :archived_at
6
+ <% if with_by_column? -%>
7
+
8
+ # Optional: track who archived the record
9
+ t.integer :archived_by_id
10
+ <% end -%>
11
+ <% if with_reason_column? -%>
12
+
13
+ # Optional: track why the record was archived
14
+ t.string :archive_reason
15
+ <% end -%>
16
+ end
17
+ <% if add_indexes? -%>
18
+
19
+ # Indexes for performance
20
+ add_index :<%= table_name %>, :archived_at
21
+ <% if with_by_column? -%>
22
+ add_index :<%= table_name %>, :archived_by_id
23
+ <% end -%>
24
+ <% end -%>
25
+ end
26
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :better_model do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: better_model
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - alessiobussolari
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-10-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 8.1.0
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: 8.1.0
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9.0'
33
+ description: BetterModel is a Rails engine gem (Rails 8.1+) that provides powerful
34
+ extensions for ActiveRecord models including declarative status management and more.
35
+ email:
36
+ - alessio@cosmic.tech
37
+ executables: []
38
+ extensions: []
39
+ extra_rdoc_files: []
40
+ files:
41
+ - MIT-LICENSE
42
+ - README.md
43
+ - Rakefile
44
+ - lib/better_model.rb
45
+ - lib/better_model/archivable.rb
46
+ - lib/better_model/permissible.rb
47
+ - lib/better_model/predicable.rb
48
+ - lib/better_model/railtie.rb
49
+ - lib/better_model/searchable.rb
50
+ - lib/better_model/sortable.rb
51
+ - lib/better_model/statusable.rb
52
+ - lib/better_model/version.rb
53
+ - lib/generators/better_model/archivable/archivable_generator.rb
54
+ - lib/generators/better_model/archivable/templates/migration.rb.tt
55
+ - lib/tasks/better_model_tasks.rake
56
+ homepage: https://github.com/alessiobussolari/better_model
57
+ licenses:
58
+ - MIT
59
+ metadata:
60
+ allowed_push_host: https://rubygems.org
61
+ source_code_uri: https://github.com/alessiobussolari/better_model
62
+ changelog_uri: https://github.com/alessiobussolari/better_model/blob/main/CHANGELOG.md
63
+ post_install_message:
64
+ rdoc_options: []
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: 3.0.0
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ requirements: []
78
+ rubygems_version: 3.5.11
79
+ signing_key:
80
+ specification_version: 4
81
+ summary: Rails engine gem that extends ActiveRecord model functionality
82
+ test_files: []