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.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -2
- data/.rulesync/commands/translate-readme.md +46 -0
- data/{CLAUDE.md → .rulesync/rules/project.md} +23 -7
- data/README.md +104 -223
- 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/in_time_scope/class_methods.rb +393 -0
- data/lib/in_time_scope/version.rb +2 -1
- data/lib/in_time_scope.rb +115 -213
- data/rbs_collection.yaml +16 -0
- data/rulesync.jsonc +6 -0
- data/sig/in_time_scope.rbs +90 -1
- metadata +75 -7
|
@@ -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
|