better_model 1.2.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.
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterModel
4
+ module Schedulable
5
+ # ScheduleBuilder - DSL per costruire configurazioni di schedule
6
+ #
7
+ # Questo builder implementa il DSL fluent per definire pattern di ricorrenza,
8
+ # eccezioni, e vincoli temporali.
9
+ #
10
+ # Esempio:
11
+ # builder = ScheduleBuilder.new(event)
12
+ # builder.recurs(:daily) do
13
+ # at ['09:00']
14
+ # except weekday: [:saturday]
15
+ # end
16
+ #
17
+ class ScheduleBuilder
18
+ attr_reader :record, :config
19
+
20
+ VALID_FREQUENCIES = %w[
21
+ daily every_n_days
22
+ weekly biweekly every_n_weeks
23
+ monthly every_n_months
24
+ yearly every_n_years
25
+ every_n_hours every_n_minutes
26
+ ].freeze
27
+
28
+ def initialize(record)
29
+ @record = record
30
+ @config = {}
31
+ @recurrence_builder = nil
32
+ end
33
+
34
+ # Definisce un pattern di ricorrenza
35
+ #
36
+ # @param frequency [Symbol] :daily, :weekly, :monthly, :yearly, :every_n_days, etc.
37
+ # @yield [builder] Blocco per configurare il pattern
38
+ def recurs(frequency, &block)
39
+ freq_str = frequency.to_s
40
+ unless VALID_FREQUENCIES.include?(freq_str)
41
+ raise ArgumentError, "Invalid frequency: '#{frequency}'. Valid options: #{VALID_FREQUENCIES.join(', ')}"
42
+ end
43
+
44
+ @config[:frequency] = freq_str
45
+
46
+ # Crea builder per la ricorrenza specifica
47
+ @recurrence_builder = RecurrenceBuilder.new(@config)
48
+
49
+ # Supporta due stili:
50
+ # 1. Con parametro: s.recurs(:daily) {|r| r.at [...] }
51
+ # 2. Senza parametro: s.recurs(:daily) { at [...] }
52
+ if block_given?
53
+ # DEBUG
54
+ # puts "Block arity: #{block.arity}"
55
+
56
+ if block.arity.zero?
57
+ # Nessun parametro → usa instance_eval
58
+ @recurrence_builder.instance_eval(&block)
59
+ else
60
+ # Con parametro → yield normale
61
+ yield @recurrence_builder
62
+ end
63
+ end
64
+
65
+ self
66
+ end
67
+
68
+ # Converte in hash per serializzazione
69
+ def to_h
70
+ @config
71
+ end
72
+
73
+ # RecurrenceBuilder - Builder interno per pattern di ricorrenza
74
+ class RecurrenceBuilder
75
+ VALID_WEEKDAYS = %w[monday tuesday wednesday thursday friday saturday sunday].freeze
76
+
77
+ def initialize(config)
78
+ @config = config
79
+ end
80
+
81
+ # Imposta gli orari
82
+ # @param times [Array<String>] Array di orari tipo ['09:00', '15:00']
83
+ def at(times)
84
+ times_array = Array(times)
85
+ times_array.each do |time_str|
86
+ unless valid_time_format?(time_str)
87
+ raise ArgumentError, "Invalid time format: '#{time_str}'. Expected HH:MM (00:00-23:59)"
88
+ end
89
+ end
90
+ @config[:time] = times_array
91
+ end
92
+
93
+ # Imposta i vincoli sui giorni
94
+ # @option options [Array<Symbol>] :weekday Giorni della settimana [:monday, :friday]
95
+ # @option options [Array<Integer>] :day Giorni del mese [1, 15, -1]
96
+ # @option options [Array<String>] :date Date annuali ['03-15', '12-25']
97
+ # @option options [Integer] :occurrence N-esimo giorno (per monthly)
98
+ def on(**options)
99
+ if options[:weekday]
100
+ weekdays = Array(options[:weekday]).map(&:to_s)
101
+ invalid = weekdays - VALID_WEEKDAYS
102
+
103
+ if invalid.any?
104
+ raise ArgumentError, "Invalid weekdays: #{invalid.join(', ')}. Valid options: #{VALID_WEEKDAYS.join(', ')}"
105
+ end
106
+
107
+ @config[:weekday] = weekdays
108
+
109
+ # Se c'è occurrence, è monthly con N-esimo weekday
110
+ if options[:occurrence]
111
+ @config[:occurrence] = options[:occurrence]
112
+ end
113
+ end
114
+
115
+ if options[:day]
116
+ @config[:day] = Array(options[:day])
117
+ end
118
+
119
+ if options[:date]
120
+ dates = Array(options[:date])
121
+ dates.each do |date_str|
122
+ unless valid_date_format?(date_str)
123
+ raise ArgumentError, "Invalid date format: '#{date_str}'. Expected MM-DD (e.g., '03-15', '12-25')"
124
+ end
125
+ end
126
+ @config[:date] = dates
127
+ end
128
+ end
129
+
130
+ # Imposta intervallo (per every_n_days, every_n_weeks, etc.)
131
+ # @param value [Integer] Numero di giorni/settimane/mesi/ore/minuti
132
+ def interval(value)
133
+ int_value = value.to_i
134
+ raise ArgumentError, "Interval must be a positive integer, got: #{value}" if int_value <= 0
135
+ @config[:interval] = int_value
136
+ end
137
+
138
+ # Imposta range temporale
139
+ # @param start_time [String, Time, Array] Inizio (o array di time range)
140
+ # @param end_time [String, Time] Fine
141
+ def between(start_time, end_time = nil)
142
+ if end_time
143
+ # Range orario o date range
144
+ @config[:between] = {
145
+ start: start_time.is_a?(String) ? start_time : start_time.iso8601,
146
+ end: end_time.is_a?(String) ? end_time : end_time.iso8601
147
+ }
148
+ else
149
+ # Solo orario range (array)
150
+ if start_time.is_a?(Array)
151
+ # Validate time format for array ranges
152
+ start_time.each do |time_str|
153
+ next unless time_str.is_a?(String)
154
+ unless valid_time_format?(time_str)
155
+ raise ArgumentError, "Invalid time format in between range: '#{time_str}'. Expected HH:MM (00:00-23:59)"
156
+ end
157
+ end
158
+ end
159
+ @config[:between] = start_time
160
+ end
161
+ end
162
+
163
+ # Imposta durata evento
164
+ # @param duration [ActiveSupport::Duration] Durata
165
+ def duration(duration)
166
+ @config[:duration] = duration.to_i # Salva in secondi
167
+ end
168
+
169
+ # Imposta data di inizio
170
+ # @param date [String, Date] Data di inizio
171
+ def starting_from(date)
172
+ date_str = date.is_a?(String) ? date : date.to_s
173
+
174
+ # Validate date format
175
+ begin
176
+ Date.parse(date_str) if date_str.is_a?(String)
177
+ rescue ArgumentError => e
178
+ raise ArgumentError, "Invalid starting_from date format: '#{date_str}'. #{e.message}"
179
+ end
180
+
181
+ @config[:starting_from] = date_str
182
+ end
183
+
184
+ # Imposta data di fine
185
+ # @param date [String, Date] Data di fine
186
+ def ending_at(date)
187
+ @config[:ending_at] = date.is_a?(String) ? date : date.to_s
188
+ end
189
+
190
+ # Alias per ending_at
191
+ def for_duration(duration)
192
+ starting = @config[:starting_from] || Time.current.to_date.to_s
193
+ ending = (Date.parse(starting) + duration).to_s
194
+ ending_at(ending)
195
+ end
196
+
197
+ # Imposta minuti specifici (per hourly)
198
+ # @param minutes [Array<Integer>] Minuti [0, 30]
199
+ def at_minutes(minutes)
200
+ @config[:at_minutes] = Array(minutes)
201
+ end
202
+
203
+ # Imposta reminder anticipato
204
+ # @param advance [Integer, Array<Integer>] Giorni prima (negativi)
205
+ def advance_by(advance)
206
+ @config[:advance_by] = Array(advance).map { |v| v.is_a?(Integer) ? v : v.to_i }
207
+ end
208
+
209
+ # Imposta eccezioni
210
+ # @option options [Array<Symbol>] :weekday Giorni da escludere
211
+ # @option options [Array<String>] :date Date da escludere
212
+ # @option options [Array<Integer>] :month Mesi da escludere
213
+ # @option options [Hash] :pattern Pattern complessi
214
+ def except(**options)
215
+ @config[:exceptions] ||= {}
216
+
217
+ if options[:weekday]
218
+ @config[:exceptions][:weekdays] ||= []
219
+ @config[:exceptions][:weekdays] += Array(options[:weekday]).map(&:to_s)
220
+ @config[:exceptions][:weekdays].uniq!
221
+ end
222
+
223
+ if options[:date]
224
+ @config[:exceptions][:dates] ||= []
225
+ @config[:exceptions][:dates] += Array(options[:date])
226
+ @config[:exceptions][:dates].uniq!
227
+ end
228
+
229
+ if options[:month]
230
+ @config[:exceptions][:months] ||= []
231
+ @config[:exceptions][:months] += Array(options[:month])
232
+ @config[:exceptions][:months].uniq!
233
+ end
234
+
235
+ if options[:pattern]
236
+ @config[:exceptions][:patterns] ||= []
237
+ @config[:exceptions][:patterns] << options[:pattern]
238
+ end
239
+ end
240
+
241
+ private
242
+
243
+ # Valida formato time HH:MM
244
+ def valid_time_format?(time_str)
245
+ return false unless time_str.is_a?(String)
246
+ return false unless time_str.match?(/^\d{1,2}:\d{2}$/)
247
+
248
+ hour, minute = time_str.split(":").map(&:to_i)
249
+ hour.between?(0, 23) && minute.between?(0, 59)
250
+ end
251
+
252
+ # Valida formato date MM-DD
253
+ def valid_date_format?(date_str)
254
+ return false unless date_str.is_a?(String)
255
+ return false unless date_str.match?(/^\d{1,2}-\d{1,2}$/)
256
+
257
+ month, day = date_str.split("-").map(&:to_i)
258
+ return false unless month.between?(1, 12)
259
+
260
+ # Usa anno bisestile (2024) per validare giorni in febbraio
261
+ Date.new(2024, month, day)
262
+ true
263
+ rescue ArgumentError
264
+ false
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,356 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Schedulable - Sistema di schedulazione ricorrente per modelli Rails
4
+ #
5
+ # Questo concern permette di definire schedule ricorrenti con pattern complessi,
6
+ # eccezioni, e calcolo automatico delle occorrenze.
7
+ #
8
+ # APPROCCIO OPT-IN: La schedulazione non è attiva automaticamente.
9
+ # Devi chiamare esplicitamente `schedulable do...end` nel tuo modello.
10
+ #
11
+ # REQUISITI DATABASE:
12
+ # - Colonna per salvare la configurazione schedule (JSONB o TEXT con serialize)
13
+ # - Colonna opzionale per timezone per-record
14
+ # - Colonna opzionale per cache next_occurrence_at
15
+ #
16
+ # Esempio di utilizzo:
17
+ # class Event < ApplicationRecord
18
+ # include BetterModel
19
+ #
20
+ # serialize :schedule_config, coder: JSON, type: Hash
21
+ #
22
+ # schedulable do
23
+ # schedule_field :schedule_config
24
+ # timezone_field :timezone
25
+ # end
26
+ # end
27
+ #
28
+ # # Per-record scheduling
29
+ # event = Event.create!(name: "Stand-up Meeting")
30
+ # event.schedule do |s|
31
+ # s.recurs :weekly do
32
+ # s.on weekday: [:monday]
33
+ # s.at ['09:00']
34
+ # end
35
+ # end
36
+ #
37
+ # event.next_occurrence # => 2025-11-10 09:00:00 +0100
38
+ # event.due_now? # => true/false
39
+ # Event.due_now # => [events due now]
40
+ #
41
+ module BetterModel
42
+ module Schedulable
43
+ extend ActiveSupport::Concern
44
+
45
+ included do
46
+ # Validazione ActiveRecord
47
+ unless ancestors.include?(ActiveRecord::Base)
48
+ raise ArgumentError, "BetterModel::Schedulable can only be included in ActiveRecord models"
49
+ end
50
+
51
+ # Configurazione schedulable (opt-in)
52
+ class_attribute :schedulable_enabled, default: false
53
+ class_attribute :schedulable_config, default: nil
54
+
55
+ # Auto-update next_occurrence_at when schedule changes
56
+ after_save :auto_update_next_occurrence, if: :schedulable_enabled?
57
+ end
58
+
59
+ class_methods do
60
+ # DSL per attivare e configurare schedulable (OPT-IN)
61
+ #
62
+ # @example Attivazione base
63
+ # schedulable do
64
+ # schedule_field :schedule_config
65
+ # timezone_field :timezone
66
+ # end
67
+ #
68
+ def schedulable(&block)
69
+ # Attiva schedulable
70
+ self.schedulable_enabled = true
71
+
72
+ # Configura se passato un blocco
73
+ if block_given?
74
+ config = Configuration.new
75
+ config.instance_eval(&block)
76
+ self.schedulable_config = config.freeze
77
+ end
78
+
79
+ # Valida configurazione
80
+ validate_schedulable_config!
81
+ end
82
+
83
+ # Verifica se schedulable è attivo
84
+ def schedulable_enabled?
85
+ schedulable_enabled == true
86
+ end
87
+
88
+ # Trova tutti i record dovuti ora
89
+ #
90
+ # @param tolerance [ActiveSupport::Duration] Finestra di tolleranza
91
+ # @return [Array] Array of records (not ActiveRecord::Relation for now)
92
+ def due_now(tolerance: 5.minutes)
93
+ return [] unless schedulable_enabled?
94
+
95
+ # For now, use in-memory filtering to ensure accuracy with travel_to
96
+ # TODO: Optimize with next_occurrence_at cache for production
97
+ all.select { |record| record.due_now?(tolerance: tolerance) }
98
+ end
99
+
100
+ # Trova i prossimi N eventi in programma
101
+ #
102
+ # @param limit [Integer] Numero massimo di risultati
103
+ # @return [Array] Array of records sorted by next_occurrence_at
104
+ def upcoming(limit: 10)
105
+ return [] unless schedulable_enabled?
106
+
107
+ results = all.map do |record|
108
+ next_occ = record.next_occurrence
109
+ next unless next_occ
110
+
111
+ { event: record, occurrence: next_occ }
112
+ end.compact
113
+
114
+ results.sort_by { |r| r[:occurrence] }.first(limit)
115
+ end
116
+
117
+ # Trova eventi in ritardo (oltre grace period)
118
+ #
119
+ # @param grace_period [ActiveSupport::Duration] Periodo di grazia
120
+ # @return [Array]
121
+ def overdue(grace_period: 1.hour)
122
+ return [] unless schedulable_enabled?
123
+
124
+ # For now, use in-memory filtering to ensure accuracy with travel_to
125
+ # TODO: Optimize with next_occurrence_at cache for production
126
+ now = Time.current
127
+
128
+ all.select do |record|
129
+ # Get the last occurrence that should have happened
130
+ last_occ = record.previous_occurrence(before: now)
131
+ next unless last_occ
132
+
133
+ # Also get the next upcoming occurrence
134
+ next_occ = record.next_occurrence(after: now)
135
+ next unless next_occ
136
+
137
+ # Overdue if:
138
+ # 1. Last occurrence is beyond grace period
139
+ # 2. Last occurrence is closer to now than next occurrence (it was "recent")
140
+ past_dist = now - last_occ
141
+ future_dist = next_occ - now
142
+
143
+ last_occ < (now - grace_period) && past_dist < future_dist
144
+ end
145
+ end
146
+
147
+ private
148
+
149
+ def validate_schedulable_config!
150
+ config = schedulable_config
151
+ return unless config
152
+
153
+ # Valida che i campi esistano
154
+ schedule_field = config.schedule_field
155
+ unless column_names.include?(schedule_field.to_s)
156
+ raise ArgumentError, "Schedule field #{schedule_field} does not exist in #{table_name}"
157
+ end
158
+
159
+ if config.timezone_field
160
+ unless column_names.include?(config.timezone_field.to_s)
161
+ raise ArgumentError, "Timezone field #{config.timezone_field} does not exist in #{table_name}"
162
+ end
163
+ end
164
+ end
165
+ end
166
+
167
+ # ============================================================================
168
+ # INSTANCE METHODS
169
+ # ============================================================================
170
+
171
+ # DSL per configurare lo schedule del record
172
+ #
173
+ # @example
174
+ # event.schedule do |s|
175
+ # s.recurs :daily do
176
+ # s.at ['09:00']
177
+ # end
178
+ # end
179
+ #
180
+ def schedule(&block)
181
+ raise SchedulableNotEnabledError unless self.class.schedulable_enabled?
182
+
183
+ builder = ScheduleBuilder.new(self)
184
+ yield builder if block_given?
185
+
186
+ # Salva configurazione
187
+ schedule_field = self.class.schedulable_config.schedule_field
188
+ self[schedule_field] = builder.to_h
189
+
190
+ if persisted?
191
+ save!
192
+ update_next_occurrence!
193
+ end
194
+ end
195
+
196
+ # Calcola la prossima occorrenza dopo un momento specifico
197
+ #
198
+ # @param after [Time] Momento di riferimento (default: Time.current)
199
+ # @return [Time, nil]
200
+ def next_occurrence(after: Time.current)
201
+ raise SchedulableNotEnabledError unless self.class.schedulable_enabled?
202
+
203
+ schedule_config = get_schedule_config
204
+ return nil if schedule_config.blank?
205
+
206
+ calculator = OccurrenceCalculator.new(schedule_config, record_timezone)
207
+ calculator.next_occurrence(after: after)
208
+ end
209
+
210
+ # Calcola l'occorrenza precedente prima di un momento specifico
211
+ #
212
+ # @param before [Time] Momento di riferimento (default: Time.current)
213
+ # @return [Time, nil]
214
+ def previous_occurrence(before: Time.current)
215
+ raise SchedulableNotEnabledError unless self.class.schedulable_enabled?
216
+
217
+ schedule_config = get_schedule_config
218
+ return nil if schedule_config.blank?
219
+
220
+ calculator = OccurrenceCalculator.new(schedule_config, record_timezone)
221
+ calculator.previous_occurrence(before: before)
222
+ end
223
+
224
+ # Genera array di occorrenze in un range temporale
225
+ #
226
+ # @param start_time [Time] Inizio range
227
+ # @param end_time [Time] Fine range
228
+ # @param limit [Integer] Limite numero occorrenze
229
+ # @return [Array<Time>]
230
+ def occurrences_between(start_time, end_time, limit: 100)
231
+ raise SchedulableNotEnabledError unless self.class.schedulable_enabled?
232
+
233
+ schedule_config = get_schedule_config
234
+ return [] if schedule_config.blank?
235
+
236
+ calculator = OccurrenceCalculator.new(schedule_config, record_timezone)
237
+ calculator.occurrences_between(start_time, end_time, limit: limit)
238
+ end
239
+
240
+ # Verifica se l'evento occorre in un momento specifico
241
+ #
242
+ # @param time [Time] Momento da verificare
243
+ # @return [Boolean]
244
+ def occurs_at?(time)
245
+ raise SchedulableNotEnabledError unless self.class.schedulable_enabled?
246
+
247
+ schedule_config = get_schedule_config
248
+ return false if schedule_config.blank?
249
+
250
+ calculator = OccurrenceCalculator.new(schedule_config, record_timezone)
251
+ calculator.occurs_at?(time)
252
+ end
253
+
254
+ # Verifica se l'evento è dovuto ora (entro finestra di tolleranza)
255
+ #
256
+ # @param tolerance [ActiveSupport::Duration] Finestra di tolleranza
257
+ # @return [Boolean]
258
+ def due_now?(tolerance: 5.minutes)
259
+ raise SchedulableNotEnabledError unless self.class.schedulable_enabled?
260
+
261
+ now = Time.current
262
+
263
+ # Check if next occurrence is within tolerance window (forward or backward)
264
+ # This handles both: event happening now AND event coming up soon
265
+ next_occ = next_occurrence(after: now - tolerance)
266
+ return false unless next_occ
267
+
268
+ next_occ <= (now + tolerance)
269
+ end
270
+
271
+ # Aggiorna la cache next_occurrence_at (se la colonna esiste)
272
+ def update_next_occurrence!
273
+ return unless self.class.schedulable_enabled?
274
+ return unless respond_to?(:next_occurrence_at=)
275
+
276
+ next_occ = next_occurrence
277
+ update_column(:next_occurrence_at, next_occ)
278
+ end
279
+
280
+ # Callback: auto-update next_occurrence_at after save
281
+ def auto_update_next_occurrence
282
+ return unless respond_to?(:next_occurrence_at=)
283
+
284
+ # Check if schedule_config changed
285
+ schedule_field = self.class.schedulable_config.schedule_field
286
+ if saved_change_to_attribute?(schedule_field)
287
+ next_occ = next_occurrence
288
+ update_column(:next_occurrence_at, next_occ)
289
+ end
290
+ end
291
+
292
+ # Restituisce una descrizione leggibile dello schedule
293
+ #
294
+ # @return [String]
295
+ def schedule_summary
296
+ raise SchedulableNotEnabledError unless self.class.schedulable_enabled?
297
+
298
+ schedule_config = get_schedule_config
299
+ return "No schedule configured" if schedule_config.blank?
300
+
301
+ # TODO: Implementare formatter human-readable
302
+ schedule_config.inspect
303
+ end
304
+
305
+ private
306
+
307
+ def get_schedule_config
308
+ schedule_field = self.class.schedulable_config.schedule_field
309
+ config = self[schedule_field]
310
+
311
+ # Gestisci sia Hash che String (se serializzato)
312
+ return config if config.is_a?(Hash)
313
+ return JSON.parse(config) if config.is_a?(String)
314
+
315
+ {}
316
+ rescue JSON::ParserError
317
+ {}
318
+ end
319
+
320
+ def record_timezone
321
+ timezone_field = self.class.schedulable_config&.timezone_field
322
+ return "UTC" unless timezone_field
323
+
324
+ self[timezone_field] || "UTC"
325
+ end
326
+ end
327
+
328
+ # Configurazione Schedulable
329
+ class Schedulable::Configuration
330
+ def initialize
331
+ @schedule_field = :schedule_config
332
+ @timezone_field = nil
333
+ end
334
+
335
+ # Dual-purpose getter/setter for schedule_field
336
+ def schedule_field(field_name = nil)
337
+ return @schedule_field if field_name.nil?
338
+ @schedule_field = field_name.to_sym
339
+ end
340
+
341
+ # Dual-purpose getter/setter for timezone_field
342
+ def timezone_field(field_name = nil)
343
+ return @timezone_field if field_name.nil?
344
+ @timezone_field = field_name.to_sym
345
+ end
346
+ end
347
+
348
+ # Errori custom
349
+ class SchedulableError < StandardError; end
350
+
351
+ class SchedulableNotEnabledError < SchedulableError
352
+ def initialize(msg = nil)
353
+ super(msg || "Schedulable is not enabled. Add 'schedulable do...end' to your model.")
354
+ end
355
+ end
356
+ end
@@ -227,13 +227,13 @@ module BetterModel
227
227
  next if value.nil?
228
228
  next if value.respond_to?(:empty?) && value.empty?
229
229
 
230
- # Apply scope
231
- scope = if value == true || value == "true"
232
- scope.public_send(predicate_scope)
233
- elsif value.is_a?(Array)
230
+ # v2.0.0: ALL predicates require parameters
231
+ # Apply scope with parameter
232
+ scope = if value.is_a?(Array)
234
233
  # Splat array values for predicates like _between that expect multiple args
235
234
  scope.public_send(predicate_scope, *value)
236
235
  else
236
+ # Pass value as parameter (all predicates require at least one parameter)
237
237
  scope.public_send(predicate_scope, value)
238
238
  end
239
239
  end