in_time_scope 0.1.4 → 0.1.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c5ab9dff9b5a5abce4d0418d2a8ec3895126a910a0926f1a449fc55bad3cebd4
4
- data.tar.gz: '082a9f889d860c0dc7f587fafb77baaa932ccc864e8dfe06b10b92ab9644308a'
3
+ metadata.gz: 3938b69e9b635a2d488513fbba9a0af9171e163accf5a776357f837f93783830
4
+ data.tar.gz: eb16bb16cb6681cc8ffa67b10aa32b8e349f5df87734436a6e5d8ff7afebd26a
5
5
  SHA512:
6
- metadata.gz: 7df8620094c225af106a3ed909dfa3af5abd328630e1a571cbe0943ecf5a8f965bc1c56286493181305a44fc87a148b055247c12291ee9110b49ca700b45b1bc
7
- data.tar.gz: 7dc373ec631d291cbdb9c73acaec4bc21d5271495a1131681a3a8aa6929480a28a886010eb3bcc209951825b5c92590a13730aae7ec119a09d96fb66d17f8218
6
+ metadata.gz: 0cea4dc04116f248f8343902d9aeeb875e6a3cac9dc6c772a9462daea7d99a526862d715af5e94fa45826896ab5cd832a55b7f31fb7b1a94e12649a0ea059984
7
+ data.tar.gz: e55bc250bd8d38b6cc7fb5a2b1d81d808948244defe93aa4715fb052967f8b3318d62b5dd58e614b03ddaf528a3ddb7ef4ae07786f9dbc67e2eced0c28065d9d
data/.rubocop.yml CHANGED
@@ -1,5 +1,5 @@
1
1
  AllCops:
2
- TargetRubyVersion: 3.1
2
+ TargetRubyVersion: 3.0
3
3
  NewCops: enable
4
4
  SuggestExtensions: false
5
5
 
data/README.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  A Ruby gem that adds time-window scopes to ActiveRecord models. It provides a convenient way to query records that fall within specific time periods (between `start_at` and `end_at` timestamps), with support for nullable columns, custom column names, and multiple scopes per model.
4
4
 
