in_time_scope 0.1.4 → 0.1.6

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,393 @@
1
+ # frozen_string_literal: true
2
+
3
+ module InTimeScope
4
+ # Class methods added to ActiveRecord models when InTimeScope is included
5
+ module ClassMethods
6
+ # Defines time-window scopes for the model.
7
+ #
8
+ # This method creates both a class-level scope and an instance method
9
+ # to check if records fall within a specified time window.
10
+ #
11
+ # @param scope_name [Symbol] The name of the scope (default: :in_time)
12
+ # When not :in_time, columns default to +<scope_name>_start_at+ and +<scope_name>_end_at+
13
+ #
14
+ # @param start_at [Hash] Configuration for the start column
15
+ # @option start_at [Symbol, nil] :column Column name (nil to disable start boundary)
16
+ # @option start_at [Boolean] :null Whether the column allows NULL values
17
+ # (auto-detected from schema if not specified)
18
+ #
19
+ # @param end_at [Hash] Configuration for the end column
20
+ # @option end_at [Symbol, nil] :column Column name (nil to disable end boundary)
21
+ # @option end_at [Boolean] :null Whether the column allows NULL values
22
+ # (auto-detected from schema if not specified)
23
+ #
24
+ # @raise [ColumnNotFoundError] When a specified column doesn't exist (at class load time)
25
+ # @raise [ConfigurationError] When both columns are nil, or when using start-only/end-only
26
+ # pattern with a nullable column (at scope call time)
27
+ #
28
+ # @return [void]
29
+ #
30
+ # == Examples
31
+ #
32
+ # Default scope with nullable columns:
33
+ #
34
+ # in_time_scope
35
+ # # Creates: Model.in_time, model.in_time?
36
+ #
37
+ # Named scope:
38
+ #
39
+ # in_time_scope :published
40
+ # # Creates: Model.in_time_published, model.in_time_published?
41
+ # # Uses: published_start_at, published_end_at columns
42
+ #
43
+ # Custom columns:
44
+ #
45
+ # in_time_scope start_at: { column: :available_at }, end_at: { column: :expired_at }
46
+ #
47
+ # Start-only pattern (for history tracking):
48
+ #
49
+ # in_time_scope start_at: { null: false }, end_at: { column: nil }
50
+ # # Also creates: Model.latest_in_time(:foreign_key), Model.earliest_in_time(:foreign_key)
51
+ #
52
+ # End-only pattern (for expiration):
53
+ #
54
+ # in_time_scope start_at: { column: nil }, end_at: { null: false }
55
+ # # Also creates: Model.latest_in_time(:foreign_key), Model.earliest_in_time(:foreign_key)
56
+ #
57
+ def in_time_scope(scope_name = :in_time, start_at: {}, end_at: {})
58
+ table_column_hash = columns_hash
59
+ time_column_prefix = scope_name == :in_time ? "" : "#{scope_name}_"
60
+
61
+ start_at_column = start_at.fetch(:column, :"#{time_column_prefix}start_at")
62
+ end_at_column = end_at.fetch(:column, :"#{time_column_prefix}end_at")
63
+
64
+ start_at_null = fetch_null_option(start_at, start_at_column, table_column_hash)
65
+ end_at_null = fetch_null_option(end_at, end_at_column, table_column_hash)
66
+
67
+ define_scope_methods(
68
+ scope_name == :in_time ? "" : "_#{scope_name}",
69
+ start_at_column: start_at_column,
70
+ start_at_null: start_at_null,
71
+ end_at_column: end_at_column,
72
+ end_at_null: end_at_null
73
+ )
74
+ end
75
+
76
+ private
77
+
78
+ # Fetches the null option for a column, auto-detecting from schema if not specified
79
+ #
80
+ # @param config [Hash] Configuration hash with optional :null key
81
+ # @param column [Symbol, nil] Column name
82
+ # @param table_column_hash [Hash] Hash of column metadata from ActiveRecord
83
+ # @return [Boolean, nil] Whether the column allows NULL values
84
+ # @raise [ColumnNotFoundError] When the column doesn't exist in the table
85
+ # @api private
86
+ def fetch_null_option(config, column, table_column_hash)
87
+ return nil if column.nil?
88
+ return config[:null] if config.key?(:null)
89
+
90
+ column_info = table_column_hash[column.to_s]
91
+ raise ColumnNotFoundError, "Column '#{column}' does not exist on table '#{table_name}'" if column_info.nil?
92
+
93
+ column_info.null
94
+ end
95
+
96
+ # Defines the appropriate scope methods based on configuration
97
+ #
98
+ # @param suffix [String] The suffix for method names ("" or "_#{scope_name}")
99
+ # @param start_at_column [Symbol, nil] Start column name
100
+ # @param start_at_null [Boolean, nil] Whether start column allows NULL
101
+ # @param end_at_column [Symbol, nil] End column name
102
+ # @param end_at_null [Boolean, nil] Whether end column allows NULL
103
+ # @return [void]
104
+ # @api private
105
+ def define_scope_methods(suffix, start_at_column:, start_at_null:, end_at_column:, end_at_null:)
106
+ # Define class-level scope and instance method
107
+ if start_at_column.nil? && end_at_column.nil?
108
+ define_error_scope_and_method(suffix,
109
+ "At least one of start_at or end_at must be specified")
110
+ elsif end_at_column.nil?
111
+ # Start-only pattern (history tracking) - requires non-nullable column
112
+ if start_at_null
113
+ define_error_scope_and_method(suffix,
114
+ "Start-only pattern requires non-nullable column. " \
115
+ "Set `start_at: { null: false }` or add an end_at column")
116
+ else
117
+ define_start_only_scope(suffix, start_at_column)
118
+ define_instance_method(suffix, start_at_column, start_at_null, end_at_column, end_at_null)
119
+ define_latest_one_scope(suffix, start_at_column)
120
+ define_earliest_one_scope(suffix, start_at_column)
121
+ define_before_scope(suffix, start_at_column, start_at_null)
122
+ define_after_scope(suffix, end_at_column, end_at_null)
123
+ define_out_of_time_scope(suffix)
124
+ end
125
+ elsif start_at_column.nil?
126
+ # End-only pattern (expiration) - requires non-nullable column
127
+ if end_at_null
128
+ define_error_scope_and_method(suffix,
129
+ "End-only pattern requires non-nullable column. " \
130
+ "Set `end_at: { null: false }` or add a start_at column")
131
+ else
132
+ define_end_only_scope(suffix, end_at_column)
133
+ define_instance_method(suffix, start_at_column, start_at_null, end_at_column, end_at_null)
134
+ define_latest_one_scope(suffix, end_at_column)
135
+ define_earliest_one_scope(suffix, end_at_column)
136
+ define_before_scope(suffix, start_at_column, start_at_null)
137
+ define_after_scope(suffix, end_at_column, end_at_null)
138
+ define_out_of_time_scope(suffix)
139
+ end
140
+ else
141
+ # Both start and end
142
+ define_full_scope(suffix, start_at_column, start_at_null, end_at_column, end_at_null)
143
+ define_instance_method(suffix, start_at_column, start_at_null, end_at_column, end_at_null)
144
+ define_before_scope(suffix, start_at_column, start_at_null)
145
+ define_after_scope(suffix, end_at_column, end_at_null)
146
+ define_out_of_time_scope(suffix)
147
+ end
148
+ end
149
+
150
+ # Defines a scope and instance method that raise ConfigurationError
151
+ #
152
+ # @param suffix [String] The suffix for method names
153
+ # @param message [String] The error message
154
+ # @return [void]
155
+ # @api private
156
+ def define_error_scope_and_method(suffix, message)
157
+ method_names = [
158
+ :"in_time#{suffix}",
159
+ :"before_in_time#{suffix}",
160
+ :"after_in_time#{suffix}",
161
+ :"out_of_time#{suffix}"
162
+ ]
163
+
164
+ method_names.each do |method_name|
165
+ scope method_name, ->(_time = Time.current) {
166
+ raise InTimeScope::ConfigurationError, message
167
+ }
168
+
169
+ define_method("#{method_name}?") do |_time = Time.current|
170
+ raise InTimeScope::ConfigurationError, message
171
+ end
172
+ end
173
+ end
174
+
175
+ # Defines a start-only scope (for history tracking pattern)
176
+ #
177
+ # @param suffix [String] The suffix for method names
178
+ # @param column [Symbol] The start column name
179
+ # @return [void]
180
+ # @api private
181
+ def define_start_only_scope(suffix, column)
182
+ # Simple scope - WHERE only, no ORDER BY
183
+ # Users can add .order(start_at: :desc) externally if needed
184
+ scope :"in_time#{suffix}", ->(time = Time.current) {
185
+ where(column => ..time)
186
+ }
187
+ end
188
+
189
+ # Defines the latest_in_time scope using NOT EXISTS subquery
190
+ #
191
+ # This scope efficiently finds the latest record per foreign key,
192
+ # suitable for use with has_one associations and includes.
193
+ #
194
+ # @param suffix [String] The suffix for method names
195
+ # @param column [Symbol] The timestamp column name
196
+ # @return [void]
197
+ #
198
+ # @example Usage with has_one
199
+ # has_one :current_price, -> { latest_in_time(:user_id) }, class_name: 'Price'
200
+ #
201
+ # @api private
202
+ def define_latest_one_scope(suffix, column)
203
+ # NOT EXISTS approach: select records where no later record exists for the same foreign key
204
+ scope :"latest_in_time#{suffix}", ->(foreign_key, time = Time.current) {
205
+ p2 = arel_table.alias("p2")
206
+
207
+ subquery = Arel::SelectManager.new(arel_table)
208
+ .from(p2)
209
+ .project(Arel.sql("1"))
210
+ .where(p2[foreign_key].eq(arel_table[foreign_key]))
211
+ .where(p2[column].lteq(time))
212
+ .where(p2[column].gt(arel_table[column]))
213
+ .where(p2[:id].not_eq(arel_table[:id]))
214
+
215
+ not_exists = Arel::Nodes::Not.new(Arel::Nodes::Exists.new(subquery.ast))
216
+
217
+ where(column => ..time).where(not_exists)
218
+ }
219
+ end
220
+
221
+ # Defines the earliest_in_time scope using NOT EXISTS subquery
222
+ #
223
+ # This scope efficiently finds the earliest record per foreign key,
224
+ # suitable for use with has_one associations and includes.
225
+ #
226
+ # @param suffix [String] The suffix for method names
227
+ # @param column [Symbol] The timestamp column name
228
+ # @return [void]
229
+ #
230
+ # @example Usage with has_one
231
+ # has_one :first_price, -> { earliest_in_time(:user_id) }, class_name: 'Price'
232
+ #
233
+ # @api private
234
+ def define_earliest_one_scope(suffix, column)
235
+ # NOT EXISTS approach: select records where no earlier record exists for the same foreign key
236
+ scope :"earliest_in_time#{suffix}", ->(foreign_key, time = Time.current) {
237
+ p2 = arel_table.alias("p2")
238
+
239
+ subquery = Arel::SelectManager.new(arel_table)
240
+ .from(p2)
241
+ .project(Arel.sql("1"))
242
+ .where(p2[foreign_key].eq(arel_table[foreign_key]))
243
+ .where(p2[column].lteq(time))
244
+ .where(p2[column].lt(arel_table[column]))
245
+ .where(p2[:id].not_eq(arel_table[:id]))
246
+
247
+ not_exists = Arel::Nodes::Not.new(Arel::Nodes::Exists.new(subquery.ast))
248
+
249
+ where(column => ..time).where(not_exists)
250
+ }
251
+ end
252
+
253
+ # Defines an end-only scope (for expiration pattern)
254
+ #
255
+ # @param suffix [String] The suffix for method names
256
+ # @param column [Symbol] The end column name
257
+ # @return [void]
258
+ # @api private
259
+ def define_end_only_scope(suffix, column)
260
+ scope :"in_time#{suffix}", ->(time = Time.current) {
261
+ where.not(column => ..time)
262
+ }
263
+ end
264
+
265
+ # Defines a full scope with both start and end columns
266
+ #
267
+ # @param suffix [String] The suffix for method names
268
+ # @param start_column [Symbol] The start column name
269
+ # @param start_null [Boolean] Whether start column allows NULL
270
+ # @param end_column [Symbol] The end column name
271
+ # @param end_null [Boolean] Whether end column allows NULL
272
+ # @return [void]
273
+ # @api private
274
+ def define_full_scope(suffix, start_column, start_null, end_column, end_null)
275
+ scope :"in_time#{suffix}", ->(time = Time.current) {
276
+ start_scope = if start_null
277
+ where(start_column => nil).or(where(start_column => ..time))
278
+ else
279
+ where(start_column => ..time)
280
+ end
281
+
282
+ end_scope = if end_null
283
+ where(end_column => nil).or(where.not(end_column => ..time))
284
+ else
285
+ where.not(end_column => ..time)
286
+ end
287
+
288
+ start_scope.merge(end_scope)
289
+ }
290
+
291
+ # NOTE: latest_in_time / earliest_in_time are NOT defined for full scope (both start and end)
292
+ # because the concept of "latest" or "earliest" is ambiguous when there's a time range.
293
+ # These scopes are only available for start-only or end-only patterns.
294
+ end
295
+
296
+ # Defines the instance method to check if a record is within the time window
297
+ #
298
+ # @param suffix [String] The suffix for method names
299
+ # @param start_column [Symbol, nil] The start column name
300
+ # @param start_null [Boolean, nil] Whether start column allows NULL
301
+ # @param end_column [Symbol, nil] The end column name
302
+ # @param end_null [Boolean, nil] Whether end column allows NULL
303
+ # @return [void]
304
+ # @api private
305
+ def define_instance_method(suffix, start_column, start_null, end_column, end_null)
306
+ define_method("in_time#{suffix}?") do |time = Time.current|
307
+ start_ok = if start_column.nil?
308
+ true
309
+ elsif start_null
310
+ send(start_column).nil? || send(start_column) <= time
311
+ else
312
+ send(start_column) <= time
313
+ end
314
+
315
+ end_ok = if end_column.nil?
316
+ true
317
+ elsif end_null
318
+ send(end_column).nil? || send(end_column) > time
319
+ else
320
+ send(end_column) > time
321
+ end
322
+
323
+ start_ok && end_ok
324
+ end
325
+ end
326
+
327
+ # Defines before_in_time scope (records not yet started: start_at > time)
328
+ #
329
+ # @param suffix [String] The suffix for method names
330
+ # @param start_column [Symbol, nil] The start column name
331
+ # @param start_null [Boolean, nil] Whether start column allows NULL
332
+ # @return [void]
333
+ # @api private
334
+ def define_before_scope(suffix, start_column, start_null)
335
+ # No start column means always started (never before)
336
+ # start_at > time means not yet started
337
+ # NULL start_at is treated as "already started" (not before)
338
+ scope :"before_in_time#{suffix}", ->(time = Time.current) {
339
+ start_column.nil? ? none : where.not(start_column => ..time)
340
+ }
341
+
342
+ define_method("before_in_time#{suffix}?") do |time = Time.current|
343
+ return false if start_column.nil?
344
+
345
+ val = send(start_column)
346
+ return false if val.nil? && start_null
347
+
348
+ val > time
349
+ end
350
+ end
351
+
352
+ # Defines after_in_time scope (records already ended: end_at <= time)
353
+ #
354
+ # @param suffix [String] The suffix for method names
355
+ # @param end_column [Symbol, nil] The end column name
356
+ # @param end_null [Boolean, nil] Whether end column allows NULL
357
+ # @return [void]
358
+ # @api private
359
+ def define_after_scope(suffix, end_column, end_null)
360
+ # No end column means never ends (never after)
361
+ # end_at <= time means already ended
362
+ # NULL end_at is treated as "never ends" (not after)
363
+ scope :"after_in_time#{suffix}", ->(time = Time.current) {
364
+ end_column.nil? ? none : where(end_column => ..time)
365
+ }
366
+
367
+ define_method("after_in_time#{suffix}?") do |time = Time.current|
368
+ return false if end_column.nil?
369
+
370
+ val = send(end_column)
371
+ return false if val.nil? && end_null
372
+
373
+ val <= time
374
+ end
375
+ end
376
+
377
+ # Defines out_of_time scope (records outside time window: before OR after)
378
+ #
379
+ # @param suffix [String] The suffix for method names
380
+ # @return [void]
381
+ # @api private
382
+ def define_out_of_time_scope(suffix)
383
+ # out_of_time = before_in_time OR after_in_time
384
+ scope :"out_of_time#{suffix}", ->(time = Time.current) {
385
+ send(:"before_in_time#{suffix}", time).or(send(:"after_in_time#{suffix}", time))
386
+ }
387
+
388
+ define_method("out_of_time#{suffix}?") do |time = Time.current|
389
+ send("before_in_time#{suffix}?", time) || send("after_in_time#{suffix}?", time)
390
+ end
391
+ end
392
+ end
393
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module InTimeScope
4
- VERSION = "0.1.4"
4
+ # The current version of the InTimeScope gem
5
+ VERSION = "0.1.6"
5
6
  end