active_record_in_time_scope 0.1.7
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/.rubocop.yml +47 -0
- data/.rulesync/commands/translate-readme.md +46 -0
- data/.rulesync/rules/project.md +87 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +191 -0
- data/Rakefile +10 -0
- data/Steepfile +25 -0
- data/docs/book.toml +14 -0
- data/docs/de/SUMMARY.md +5 -0
- data/docs/de/index.md +192 -0
- data/docs/de/point-system.md +295 -0
- data/docs/de/user-name-history.md +164 -0
- data/docs/fr/SUMMARY.md +5 -0
- data/docs/fr/index.md +192 -0
- data/docs/fr/point-system.md +295 -0
- data/docs/fr/user-name-history.md +164 -0
- data/docs/ja/SUMMARY.md +5 -0
- data/docs/ja/index.md +192 -0
- data/docs/ja/point-system.md +295 -0
- data/docs/ja/user-name-history.md +164 -0
- data/docs/src/SUMMARY.md +5 -0
- data/docs/src/index.md +194 -0
- data/docs/src/point-system.md +295 -0
- data/docs/src/user-name-history.md +164 -0
- data/docs/zh/SUMMARY.md +5 -0
- data/docs/zh/index.md +192 -0
- data/docs/zh/point-system.md +295 -0
- data/docs/zh/user-name-history.md +164 -0
- data/lib/active_record_in_time_scope/class_methods.rb +457 -0
- data/lib/active_record_in_time_scope/version.rb +6 -0
- data/lib/active_record_in_time_scope.rb +132 -0
- data/mise.toml +2 -0
- data/rbs_collection.yaml +16 -0
- data/rulesync.jsonc +6 -0
- data/sig/active_record_in_time_scope.rbs +95 -0
- metadata +223 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecordInTimeScope
|
|
4
|
+
# Class methods added to ActiveRecord models when ActiveRecordInTimeScope 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
|
+
# Returns true when only the start column is configured (history tracking pattern)
|
|
97
|
+
#
|
|
98
|
+
# @param start_at_column [Symbol, nil] Start column name
|
|
99
|
+
# @param end_at_column [Symbol, nil] End column name
|
|
100
|
+
# @return [Boolean]
|
|
101
|
+
# @api private
|
|
102
|
+
def start_only_pattern?(start_at_column, end_at_column)
|
|
103
|
+
!start_at_column.nil? && end_at_column.nil?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Returns true when only the end column is configured (expiration pattern)
|
|
107
|
+
#
|
|
108
|
+
# @param start_at_column [Symbol, nil] Start column name
|
|
109
|
+
# @param end_at_column [Symbol, nil] End column name
|
|
110
|
+
# @return [Boolean]
|
|
111
|
+
# @api private
|
|
112
|
+
def end_only_pattern?(start_at_column, end_at_column)
|
|
113
|
+
start_at_column.nil? && !end_at_column.nil?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Defines the appropriate scope methods based on configuration
|
|
117
|
+
#
|
|
118
|
+
# @param suffix [String] The suffix for method names ("" or "_#{scope_name}")
|
|
119
|
+
# @param start_at_column [Symbol, nil] Start column name
|
|
120
|
+
# @param start_at_null [Boolean, nil] Whether start column allows NULL
|
|
121
|
+
# @param end_at_column [Symbol, nil] End column name
|
|
122
|
+
# @param end_at_null [Boolean, nil] Whether end column allows NULL
|
|
123
|
+
# @return [void]
|
|
124
|
+
# @api private
|
|
125
|
+
def define_scope_methods(suffix, start_at_column:, start_at_null:, end_at_column:, end_at_null:)
|
|
126
|
+
# Define class-level scope and instance method
|
|
127
|
+
if start_at_column.nil? && end_at_column.nil?
|
|
128
|
+
define_error_scope_and_method(suffix,
|
|
129
|
+
"At least one of start_at or end_at must be specified")
|
|
130
|
+
elsif start_only_pattern?(start_at_column, end_at_column)
|
|
131
|
+
# Start-only pattern (history tracking) - requires non-nullable column
|
|
132
|
+
if start_at_null
|
|
133
|
+
define_error_scope_and_method(suffix,
|
|
134
|
+
"Start-only pattern requires non-nullable column. " \
|
|
135
|
+
"Set `start_at: { null: false }` or add an end_at column")
|
|
136
|
+
else
|
|
137
|
+
define_start_only_scope(suffix, start_at_column)
|
|
138
|
+
define_instance_method(suffix, start_at_column, start_at_null, end_at_column, end_at_null)
|
|
139
|
+
define_latest_one_scope(suffix, start_at_column)
|
|
140
|
+
define_earliest_one_scope(suffix, start_at_column)
|
|
141
|
+
define_before_scope(suffix, start_at_column, start_at_null)
|
|
142
|
+
define_after_scope(suffix, end_at_column, end_at_null)
|
|
143
|
+
define_out_of_time_scope(suffix)
|
|
144
|
+
end
|
|
145
|
+
elsif end_only_pattern?(start_at_column, end_at_column)
|
|
146
|
+
# End-only pattern (expiration) - requires non-nullable column
|
|
147
|
+
if end_at_null
|
|
148
|
+
define_error_scope_and_method(suffix,
|
|
149
|
+
"End-only pattern requires non-nullable column. " \
|
|
150
|
+
"Set `end_at: { null: false }` or add a start_at column")
|
|
151
|
+
else
|
|
152
|
+
define_end_only_scope(suffix, end_at_column)
|
|
153
|
+
define_instance_method(suffix, start_at_column, start_at_null, end_at_column, end_at_null)
|
|
154
|
+
define_latest_one_scope(suffix, end_at_column)
|
|
155
|
+
define_earliest_one_scope(suffix, end_at_column)
|
|
156
|
+
define_before_scope(suffix, start_at_column, start_at_null)
|
|
157
|
+
define_after_scope(suffix, end_at_column, end_at_null)
|
|
158
|
+
define_out_of_time_scope(suffix)
|
|
159
|
+
end
|
|
160
|
+
else
|
|
161
|
+
# Both start and end
|
|
162
|
+
define_full_scope(suffix, start_at_column, start_at_null, end_at_column, end_at_null)
|
|
163
|
+
define_instance_method(suffix, start_at_column, start_at_null, end_at_column, end_at_null)
|
|
164
|
+
define_before_scope(suffix, start_at_column, start_at_null)
|
|
165
|
+
define_after_scope(suffix, end_at_column, end_at_null)
|
|
166
|
+
define_out_of_time_scope(suffix)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Defines a scope and instance method that raise ConfigurationError
|
|
171
|
+
#
|
|
172
|
+
# @param suffix [String] The suffix for method names
|
|
173
|
+
# @param message [String] The error message
|
|
174
|
+
# @return [void]
|
|
175
|
+
# @api private
|
|
176
|
+
def define_error_scope_and_method(suffix, message)
|
|
177
|
+
method_names = [
|
|
178
|
+
:"in_time#{suffix}",
|
|
179
|
+
:"before_in_time#{suffix}",
|
|
180
|
+
:"after_in_time#{suffix}",
|
|
181
|
+
:"out_of_time#{suffix}"
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
method_names.each do |method_name|
|
|
185
|
+
scope method_name, ->(_time = Time.current) {
|
|
186
|
+
raise ActiveRecordInTimeScope::ConfigurationError, message
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
define_method("#{method_name}?") do |_time = Time.current|
|
|
190
|
+
raise ActiveRecordInTimeScope::ConfigurationError, message
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Defines a start-only scope (for history tracking pattern)
|
|
196
|
+
#
|
|
197
|
+
# @param suffix [String] The suffix for method names
|
|
198
|
+
# @param column [Symbol] The start column name
|
|
199
|
+
# @return [void]
|
|
200
|
+
# @api private
|
|
201
|
+
def define_start_only_scope(suffix, column)
|
|
202
|
+
# Simple scope - WHERE only, no ORDER BY
|
|
203
|
+
# Users can add .order(start_at: :desc) externally if needed
|
|
204
|
+
scope :"in_time#{suffix}", ->(time = Time.current) {
|
|
205
|
+
where(column => ..time)
|
|
206
|
+
}
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Defines the latest_in_time scope using NOT EXISTS subquery
|
|
210
|
+
#
|
|
211
|
+
# This scope efficiently finds the latest record per foreign key,
|
|
212
|
+
# suitable for use with has_one associations and includes.
|
|
213
|
+
#
|
|
214
|
+
# @note When multiple records share the same timestamp for a given foreign key,
|
|
215
|
+
# all of them will be returned. This is safe for +has_one+ associations
|
|
216
|
+
# (ActiveRecord picks one), but callers using this as a standalone scope
|
|
217
|
+
# should be aware that the result may contain multiple records per foreign key
|
|
218
|
+
# in the case of timestamp ties.
|
|
219
|
+
#
|
|
220
|
+
# @param suffix [String] The suffix for method names
|
|
221
|
+
# @param column [Symbol] The timestamp column name
|
|
222
|
+
# @return [void]
|
|
223
|
+
#
|
|
224
|
+
# @example Usage with has_one
|
|
225
|
+
# has_one :current_price, -> { latest_in_time(:user_id) }, class_name: 'Price'
|
|
226
|
+
#
|
|
227
|
+
# @api private
|
|
228
|
+
def define_latest_one_scope(suffix, column)
|
|
229
|
+
# NOT EXISTS approach: select records where no later record exists for the same foreign key
|
|
230
|
+
scope :"latest_in_time#{suffix}", ->(foreign_key, time = Time.current) {
|
|
231
|
+
p2 = arel_table.alias("p2")
|
|
232
|
+
|
|
233
|
+
subquery = Arel::SelectManager.new(arel_table)
|
|
234
|
+
.from(p2)
|
|
235
|
+
.project(Arel.sql("1"))
|
|
236
|
+
.where(p2[foreign_key].eq(arel_table[foreign_key]))
|
|
237
|
+
.where(p2[column].lteq(time))
|
|
238
|
+
.where(p2[column].gt(arel_table[column]))
|
|
239
|
+
.where(p2[:id].not_eq(arel_table[:id]))
|
|
240
|
+
|
|
241
|
+
# Propagate simple equality conditions from the current scope into the NOT EXISTS
|
|
242
|
+
# subquery. This ensures that chained scopes like `.approved.latest_in_time(:user_id)`
|
|
243
|
+
# only consider approved records when searching for "a newer record exists",
|
|
244
|
+
# preventing a newer rejected record from shadowing the latest approved one.
|
|
245
|
+
where_values_hash.each do |col, val|
|
|
246
|
+
next if col.to_s == column.to_s # time column already handled above
|
|
247
|
+
next if col.to_s == foreign_key.to_s # foreign key already handled above
|
|
248
|
+
|
|
249
|
+
subquery = if val.nil?
|
|
250
|
+
subquery.where(p2[col].eq(nil))
|
|
251
|
+
elsif val.is_a?(Array)
|
|
252
|
+
subquery.where(p2[col].in(val))
|
|
253
|
+
else
|
|
254
|
+
subquery.where(p2[col].eq(val))
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
not_exists = Arel::Nodes::Not.new(Arel::Nodes::Exists.new(subquery.ast))
|
|
259
|
+
|
|
260
|
+
where(column => ..time).where(not_exists)
|
|
261
|
+
}
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Defines the earliest_in_time scope using NOT EXISTS subquery
|
|
265
|
+
#
|
|
266
|
+
# This scope efficiently finds the earliest record per foreign key,
|
|
267
|
+
# suitable for use with has_one associations and includes.
|
|
268
|
+
#
|
|
269
|
+
# @note When multiple records share the same timestamp for a given foreign key,
|
|
270
|
+
# all of them will be returned. This is safe for +has_one+ associations
|
|
271
|
+
# (ActiveRecord picks one), but callers using this as a standalone scope
|
|
272
|
+
# should be aware that the result may contain multiple records per foreign key
|
|
273
|
+
# in the case of timestamp ties.
|
|
274
|
+
#
|
|
275
|
+
# @param suffix [String] The suffix for method names
|
|
276
|
+
# @param column [Symbol] The timestamp column name
|
|
277
|
+
# @return [void]
|
|
278
|
+
#
|
|
279
|
+
# @example Usage with has_one
|
|
280
|
+
# has_one :first_price, -> { earliest_in_time(:user_id) }, class_name: 'Price'
|
|
281
|
+
#
|
|
282
|
+
# @api private
|
|
283
|
+
def define_earliest_one_scope(suffix, column)
|
|
284
|
+
# NOT EXISTS approach: select records where no earlier record exists for the same foreign key
|
|
285
|
+
scope :"earliest_in_time#{suffix}", ->(foreign_key, time = Time.current) {
|
|
286
|
+
p2 = arel_table.alias("p2")
|
|
287
|
+
|
|
288
|
+
subquery = Arel::SelectManager.new(arel_table)
|
|
289
|
+
.from(p2)
|
|
290
|
+
.project(Arel.sql("1"))
|
|
291
|
+
.where(p2[foreign_key].eq(arel_table[foreign_key]))
|
|
292
|
+
.where(p2[column].lteq(time))
|
|
293
|
+
.where(p2[column].lt(arel_table[column]))
|
|
294
|
+
.where(p2[:id].not_eq(arel_table[:id]))
|
|
295
|
+
|
|
296
|
+
# Propagate simple equality conditions from the current scope into the NOT EXISTS
|
|
297
|
+
# subquery (same reasoning as define_latest_one_scope).
|
|
298
|
+
where_values_hash.each do |col, val|
|
|
299
|
+
next if col.to_s == column.to_s
|
|
300
|
+
next if col.to_s == foreign_key.to_s
|
|
301
|
+
|
|
302
|
+
subquery = if val.nil?
|
|
303
|
+
subquery.where(p2[col].eq(nil))
|
|
304
|
+
elsif val.is_a?(Array)
|
|
305
|
+
subquery.where(p2[col].in(val))
|
|
306
|
+
else
|
|
307
|
+
subquery.where(p2[col].eq(val))
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
not_exists = Arel::Nodes::Not.new(Arel::Nodes::Exists.new(subquery.ast))
|
|
312
|
+
|
|
313
|
+
where(column => ..time).where(not_exists)
|
|
314
|
+
}
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Defines an end-only scope (for expiration pattern)
|
|
318
|
+
#
|
|
319
|
+
# @param suffix [String] The suffix for method names
|
|
320
|
+
# @param column [Symbol] The end column name
|
|
321
|
+
# @return [void]
|
|
322
|
+
# @api private
|
|
323
|
+
def define_end_only_scope(suffix, column)
|
|
324
|
+
scope :"in_time#{suffix}", ->(time = Time.current) {
|
|
325
|
+
where.not(column => ..time)
|
|
326
|
+
}
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Defines a full scope with both start and end columns
|
|
330
|
+
#
|
|
331
|
+
# @param suffix [String] The suffix for method names
|
|
332
|
+
# @param start_column [Symbol] The start column name
|
|
333
|
+
# @param start_null [Boolean] Whether start column allows NULL
|
|
334
|
+
# @param end_column [Symbol] The end column name
|
|
335
|
+
# @param end_null [Boolean] Whether end column allows NULL
|
|
336
|
+
# @return [void]
|
|
337
|
+
# @api private
|
|
338
|
+
def define_full_scope(suffix, start_column, start_null, end_column, end_null)
|
|
339
|
+
scope :"in_time#{suffix}", ->(time = Time.current) {
|
|
340
|
+
start_scope = if start_null
|
|
341
|
+
where(start_column => nil).or(where(start_column => ..time))
|
|
342
|
+
else
|
|
343
|
+
where(start_column => ..time)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
end_scope = if end_null
|
|
347
|
+
where(end_column => nil).or(where.not(end_column => ..time))
|
|
348
|
+
else
|
|
349
|
+
where.not(end_column => ..time)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
start_scope.merge(end_scope)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
# NOTE: latest_in_time / earliest_in_time are NOT defined for full scope (both start and end)
|
|
356
|
+
# because the concept of "latest" or "earliest" is ambiguous when there's a time range.
|
|
357
|
+
# These scopes are only available for start-only or end-only patterns.
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Defines the instance method to check if a record is within the time window
|
|
361
|
+
#
|
|
362
|
+
# @param suffix [String] The suffix for method names
|
|
363
|
+
# @param start_column [Symbol, nil] The start column name
|
|
364
|
+
# @param start_null [Boolean, nil] Whether start column allows NULL
|
|
365
|
+
# @param end_column [Symbol, nil] The end column name
|
|
366
|
+
# @param end_null [Boolean, nil] Whether end column allows NULL
|
|
367
|
+
# @return [void]
|
|
368
|
+
# @api private
|
|
369
|
+
def define_instance_method(suffix, start_column, start_null, end_column, end_null)
|
|
370
|
+
define_method("in_time#{suffix}?") do |time = Time.current|
|
|
371
|
+
start_ok = if start_column.nil?
|
|
372
|
+
true
|
|
373
|
+
elsif start_null
|
|
374
|
+
send(start_column).nil? || send(start_column) <= time
|
|
375
|
+
else
|
|
376
|
+
send(start_column) <= time
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
end_ok = if end_column.nil?
|
|
380
|
+
true
|
|
381
|
+
elsif end_null
|
|
382
|
+
send(end_column).nil? || send(end_column) > time
|
|
383
|
+
else
|
|
384
|
+
send(end_column) > time
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
start_ok && end_ok
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Defines before_in_time scope (records not yet started: start_at > time)
|
|
392
|
+
#
|
|
393
|
+
# @param suffix [String] The suffix for method names
|
|
394
|
+
# @param start_column [Symbol, nil] The start column name
|
|
395
|
+
# @param start_null [Boolean, nil] Whether start column allows NULL
|
|
396
|
+
# @return [void]
|
|
397
|
+
# @api private
|
|
398
|
+
def define_before_scope(suffix, start_column, start_null)
|
|
399
|
+
# No start column means always started (never before)
|
|
400
|
+
# start_at > time means not yet started
|
|
401
|
+
# NULL start_at is treated as "already started" (not before)
|
|
402
|
+
scope :"before_in_time#{suffix}", ->(time = Time.current) {
|
|
403
|
+
start_column.nil? ? none : where.not(start_column => ..time)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
define_method("before_in_time#{suffix}?") do |time = Time.current|
|
|
407
|
+
return false if start_column.nil?
|
|
408
|
+
|
|
409
|
+
val = send(start_column)
|
|
410
|
+
return false if val.nil? && start_null
|
|
411
|
+
|
|
412
|
+
val > time
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Defines after_in_time scope (records already ended: end_at <= time)
|
|
417
|
+
#
|
|
418
|
+
# @param suffix [String] The suffix for method names
|
|
419
|
+
# @param end_column [Symbol, nil] The end column name
|
|
420
|
+
# @param end_null [Boolean, nil] Whether end column allows NULL
|
|
421
|
+
# @return [void]
|
|
422
|
+
# @api private
|
|
423
|
+
def define_after_scope(suffix, end_column, end_null)
|
|
424
|
+
# No end column means never ends (never after)
|
|
425
|
+
# end_at <= time means already ended
|
|
426
|
+
# NULL end_at is treated as "never ends" (not after)
|
|
427
|
+
scope :"after_in_time#{suffix}", ->(time = Time.current) {
|
|
428
|
+
end_column.nil? ? none : where(end_column => ..time)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
define_method("after_in_time#{suffix}?") do |time = Time.current|
|
|
432
|
+
return false if end_column.nil?
|
|
433
|
+
|
|
434
|
+
val = send(end_column)
|
|
435
|
+
return false if val.nil? && end_null
|
|
436
|
+
|
|
437
|
+
val <= time
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Defines out_of_time scope (records outside time window: before OR after)
|
|
442
|
+
#
|
|
443
|
+
# @param suffix [String] The suffix for method names
|
|
444
|
+
# @return [void]
|
|
445
|
+
# @api private
|
|
446
|
+
def define_out_of_time_scope(suffix)
|
|
447
|
+
# out_of_time = before_in_time OR after_in_time
|
|
448
|
+
scope :"out_of_time#{suffix}", ->(time = Time.current) {
|
|
449
|
+
send(:"before_in_time#{suffix}", time).or(send(:"after_in_time#{suffix}", time))
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
define_method("out_of_time#{suffix}?") do |time = Time.current|
|
|
453
|
+
send("before_in_time#{suffix}?", time) || send("after_in_time#{suffix}?", time)
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
require_relative "active_record_in_time_scope/version"
|
|
5
|
+
require_relative "active_record_in_time_scope/class_methods"
|
|
6
|
+
|
|
7
|
+
# ActiveRecordInTimeScope provides time-window scopes for ActiveRecord models.
|
|
8
|
+
#
|
|
9
|
+
# It allows you to easily query records that fall within specific time periods,
|
|
10
|
+
# with support for nullable columns, custom column names, and multiple scopes per model.
|
|
11
|
+
#
|
|
12
|
+
# ActiveRecordInTimeScope is automatically included in ActiveRecord::Base, so you can use
|
|
13
|
+
# +in_time_scope+ directly in your models without explicit include.
|
|
14
|
+
#
|
|
15
|
+
# == Basic usage
|
|
16
|
+
#
|
|
17
|
+
# class Event < ActiveRecord::Base
|
|
18
|
+
# in_time_scope
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# Event.in_time # Records active at current time
|
|
22
|
+
# Event.in_time(some_time) # Records active at specific time
|
|
23
|
+
# event.in_time? # Check if record is active now
|
|
24
|
+
#
|
|
25
|
+
# == Patterns
|
|
26
|
+
#
|
|
27
|
+
# === Full pattern (both start and end)
|
|
28
|
+
#
|
|
29
|
+
# Default pattern with both +start_at+ and +end_at+ columns.
|
|
30
|
+
# Supports nullable columns (NULL means "no limit").
|
|
31
|
+
#
|
|
32
|
+
# class Event < ActiveRecord::Base
|
|
33
|
+
# in_time_scope # Uses start_at and end_at columns
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# === Start-only pattern (history tracking)
|
|
37
|
+
#
|
|
38
|
+
# For versioned records where each row is valid from +start_at+ until the next row.
|
|
39
|
+
# Requires non-nullable column.
|
|
40
|
+
#
|
|
41
|
+
# class Price < ActiveRecord::Base
|
|
42
|
+
# in_time_scope start_at: { null: false }, end_at: { column: nil }
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
45
|
+
# # Additional scopes created:
|
|
46
|
+
# Price.latest_in_time(:user_id) # Latest record per user
|
|
47
|
+
# Price.earliest_in_time(:user_id) # Earliest record per user
|
|
48
|
+
#
|
|
49
|
+
# === End-only pattern (expiration)
|
|
50
|
+
#
|
|
51
|
+
# For records that are always active until they expire.
|
|
52
|
+
# Requires non-nullable column.
|
|
53
|
+
#
|
|
54
|
+
# class Coupon < ActiveRecord::Base
|
|
55
|
+
# in_time_scope start_at: { column: nil }, end_at: { null: false }
|
|
56
|
+
# end
|
|
57
|
+
#
|
|
58
|
+
# == Using with has_one associations
|
|
59
|
+
#
|
|
60
|
+
# The +latest_in_time+ and +earliest_in_time+ scopes are optimized for
|
|
61
|
+
# +has_one+ associations with +includes+, using NOT EXISTS subqueries.
|
|
62
|
+
#
|
|
63
|
+
# class Price < ActiveRecord::Base
|
|
64
|
+
# belongs_to :user
|
|
65
|
+
# in_time_scope start_at: { null: false }, end_at: { column: nil }
|
|
66
|
+
# end
|
|
67
|
+
#
|
|
68
|
+
# class User < ActiveRecord::Base
|
|
69
|
+
# has_many :prices
|
|
70
|
+
#
|
|
71
|
+
# # Efficient: uses NOT EXISTS subquery
|
|
72
|
+
# has_one :current_price,
|
|
73
|
+
# -> { latest_in_time(:user_id) },
|
|
74
|
+
# class_name: "Price"
|
|
75
|
+
#
|
|
76
|
+
# has_one :first_price,
|
|
77
|
+
# -> { earliest_in_time(:user_id) },
|
|
78
|
+
# class_name: "Price"
|
|
79
|
+
# end
|
|
80
|
+
#
|
|
81
|
+
# # Works efficiently with includes
|
|
82
|
+
# User.includes(:current_price).each do |user|
|
|
83
|
+
# puts user.current_price&.amount
|
|
84
|
+
# end
|
|
85
|
+
#
|
|
86
|
+
# == Named scopes
|
|
87
|
+
#
|
|
88
|
+
# Define multiple time windows per model using named scopes.
|
|
89
|
+
#
|
|
90
|
+
# class Article < ActiveRecord::Base
|
|
91
|
+
# in_time_scope :published # Uses published_start_at, published_end_at
|
|
92
|
+
# in_time_scope :featured # Uses featured_start_at, featured_end_at
|
|
93
|
+
# end
|
|
94
|
+
#
|
|
95
|
+
# Article.in_time_published
|
|
96
|
+
# Article.in_time_featured
|
|
97
|
+
# article.in_time_published?
|
|
98
|
+
#
|
|
99
|
+
# == Custom columns
|
|
100
|
+
#
|
|
101
|
+
# class Event < ActiveRecord::Base
|
|
102
|
+
# in_time_scope start_at: { column: :available_at },
|
|
103
|
+
# end_at: { column: :expired_at }
|
|
104
|
+
# end
|
|
105
|
+
#
|
|
106
|
+
# == Error handling
|
|
107
|
+
#
|
|
108
|
+
# - ColumnNotFoundError: Raised at class load time if column doesn't exist
|
|
109
|
+
# - ConfigurationError: Raised at scope call time for invalid configurations
|
|
110
|
+
#
|
|
111
|
+
# @see ClassMethods#in_time_scope
|
|
112
|
+
module ActiveRecordInTimeScope
|
|
113
|
+
# Base error class for ActiveRecordInTimeScope errors
|
|
114
|
+
class Error < StandardError; end
|
|
115
|
+
|
|
116
|
+
# Raised when a specified column does not exist on the table
|
|
117
|
+
# @note This error is raised at class load time
|
|
118
|
+
class ColumnNotFoundError < Error; end
|
|
119
|
+
|
|
120
|
+
# Raised when the scope configuration is invalid
|
|
121
|
+
# @note This error is raised when the scope or instance method is called
|
|
122
|
+
class ConfigurationError < Error; end
|
|
123
|
+
|
|
124
|
+
# @api private
|
|
125
|
+
def self.included(model)
|
|
126
|
+
model.extend ClassMethods
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
ActiveSupport.on_load(:active_record) do
|
|
131
|
+
include ActiveRecordInTimeScope
|
|
132
|
+
end
|
data/mise.toml
ADDED
data/rbs_collection.yaml
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# RBS collection configuration
|
|
2
|
+
# Run `rbs collection install` to download external gem signatures
|
|
3
|
+
|
|
4
|
+
sources:
|
|
5
|
+
- type: git
|
|
6
|
+
name: ruby/gem_rbs_collection
|
|
7
|
+
remote: https://github.com/ruby/gem_rbs_collection.git
|
|
8
|
+
revision: main
|
|
9
|
+
repo_dir: gems
|
|
10
|
+
|
|
11
|
+
path: .gem_rbs_collection
|
|
12
|
+
|
|
13
|
+
gems:
|
|
14
|
+
- name: activerecord
|
|
15
|
+
- name: activesupport
|
|
16
|
+
- name: activemodel
|