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 +4 -4
- data/.rubocop.yml +1 -1
- data/README.md +20 -22
- data/Steepfile +25 -0
- data/lib/in_time_scope/class_methods.rb +345 -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/sig/in_time_scope.rbs +80 -1
- metadata +51 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3938b69e9b635a2d488513fbba9a0af9171e163accf5a776357f837f93783830
|
|
4
|
+
data.tar.gz: eb16bb16cb6681cc8ffa67b10aa32b8e349f5df87734436a6e5d8ff7afebd26a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0cea4dc04116f248f8343902d9aeeb875e6a3cac9dc6c772a9462daea7d99a526862d715af5e94fa45826896ab5cd832a55b7f31fb7b1a94e12649a0ea059984
|
|
7
|
+
data.tar.gz: e55bc250bd8d38b6cc7fb5a2b1d81d808948244defe93aa4715fb052967f8b3318d62b5dd58e614b03ddaf528a3ddb7ef4ae07786f9dbc67e2eced0c28065d9d
|
data/.rubocop.yml
CHANGED
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
|
-
|
|
98
|
-
- `start_at` is
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
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
|
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
|
data/sig/in_time_scope.rbs
CHANGED
|
@@ -1,4 +1,83 @@
|
|
|
1
|
+
# Type definitions for InTimeScope gem
|
|
2
|
+
|
|
1
3
|
module InTimeScope
|
|
2
4
|
VERSION: String
|
|
3
|
-
|
|
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
|
+
version: 0.1.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- kyohah
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
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: '
|
|
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: '
|
|
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.
|
|
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:
|
|
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: []
|