5
+ ## Motivation
6
+
7
+ This gem is inspired by [onk/shibaraku](https://github.com/onk/shibaraku). While shibaraku is a great gem, I wanted to extend it with additional features:
8
+
9
+ - **Nullable column handling**: shibaraku generates SQL with `OR` conditions when columns allow `NULL`, which can impact query performance. InTimeScope auto-detects column nullability from the schema and generates optimized queries.
10
+ - **Named scopes**: shibaraku only provides `in_time` method. InTimeScope allows multiple named scopes like `in_time_published`, `in_time_featured` per model.
11
+ - **Start-only / End-only patterns**: Support for versioned records (start_at only, no end_at) and expiration patterns (end_at only, no start_at).
12
+ - **has_one association support**: `latest_in_time` and `earliest_in_time` scopes optimized for `has_one` with `includes`, using NOT EXISTS subqueries.
13
+
14
+ These features required significant architectural changes, so I created a new gem rather than extending shibaraku.
15
+
5
16
  ## Installation
6
17
 
7
18
  Install the gem and add to the application's Gemfile by executing:
@@ -30,8 +41,6 @@ create_table :events do |t|
30
41
  end
31
42
 
32
43
  class Event < ActiveRecord::Base
33
- include InTimeScope
34
-
35
44
  # Uses start_at / end_at by default
36
45
  in_time_scope
37
46
  end
@@ -72,8 +81,6 @@ Event.in_time(Time.parse("2024-06-01 12:00:00"))
72
81
  # => SELECT "events".* FROM "events" WHERE ("events"."start_at" <= '2024-06-01 12:00:00.000000') AND ("events"."end_at" > '2024-06-01 12:00:00.000000')
73
82
 
74
83
  class Event < ActiveRecord::Base
75
- include InTimeScope
76
-
77
84
  # Explicitly mark columns as NOT NULL (even if the DB allows NULL)
78
85
  in_time_scope start_at: { null: false }, end_at: { null: false }
79
86
  end
@@ -94,8 +101,8 @@ Use these options in `in_time_scope` to customize column behavior.
94
101
  ### Alternative: Start-Only History (No `end_at`)
95
102
  Use this when periods never overlap and you want exactly one "current" row.
96
103
 
97
- Assumptions:
98
- - `start_at` is always present
104
+ **Requirements:**
105
+ - `start_at` must be NOT NULL (a `ConfigurationError` is raised otherwise)
99
106
  - periods never overlap (validated)
100
107
  - the latest row is the current one
101
108
 
@@ -103,8 +110,6 @@ If your table still has an `end_at` column but you want to ignore it, disable it
103
110
 
104
111
  ```ruby
105
112
  class Event < ActiveRecord::Base
106
- include InTimeScope
107
-
108
113
  # Ignore end_at even if the column exists
109
114
  in_time_scope start_at: { null: false }, end_at: { column: nil }
110
115
  end
@@ -130,22 +135,20 @@ CREATE INDEX index_events_on_start_at ON events (start_at);
130
135
  ### Alternative: End-Only Expiration (No `start_at`)
131
136
  Use this when a record is active immediately and expires at `end_at`.
132
137
 
133
- Assumptions:
138
+ **Requirements:**
139
+ - `end_at` must be NOT NULL (a `ConfigurationError` is raised otherwise)
134
140
  - `start_at` is not used (implicit "always active")
135
- - `end_at` can be `NULL` for "never expires"
136
141
 
137
142
  If your table still has a `start_at` column but you want to ignore it, disable it via options:
138
143
 
139
144
  ```ruby
140
145
  class Event < ActiveRecord::Base
141
- include InTimeScope
142
-
143
146
  # Ignore start_at and only use end_at
144
- in_time_scope start_at: { column: nil }, end_at: { null: true }
147
+ in_time_scope start_at: { column: nil }, end_at: { null: false }
145
148
  end
146
149
 
147
150
  Event.in_time(Time.parse("2024-06-01 12:00:00"))
148
- # => SELECT "events".* FROM "events" WHERE ("events"."end_at" IS NULL OR "events"."end_at" > '2024-06-01 12:00:00.000000')
151
+ # => SELECT "events".* FROM "events" WHERE "events"."end_at" > '2024-06-01 12:00:00.000000'
149
152
  ```
150
153
 
151
154
  Recommended index:
@@ -168,8 +171,6 @@ create_table :events do |t|
168
171
  end
169
172
 
170
173
  class Event < ActiveRecord::Base
171
- include InTimeScope
172
-
173
174
  # Use different column names
174
175
  in_time_scope start_at: { column: :available_at }, end_at: { column: :expired_at }
175
176
 
@@ -189,8 +190,6 @@ Use the `prefix: true` option if you prefer the scope name as a prefix instead o
189
190
 
190
191
  ```ruby
191
192
  class Event < ActiveRecord::Base
192
- include InTimeScope
193
-
194
193
  # With prefix: true, the method name becomes published_in_time instead of in_time_published
195
194
  in_time_scope :published, prefix: true
196
195
  end
@@ -201,7 +200,9 @@ Event.published_in_time
201
200
 
202
201
  ### Using with `has_one` Associations
203
202
 
204
- The start-only pattern provides scopes for `has_one` associations:
203
+ The start-only and end-only patterns provide `latest_in_time` and `earliest_in_time` scopes for efficient `has_one` associations.
204
+
205
+ **Note:** These scopes are NOT available for full time window patterns (both `start_at` and `end_at`), because the concept of "latest" or "earliest" is ambiguous when there's a time range.
205
206
 
206
207
  #### Simple approach: `in_time` + `order`
207
208
 
@@ -209,7 +210,6 @@ The start-only pattern provides scopes for `has_one` associations:
209
210
 
210
211
  ```ruby
211
212
  class Price < ActiveRecord::Base
212
- include InTimeScope
213
213
  belongs_to :user
214
214
 
215
215
  in_time_scope start_at: { null: false }, end_at: { column: nil }
@@ -281,8 +281,6 @@ If you specify a scope name but the expected columns don't exist, a `ColumnNotFo
281
281
 
282
282
  ```ruby
283
283
  class Event < ActiveRecord::Base
284
- include InTimeScope
285
-
286
284
  # This will raise ColumnNotFoundError if hoge_start_at or hoge_end_at columns don't exist
287
285
  in_time_scope :hoge
288
286
  end
data/Steepfile ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Steepfile for InTimeScope type checking
4
+
5
+ target :lib do
6
+ signature "sig"
7
+
8
+ check "lib"
9
+
10
+ # Use RBS collection for external gem types
11
+ collection_config "rbs_collection.yaml"
12
+
13
+ # Configure libraries
14
+ library "time"
15
+
16
+ # Ignore implementation details that use ActiveRecord internals
17
+ # The public API is properly typed, but internal methods use
18
+ # dynamic ActiveRecord features that are hard to type statically
19
+ configure_code_diagnostics do |hash|
20
+ # Allow untyped method calls for ActiveRecord dynamic methods
21
+ hash[Steep::Diagnostic::Ruby::NoMethod] = :hint
22
+ hash[Steep::Diagnostic::Ruby::UnknownInstanceVariable] = :hint
23
+ hash[Steep::Diagnostic::Ruby::RequiredBlockMissing] = :hint
24
+ end
25
+ end
@@ -0,0 +1,345 @@
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
+ # @param prefix [Boolean] If true, creates +<scope_name>_in_time+ instead of +in_time_<scope_name>+
25
+ #
26
+ # @raise [ColumnNotFoundError] When a specified column doesn't exist (at class load time)
27
+ # @raise [ConfigurationError] When both columns are nil, or when using start-only/end-only
28
+ # pattern with a nullable column (at scope call time)
29
+ #
30
+ # @return [void]
31
+ #
32
+ # == Examples
33
+ #
34
+ # Default scope with nullable columns:
35
+ #
36
+ # in_time_scope
37
+ # # Creates: Model.in_time, model.in_time?
38
+ #
39
+ # Named scope:
40
+ #
41
+ # in_time_scope :published
42
+ # # Creates: Model.in_time_published, model.in_time_published?
43
+ # # Uses: published_start_at, published_end_at columns
44
+ #
45
+ # Custom columns:
46
+ #
47
+ # in_time_scope start_at: { column: :available_at }, end_at: { column: :expired_at }
48
+ #
49
+ # Start-only pattern (for history tracking):
50
+ #
51
+ # in_time_scope start_at: { null: false }, end_at: { column: nil }
52
+ # # Also creates: Model.latest_in_time(:foreign_key), Model.earliest_in_time(:foreign_key)
53
+ #
54
+ # End-only pattern (for expiration):
55
+ #
56
+ # in_time_scope start_at: { column: nil }, end_at: { null: false }
57
+ # # Also creates: Model.latest_in_time(:foreign_key), Model.earliest_in_time(:foreign_key)
58
+ #
59
+ def in_time_scope(scope_name = :in_time, start_at: {}, end_at: {}, prefix: false)
60
+ table_column_hash = columns_hash
61
+ time_column_prefix = scope_name == :in_time ? "" : "#{scope_name}_"
62
+
63
+ start_at_column = start_at.fetch(:column, :"#{time_column_prefix}start_at")
64
+ end_at_column = end_at.fetch(:column, :"#{time_column_prefix}end_at")
65
+
66
+ start_at_null = fetch_null_option(start_at, start_at_column, table_column_hash)
67
+ end_at_null = fetch_null_option(end_at, end_at_column, table_column_hash)
68
+
69
+ scope_method_name = method_name(scope_name, prefix)
70
+
71
+ define_scope_methods(
72
+ scope_method_name,
73
+ start_at_column: start_at_column,
74
+ start_at_null: start_at_null,
75
+ end_at_column: end_at_column,
76
+ end_at_null: end_at_null
77
+ )
78
+ end
79
+
80
+ private
81
+
82
+ # Fetches the null option for a column, auto-detecting from schema if not specified
83
+ #
84
+ # @param config [Hash] Configuration hash with optional :null key
85
+ # @param column [Symbol, nil] Column name
86
+ # @param table_column_hash [Hash] Hash of column metadata from ActiveRecord
87
+ # @return [Boolean, nil] Whether the column allows NULL values
88
+ # @raise [ColumnNotFoundError] When the column doesn't exist in the table
89
+ # @api private
90
+ def fetch_null_option(config, column, table_column_hash)
91
+ return nil if column.nil?
92
+ return config[:null] if config.key?(:null)
93
+
94
+ column_info = table_column_hash[column.to_s]
95
+ raise ColumnNotFoundError, "Column '#{column}' does not exist on table '#{table_name}'" if column_info.nil?
96
+
97
+ column_info.null
98
+ end
99
+
100
+ # Generates the method name for the scope
101
+ #
102
+ # @param scope_name [Symbol] The scope name
103
+ # @param prefix [Boolean] Whether to use prefix style
104
+ # @return [Symbol] The generated method name
105
+ # @api private
106
+ def method_name(scope_name, prefix)
107
+ return :in_time if scope_name == :in_time
108
+
109
+ prefix ? "#{scope_name}_in_time" : "in_time_#{scope_name}"
110
+ end
111
+
112
+ # Defines the appropriate scope methods based on configuration
113
+ #
114
+ # @param scope_method_name [Symbol] The name of the scope method to create
115
+ # @param start_at_column [Symbol, nil] Start column name
116
+ # @param start_at_null [Boolean, nil] Whether start column allows NULL
117
+ # @param end_at_column [Symbol, nil] End column name
118
+ # @param end_at_null [Boolean, nil] Whether end column allows NULL
119
+ # @return [void]
120
+ # @api private
121
+ def define_scope_methods(scope_method_name, start_at_column:, start_at_null:, end_at_column:, end_at_null:)
122
+ # Define class-level scope and instance method
123
+ if start_at_column.nil? && end_at_column.nil?
124
+ define_error_scope_and_method(scope_method_name,
125
+ "At least one of start_at or end_at must be specified")
126
+ elsif end_at_column.nil?
127
+ # Start-only pattern (history tracking) - requires non-nullable column
128
+ if start_at_null
129
+ define_error_scope_and_method(scope_method_name,
130
+ "Start-only pattern requires non-nullable column. " \
131
+ "Set `start_at: { null: false }` or add an end_at column")
132
+ else
133
+ define_start_only_scope(scope_method_name, start_at_column)
134
+ define_instance_method(scope_method_name, start_at_column, start_at_null, end_at_column, end_at_null)
135
+ end
136
+ elsif start_at_column.nil?
137
+ # End-only pattern (expiration) - requires non-nullable column
138
+ if end_at_null
139
+ define_error_scope_and_method(scope_method_name,
140
+ "End-only pattern requires non-nullable column. " \
141
+ "Set `end_at: { null: false }` or add a start_at column")
142
+ else
143
+ define_end_only_scope(scope_method_name, end_at_column)
144
+ define_instance_method(scope_method_name, start_at_column, start_at_null, end_at_column, end_at_null)
145
+ end
146
+ else
147
+ # Both start and end
148
+ define_full_scope(scope_method_name, start_at_column, start_at_null, end_at_column, end_at_null)
149
+ define_instance_method(scope_method_name, start_at_column, start_at_null, end_at_column, end_at_null)
150
+ end
151
+ end
152
+
153
+ # Defines a scope and instance method that raise ConfigurationError
154
+ #
155
+ # @param scope_method_name [Symbol] The name of the scope method
156
+ # @param message [String] The error message
157
+ # @return [void]
158
+ # @api private
159
+ def define_error_scope_and_method(scope_method_name, message)
160
+ err_message = message
161
+
162
+ scope scope_method_name, ->(_time = Time.current) {
163
+ raise InTimeScope::ConfigurationError, err_message
164
+ }
165
+
166
+ define_method("#{scope_method_name}?") do |_time = Time.current|
167
+ raise InTimeScope::ConfigurationError, err_message
168
+ end
169
+ end
170
+
171
+ # Defines a start-only scope (for history tracking pattern)
172
+ #
173
+ # @param scope_method_name [Symbol] The name of the scope method
174
+ # @param column [Symbol] The start column name
175
+ # @return [void]
176
+ # @api private
177
+ def define_start_only_scope(scope_method_name, column)
178
+ col = column
179
+
180
+ # Simple scope - WHERE only, no ORDER BY
181
+ # Users can add .order(start_at: :desc) externally if needed
182
+ scope scope_method_name, ->(time = Time.current) {
183
+ where(col => ..time)
184
+ }
185
+
186
+ # Efficient scope for has_one + includes using NOT EXISTS subquery
187
+ # Usage: has_one :current_price, -> { latest_in_time(:user_id) }, class_name: 'Price'
188
+ define_latest_one_scope(scope_method_name, column)
189
+ define_earliest_one_scope(scope_method_name, column)
190
+ end
191
+
192
+ # Defines the latest_in_time scope using NOT EXISTS subquery
193
+ #
194
+ # This scope efficiently finds the latest record per foreign key,
195
+ # suitable for use with has_one associations and includes.
196
+ #
197
+ # @param scope_method_name [Symbol] The base scope method name
198
+ # @param column [Symbol] The timestamp column name
199
+ # @return [void]
200
+ #
201
+ # @example Usage with has_one
202
+ # has_one :current_price, -> { latest_in_time(:user_id) }, class_name: 'Price'
203
+ #
204
+ # @api private
205
+ def define_latest_one_scope(scope_method_name, column)
206
+ latest_method_name = scope_method_name == :in_time ? :latest_in_time : :"latest_#{scope_method_name}"
207
+ col = column
208
+
209
+ # NOT EXISTS approach: select records where no later record exists for the same foreign key
210
+ scope latest_method_name, ->(foreign_key, time = Time.current) {
211
+ p2 = arel_table.alias("p2")
212
+
213
+ subquery = Arel::SelectManager.new(arel_table)
214
+ .from(p2)
215
+ .project(Arel.sql("1"))
216
+ .where(p2[foreign_key].eq(arel_table[foreign_key]))
217
+ .where(p2[col].lteq(time))
218
+ .where(p2[col].gt(arel_table[col]))
219
+ .where(p2[:id].not_eq(arel_table[:id]))
220
+
221
+ not_exists = Arel::Nodes::Not.new(Arel::Nodes::Exists.new(subquery.ast))
222
+
223
+ where(col => ..time).where(not_exists)
224
+ }
225
+ end
226
+
227
+ # Defines the earliest_in_time scope using NOT EXISTS subquery
228
+ #
229
+ # This scope efficiently finds the earliest record per foreign key,
230
+ # suitable for use with has_one associations and includes.
231
+ #
232
+ # @param scope_method_name [Symbol] The base scope method name
233
+ # @param column [Symbol] The timestamp column name
234
+ # @return [void]
235
+ #
236
+ # @example Usage with has_one
237
+ # has_one :first_price, -> { earliest_in_time(:user_id) }, class_name: 'Price'
238
+ #
239
+ # @api private
240
+ def define_earliest_one_scope(scope_method_name, column)
241
+ earliest_method_name = scope_method_name == :in_time ? :earliest_in_time : :"earliest_#{scope_method_name}"
242
+ col = column
243
+
244
+ # NOT EXISTS approach: select records where no earlier record exists for the same foreign key
245
+ scope earliest_method_name, ->(foreign_key, time = Time.current) {
246
+ p2 = arel_table.alias("p2")
247
+
248
+ subquery = Arel::SelectManager.new(arel_table)
249
+ .from(p2)
250
+ .project(Arel.sql("1"))
251
+ .where(p2[foreign_key].eq(arel_table[foreign_key]))
252
+ .where(p2[col].lteq(time))
253
+ .where(p2[col].lt(arel_table[col]))
254
+ .where(p2[:id].not_eq(arel_table[:id]))
255
+
256
+ not_exists = Arel::Nodes::Not.new(Arel::Nodes::Exists.new(subquery.ast))
257
+
258
+ where(col => ..time).where(not_exists)
259
+ }
260
+ end
261
+
262
+ # Defines an end-only scope (for expiration pattern)
263
+ #
264
+ # @param scope_method_name [Symbol] The name of the scope method
265
+ # @param column [Symbol] The end column name
266
+ # @return [void]
267
+ # @api private
268
+ def define_end_only_scope(scope_method_name, column)
269
+ col = column
270
+
271
+ scope scope_method_name, ->(time = Time.current) {
272
+ where.not(col => ..time)
273
+ }
274
+
275
+ # Efficient scope for has_one + includes using NOT EXISTS subquery
276
+ define_latest_one_scope(scope_method_name, column)
277
+ define_earliest_one_scope(scope_method_name, column)
278
+ end
279
+
280
+ # Defines a full scope with both start and end columns
281
+ #
282
+ # @param scope_method_name [Symbol] The name of the scope method
283
+ # @param start_column [Symbol] The start column name
284
+ # @param start_null [Boolean] Whether start column allows NULL
285
+ # @param end_column [Symbol] The end column name
286
+ # @param end_null [Boolean] Whether end column allows NULL
287
+ # @return [void]
288
+ # @api private
289
+ def define_full_scope(scope_method_name, start_column, start_null, end_column, end_null)
290
+ s_col = start_column
291
+ e_col = end_column
292
+
293
+ scope scope_method_name, ->(time = Time.current) {
294
+ start_scope = if start_null
295
+ where(s_col => nil).or(where(s_col => ..time))
296
+ else
297
+ where(s_col => ..time)
298
+ end
299
+
300
+ end_scope = if end_null
301
+ where(e_col => nil).or(where.not(e_col => ..time))
302
+ else
303
+ where.not(e_col => ..time)
304
+ end
305
+
306
+ start_scope.merge(end_scope)
307
+ }
308
+
309
+ # NOTE: latest_in_time / earliest_in_time are NOT defined for full scope (both start and end)
310
+ # because the concept of "latest" or "earliest" is ambiguous when there's a time range.
311
+ # These scopes are only available for start-only or end-only patterns.
312
+ end
313
+
314
+ # Defines the instance method to check if a record is within the time window
315
+ #
316
+ # @param scope_method_name [Symbol] The name of the scope method
317
+ # @param start_column [Symbol, nil] The start column name
318
+ # @param start_null [Boolean, nil] Whether start column allows NULL
319
+ # @param end_column [Symbol, nil] The end column name
320
+ # @param end_null [Boolean, nil] Whether end column allows NULL
321
+ # @return [void]
322
+ # @api private
323
+ def define_instance_method(scope_method_name, start_column, start_null, end_column, end_null)
324
+ define_method("#{scope_method_name}?") do |time = Time.current|
325
+ start_ok = if start_column.nil?
326
+ true
327
+ elsif start_null
328
+ send(start_column).nil? || send(start_column) <= time
329
+ else
330
+ send(start_column) <= time
331
+ end
332
+
333
+ end_ok = if end_column.nil?
334
+ true
335
+ elsif end_null
336
+ send(end_column).nil? || send(end_column) > time
337
+ else
338
+ send(end_column) > time
339
+ end
340
+
341
+ start_ok && end_ok
342
+ end
343
+ end
344
+ end
345
+ 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.5"
5
6
  end
data/lib/in_time_scope.rb CHANGED
@@ -2,227 +2,129 @@
2
2
 
3
3
  require "active_record"
4
4
  require_relative "in_time_scope/version"
5
-
5
+ require_relative "in_time_scope/class_methods"
6
+
7
+ # InTimeScope 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
+ # InTimeScope 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
6
112
  module InTimeScope
113
+ # Base error class for InTimeScope errors
7
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
8
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
9
122
  class ConfigurationError < Error; end
10
123
 
124
+ # @api private
11
125
  def self.included(model)
12
126
  model.extend ClassMethods
13
127
  end
14
-
15
- module ClassMethods
16
- def in_time_scope(scope_name = :in_time, start_at: {}, end_at: {}, prefix: false)
17
- table_column_hash = columns_hash
18
- time_column_prefix = scope_name == :in_time ? "" : "#{scope_name}_"
19
-
20
- start_at_column = start_at.fetch(:column, :"#{time_column_prefix}start_at")
21
- end_at_column = end_at.fetch(:column, :"#{time_column_prefix}end_at")
22
-
23
- start_at_null = fetch_null_option(start_at, start_at_column, table_column_hash)
24
- end_at_null = fetch_null_option(end_at, end_at_column, table_column_hash)
25
-
26
- scope_method_name = method_name(scope_name, prefix)
27
-
28
- define_scope_methods(scope_method_name, start_at_column:, start_at_null:, end_at_column:, end_at_null:)
29
- end
30
-
31
- private
32
-
33
- def fetch_null_option(config, column, table_column_hash)
34
- return nil if column.nil?
35
- return config[:null] if config.key?(:null)
36
-
37
- column_info = table_column_hash[column.to_s]
38
- raise ColumnNotFoundError, "Column '#{column}' does not exist on table '#{table_name}'" if column_info.nil?
39
-
40
- column_info.null
41
- end
42
-
43
- def method_name(scope_name, prefix)
44
- return :in_time if scope_name == :in_time
45
-
46
- prefix ? "#{scope_name}_in_time" : "in_time_#{scope_name}"
47
- end
48
-
49
- def define_scope_methods(scope_method_name, start_at_column:, start_at_null:, end_at_column:, end_at_null:)
50
- # Define class-level scope
51
- if start_at_column.nil? && end_at_column.nil?
52
- # Both disabled - return all
53
- raise ConfigurationError, "At least one of start_at or end_at must be specified"
54
- elsif end_at_column.nil?
55
- # Start-only pattern (history tracking)
56
- define_start_only_scope(scope_method_name, start_at_column, start_at_null)
57
- elsif start_at_column.nil?
58
- # End-only pattern (expiration)
59
- define_end_only_scope(scope_method_name, end_at_column, end_at_null)
60
- else
61
- # Both start and end
62
- define_full_scope(scope_method_name, start_at_column, start_at_null, end_at_column, end_at_null)
63
- end
64
-
65
- # Define instance method
66
- define_instance_method(scope_method_name, start_at_column, start_at_null, end_at_column, end_at_null)
67
- end
68
-
69
- def define_start_only_scope(scope_method_name, start_column, start_null)
70
- # Simple scope - WHERE only, no ORDER BY
71
- # Users can add .order(start_at: :desc) externally if needed
72
- if start_null
73
- scope scope_method_name, ->(time = Time.current) {
74
- where(arel_table[start_column].eq(nil).or(arel_table[start_column].lteq(time)))
75
- }
76
- else
77
- scope scope_method_name, ->(time = Time.current) {
78
- where(arel_table[start_column].lteq(time))
79
- }
80
- end
81
-
82
- # Efficient scope for has_one + includes using NOT EXISTS subquery
83
- # Usage: has_one :current_price, -> { latest_in_time(:user_id) }, class_name: 'Price'
84
- define_latest_one_scope(scope_method_name, start_column, start_null)
85
- define_earliest_one_scope(scope_method_name, start_column, start_null)
86
- end
87
-
88
- def define_latest_one_scope(scope_method_name, start_column, start_null)
89
- latest_method_name = scope_method_name == :in_time ? :latest_in_time : :"latest_#{scope_method_name}"
90
- tbl = table_name
91
- col = start_column
92
-
93
- # NOT EXISTS approach: select records where no later record exists for the same foreign key
94
- # SELECT * FROM prices p1 WHERE start_at <= ? AND NOT EXISTS (
95
- # SELECT 1 FROM prices p2 WHERE p2.user_id = p1.user_id
96
- # AND p2.start_at <= ? AND p2.start_at > p1.start_at
97
- # )
98
- scope latest_method_name, ->(foreign_key, time = Time.current) {
99
- fk = foreign_key
100
-
101
- not_exists_sql = if start_null
102
- <<~SQL.squish
103
- NOT EXISTS (
104
- SELECT 1 FROM #{tbl} p2
105
- WHERE p2.#{fk} = #{tbl}.#{fk}
106
- AND (p2.#{col} IS NULL OR p2.#{col} <= ?)
107
- AND (p2.#{col} IS NULL OR p2.#{col} > #{tbl}.#{col} OR #{tbl}.#{col} IS NULL)
108
- AND p2.id != #{tbl}.id
109
- )
110
- SQL
111
- else
112
- <<~SQL.squish
113
- NOT EXISTS (
114
- SELECT 1 FROM #{tbl} p2
115
- WHERE p2.#{fk} = #{tbl}.#{fk}
116
- AND p2.#{col} <= ?
117
- AND p2.#{col} > #{tbl}.#{col}
118
- )
119
- SQL
120
- end
121
-
122
- base_condition = if start_null
123
- where(arel_table[col].eq(nil).or(arel_table[col].lteq(time)))
124
- else
125
- where(arel_table[col].lteq(time))
126
- end
127
-
128
- base_condition.where(not_exists_sql, time)
129
- }
130
- end
131
-
132
- def define_earliest_one_scope(scope_method_name, start_column, start_null)
133
- earliest_method_name = scope_method_name == :in_time ? :earliest_in_time : :"earliest_#{scope_method_name}"
134
- tbl = table_name
135
- col = start_column
136
- scope earliest_method_name, ->(foreign_key, time = Time.current) {
137
- fk = foreign_key
138
- not_exists_sql = if start_null
139
- <<~SQL.squish
140
- NOT EXISTS (
141
- SELECT 1 FROM #{tbl} p2
142
- WHERE p2.#{fk} = #{tbl}.#{fk}
143
- AND (p2.#{col} IS NULL OR p2.#{col} <= ?)
144
- AND (p2.#{col} IS NULL OR p2.#{col} < #{tbl}.#{col} OR #{tbl}.#{col} IS NULL)
145
- AND p2.id != #{tbl}.id
146
- )
147
- SQL
148
- else
149
- <<~SQL.squish
150
- NOT EXISTS (
151
- SELECT 1 FROM #{tbl} p2
152
- WHERE p2.#{fk} = #{tbl}.#{fk}
153
- AND p2.#{col} <= ?
154
- AND p2.#{col} < #{tbl}.#{col}
155
- )
156
- SQL
157
- end
158
- base_condition = if start_null
159
- where(arel_table[col].eq(nil).or(arel_table[col].lteq(time)))
160
- else
161
- where(arel_table[col].lteq(time))
162
- end
163
-
164
- base_condition.where(not_exists_sql, time)
165
- }
166
- end
167
-
168
- def define_end_only_scope(scope_method_name, end_column, end_null)
169
- if end_null
170
- scope scope_method_name, ->(time = Time.current) {
171
- where(arel_table[end_column].eq(nil).or(arel_table[end_column].gt(time)))
172
- }
173
- else
174
- scope scope_method_name, ->(time = Time.current) {
175
- where(arel_table[end_column].gt(time))
176
- }
177
- end
178
-
179
- define_latest_one_scope(scope_method_name, end_column, end_null)
180
- define_earliest_one_scope(scope_method_name, end_column, end_null)
181
- end
182
-
183
- def define_full_scope(scope_method_name, start_column, start_null, end_column, end_null)
184
- scope scope_method_name, ->(time = Time.current) {
185
- start_condition = if start_null
186
- arel_table[start_column].eq(nil).or(arel_table[start_column].lteq(time))
187
- else
188
- arel_table[start_column].lteq(time)
189
- end
190
-
191
- end_condition = if end_null
192
- arel_table[end_column].eq(nil).or(arel_table[end_column].gt(time))
193
- else
194
- arel_table[end_column].gt(time)
195
- end
196
-
197
- where(start_condition).where(end_condition)
198
- }
199
-
200
- define_latest_one_scope(scope_method_name, start_column, start_null)
201
- define_earliest_one_scope(scope_method_name, start_column, start_null)
202
- end
203
-
204
- def define_instance_method(scope_method_name, start_column, start_null, end_column, end_null)
205
- define_method("#{scope_method_name}?") do |time = Time.current|
206
- start_ok = if start_column.nil?
207
- true
208
- elsif start_null
209
- send(start_column).nil? || send(start_column) <= time
210
- else
211
- send(start_column) <= time
212
- end
213
-
214
- end_ok = if end_column.nil?
215
- true
216
- elsif end_null
217
- send(end_column).nil? || send(end_column) > time
218
- else
219
- send(end_column) > time
220
- end
221
-
222
- start_ok && end_ok
223
- end
224
- end
225
- end
226
128
  end
227
129
 
228
130
  ActiveSupport.on_load(:active_record) do
@@ -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
@@ -1,4 +1,83 @@
1
+ # Type definitions for InTimeScope gem
2
+
1
3
  module InTimeScope
2
4
  VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+
6
+ # Base error class for InTimeScope errors
7
+ class Error < StandardError
8
+ end
9
+
10
+ # Raised when a specified column does not exist on the table
11
+ class ColumnNotFoundError < Error
12
+ end
13
+
14
+ # Raised when the scope configuration is invalid
15
+ class ConfigurationError < Error
16
+ end
17
+
18
+ def self.included: (Class model) -> void
19
+
20
+ # Configuration options for start_at column
21
+ type start_at_config = {
22
+ ?column: Symbol?,
23
+ ?null: bool
24
+ }
25
+
26
+ # Configuration options for end_at column
27
+ type end_at_config = {
28
+ ?column: Symbol?,
29
+ ?null: bool
30
+ }
31
+
32
+ # Class methods added to ActiveRecord models when InTimeScope is included
33
+ module ClassMethods
34
+ # Defines time-window scopes for the model
35
+ #
36
+ # @param scope_name [Symbol] The name of the scope (default: :in_time)
37
+ # @param start_at [Hash] Configuration for the start column
38
+ # @param end_at [Hash] Configuration for the end column
39
+ # @param prefix [Boolean] If true, creates prefix-style method names
40
+ # @return [void]
41
+ # @raise [ColumnNotFoundError] When a specified column doesn't exist
42
+ # @raise [ConfigurationError] When the configuration is invalid (at scope call time)
43
+ def in_time_scope: (
44
+ ?Symbol scope_name,
45
+ ?start_at: start_at_config,
46
+ ?end_at: end_at_config,
47
+ ?prefix: bool
48
+ ) -> void
49
+
50
+ private
51
+
52
+ # Private implementation methods
53
+ # These use ActiveRecord internals and are typed as untyped for flexibility
54
+ def fetch_null_option: (untyped config, untyped column, untyped table_column_hash) -> untyped
55
+ def method_name: (Symbol scope_name, bool prefix) -> (Symbol | String)
56
+ def define_scope_methods: (untyped scope_method_name, start_at_column: untyped, start_at_null: untyped, end_at_column: untyped, end_at_null: untyped) -> void
57
+ def define_error_scope_and_method: (untyped scope_method_name, String message) -> void
58
+ def define_start_only_scope: (untyped scope_method_name, Symbol column) -> void
59
+ def define_latest_one_scope: (untyped scope_method_name, Symbol column) -> void
60
+ def define_earliest_one_scope: (untyped scope_method_name, Symbol column) -> void
61
+ def define_end_only_scope: (untyped scope_method_name, Symbol column) -> void
62
+ def define_full_scope: (untyped scope_method_name, Symbol start_column, untyped start_null, Symbol end_column, untyped end_null) -> void
63
+ def define_instance_method: (untyped scope_method_name, Symbol? start_column, untyped start_null, Symbol? end_column, untyped end_null) -> void
64
+ end
65
+ end
66
+
67
+ # Generated scope methods (dynamically defined)
68
+ # When you call `in_time_scope` on a model, it creates these methods:
69
+ #
70
+ # Class methods:
71
+ # Model.in_time(time = Time.current) -> ActiveRecord::Relation
72
+ # Model.in_time_<name>(time = Time.current) -> ActiveRecord::Relation (for named scopes)
73
+ # Model.latest_in_time(foreign_key, time = Time.current) -> ActiveRecord::Relation (start-only/end-only)
74
+ # Model.earliest_in_time(foreign_key, time = Time.current) -> ActiveRecord::Relation (start-only/end-only)
75
+ #
76
+ # Instance methods:
77
+ # model.in_time?(time = Time.current) -> bool
78
+ # model.in_time_<name>?(time = Time.current) -> bool (for named scopes)
79
+
80
+ # Extend ActiveRecord::Base to include InTimeScope
81
+ class ActiveRecord::Base
82
+ extend InTimeScope::ClassMethods
4
83
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: in_time_scope
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - kyohah
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-01-29 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '6.0'
18
+ version: '0'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '6.0'
25
+ version: '0'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: irb
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '13.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rbs
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: rspec
56
70
  requirement: !ruby/object:Gem::Requirement
@@ -93,6 +107,34 @@ dependencies:
93
107
  - - ">="
94
108
  - !ruby/object:Gem::Version
95
109
  version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: steep
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ - !ruby/object:Gem::Dependency
125
+ name: yard
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0'
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
96
138
  description: InTimeScope provides time-window scopes for ActiveRecord models.
97
139
  email:
98
140
  - 3257272+kyohah@users.noreply.github.com
@@ -107,8 +149,11 @@ files:
107
149
  - LICENSE.txt
108
150
  - README.md
109
151
  - Rakefile
152
+ - Steepfile
110
153
  - lib/in_time_scope.rb
154
+ - lib/in_time_scope/class_methods.rb
111
155
  - lib/in_time_scope/version.rb
156
+ - rbs_collection.yaml
112
157
  - sig/in_time_scope.rbs
113
158
  homepage: https://github.com/kyohah/in_time_scope
114
159
  licenses:
@@ -126,14 +171,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
126
171
  requirements:
127
172
  - - ">="
128
173
  - !ruby/object:Gem::Version
129
- version: 3.1.0
174
+ version: 3.0.0
130
175
  required_rubygems_version: !ruby/object:Gem::Requirement
131
176
  requirements:
132
177
  - - ">="
133
178
  - !ruby/object:Gem::Version
134
179
  version: '0'
135
180
  requirements: []
136
- rubygems_version: 3.6.2
181
+ rubygems_version: 4.0.3
137
182
  specification_version: 4
138
183
  summary: Add time-window scopes to ActiveRecord models
139
184
  test_files: []