in_time_scope 0.1.2 → 0.1.4
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/README.md +52 -27
- data/lib/in_time_scope/version.rb +1 -1
- data/lib/in_time_scope.rb +87 -53
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c5ab9dff9b5a5abce4d0418d2a8ec3895126a910a0926f1a449fc55bad3cebd4
|
|
4
|
+
data.tar.gz: '082a9f889d860c0dc7f587fafb77baaa932ccc864e8dfe06b10b92ab9644308a'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7df8620094c225af106a3ed909dfa3af5abd328630e1a571cbe0943ecf5a8f965bc1c56286493181305a44fc87a148b055247c12291ee9110b49ca700b45b1bc
|
|
7
|
+
data.tar.gz: 7dc373ec631d291cbdb9c73acaec4bc21d5271495a1131681a3a8aa6929480a28a886010eb3bcc209951825b5c92590a13730aae7ec119a09d96fb66d17f8218
|
data/README.md
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# InTimeScope
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/in_time_scope`. To experiment with that code, run `bin/console` for an interactive prompt.
|
|
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.
|
|
6
4
|
|
|
7
5
|
## Installation
|
|
8
6
|
|
|
@@ -39,11 +37,10 @@ class Event < ActiveRecord::Base
|
|
|
39
37
|
end
|
|
40
38
|
|
|
41
39
|
Event.in_time
|
|
42
|
-
# => SELECT "events".* FROM "events" WHERE ("events"."start_at" IS NULL OR "events"."start_at" <= '2026-01-24 19:50:05.738232') AND ("events"."end_at" IS NULL OR "events"."end_at" > '2026-01-24 19:50:05.738232')
|
|
40
|
+
# => SELECT "events".* FROM "events" WHERE ("events"."start_at" IS NULL OR "events"."start_at" <= '2026-01-24 19:50:05.738232') AND ("events"."end_at" IS NULL OR "events"."end_at" > '2026-01-24 19:50:05.738232')
|
|
43
41
|
|
|
44
42
|
# Check at a specific time
|
|
45
43
|
Event.in_time(Time.parse("2024-06-01 12:00:00"))
|
|
46
|
-
# => SELECT "events".* FROM "events" WHERE ("events"."start_at" IS NULL OR "events"."start_at" <= '2024-06-01 12:00:00.000000') AND ("events"."end_at" IS NULL OR "events"."end_at" > '2024-06-01 12:00:00.000000') /* loading for pp */ LIMIT $1 [["LIMIT", 11]]
|
|
47
44
|
|
|
48
45
|
# Is the current time within the window?
|
|
49
46
|
event = Event.first
|
|
@@ -68,11 +65,11 @@ end
|
|
|
68
65
|
|
|
69
66
|
# Column metadata is read when Rails boots; SQL is optimized for NOT NULL columns.
|
|
70
67
|
Event.in_time
|
|
71
|
-
# => SELECT "events".* FROM "events" WHERE ("events"."start_at" <= '2026-01-24 19:50:05.738232') AND ("events"."end_at" > '2026-01-24 19:50:05.738232')
|
|
68
|
+
# => SELECT "events".* FROM "events" WHERE ("events"."start_at" <= '2026-01-24 19:50:05.738232') AND ("events"."end_at" > '2026-01-24 19:50:05.738232')
|
|
72
69
|
|
|
73
70
|
# Check at a specific time
|
|
74
71
|
Event.in_time(Time.parse("2024-06-01 12:00:00"))
|
|
75
|
-
# => 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')
|
|
72
|
+
# => 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')
|
|
76
73
|
|
|
77
74
|
class Event < ActiveRecord::Base
|
|
78
75
|
include InTimeScope
|
|
@@ -113,20 +110,16 @@ class Event < ActiveRecord::Base
|
|
|
113
110
|
end
|
|
114
111
|
|
|
115
112
|
Event.in_time(Time.parse("2024-06-01 12:00:00"))
|
|
116
|
-
# => SELECT "events".* FROM "events" WHERE "events"."start_at" <= '2024-06-01 12:00:00.000000'
|
|
113
|
+
# => SELECT "events".* FROM "events" WHERE "events"."start_at" <= '2024-06-01 12:00:00.000000'
|
|
117
114
|
|
|
118
|
-
# Use .first to get the most recent single record
|
|
119
|
-
Event.in_time.first
|
|
120
|
-
# => SELECT "events".* FROM "events" WHERE "events"."start_at" <= '...' ORDER BY "events"."start_at" DESC LIMIT 1
|
|
115
|
+
# Use .first with order to get the most recent single record
|
|
116
|
+
Event.in_time.order(start_at: :desc).first
|
|
121
117
|
```
|
|
122
118
|
|
|
123
119
|
With no `end_at`, each row implicitly ends at the next row's `start_at`.
|
|
124
|
-
The scope returns all matching records
|
|
125
|
-
-
|
|
126
|
-
- Use
|
|
127
|
-
|
|
128
|
-
Boundary behavior is stable:
|
|
129
|
-
- `start_at <= NOW()` picks the newest row whose start has passed
|
|
120
|
+
The scope returns all matching records (WHERE only, no ORDER), so:
|
|
121
|
+
- Add `.order(start_at: :desc).first` for a single latest record
|
|
122
|
+
- Use `latest_in_time` for efficient `has_one` associations
|
|
130
123
|
|
|
131
124
|
Recommended index:
|
|
132
125
|
|
|
@@ -152,15 +145,7 @@ class Event < ActiveRecord::Base
|
|
|
152
145
|
end
|
|
153
146
|
|
|
154
147
|
Event.in_time(Time.parse("2024-06-01 12:00:00"))
|
|
155
|
-
# => SELECT "events".* FROM "events" WHERE ("events"."end_at" IS NULL OR "events"."end_at" > '2024-06-01 12:00:00.000000')
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
To fetch active rows:
|
|
159
|
-
|
|
160
|
-
```sql
|
|
161
|
-
SELECT *
|
|
162
|
-
FROM events
|
|
163
|
-
WHERE end_at IS NULL OR end_at > NOW();
|
|
148
|
+
# => SELECT "events".* FROM "events" WHERE ("events"."end_at" IS NULL OR "events"."end_at" > '2024-06-01 12:00:00.000000')
|
|
164
149
|
```
|
|
165
150
|
|
|
166
151
|
Recommended index:
|
|
@@ -216,7 +201,7 @@ Event.published_in_time
|
|
|
216
201
|
|
|
217
202
|
### Using with `has_one` Associations
|
|
218
203
|
|
|
219
|
-
The start-only pattern provides
|
|
204
|
+
The start-only pattern provides scopes for `has_one` associations:
|
|
220
205
|
|
|
221
206
|
#### Simple approach: `in_time` + `order`
|
|
222
207
|
|
|
@@ -266,6 +251,46 @@ end
|
|
|
266
251
|
|
|
267
252
|
The `latest_in_time(:foreign_key)` scope uses a `NOT EXISTS` subquery to filter at the database level, avoiding loading unnecessary records into memory.
|
|
268
253
|
|
|
254
|
+
#### Getting the earliest record: `earliest_in_time`
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
class User < ActiveRecord::Base
|
|
258
|
+
has_many :prices
|
|
259
|
+
|
|
260
|
+
# Uses NOT EXISTS subquery - only loads the earliest record per user
|
|
261
|
+
has_one :first_price,
|
|
262
|
+
-> { earliest_in_time(:user_id) },
|
|
263
|
+
class_name: "Price"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Direct access
|
|
267
|
+
user.first_price
|
|
268
|
+
# => Returns the earliest price where start_at <= Time.current
|
|
269
|
+
|
|
270
|
+
# Efficient with includes
|
|
271
|
+
User.includes(:first_price).each do |user|
|
|
272
|
+
puts user.first_price&.amount
|
|
273
|
+
end
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The `earliest_in_time(:foreign_key)` scope uses a `NOT EXISTS` subquery to find records where no earlier record exists for the same foreign key.
|
|
277
|
+
|
|
278
|
+
### Error Handling
|
|
279
|
+
|
|
280
|
+
If you specify a scope name but the expected columns don't exist, a `ColumnNotFoundError` is raised at class load time:
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
class Event < ActiveRecord::Base
|
|
284
|
+
include InTimeScope
|
|
285
|
+
|
|
286
|
+
# This will raise ColumnNotFoundError if hoge_start_at or hoge_end_at columns don't exist
|
|
287
|
+
in_time_scope :hoge
|
|
288
|
+
end
|
|
289
|
+
# => InTimeScope::ColumnNotFoundError: Column 'hoge_start_at' does not exist on table 'events'
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
This helps catch configuration errors early during development.
|
|
293
|
+
|
|
269
294
|
## Development
|
|
270
295
|
|
|
271
296
|
After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
data/lib/in_time_scope.rb
CHANGED
|
@@ -5,96 +5,88 @@ require_relative "in_time_scope/version"
|
|
|
5
5
|
|
|
6
6
|
module InTimeScope
|
|
7
7
|
class Error < StandardError; end
|
|
8
|
+
class ColumnNotFoundError < Error; end
|
|
9
|
+
class ConfigurationError < Error; end
|
|
8
10
|
|
|
9
11
|
def self.included(model)
|
|
10
12
|
model.extend ClassMethods
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
module ClassMethods
|
|
14
|
-
def in_time_scope(scope_name =
|
|
15
|
-
|
|
16
|
-
|
|
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}_"
|
|
17
19
|
|
|
18
|
-
|
|
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")
|
|
20
22
|
|
|
21
|
-
|
|
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:)
|
|
22
29
|
end
|
|
23
30
|
|
|
24
31
|
private
|
|
25
32
|
|
|
26
|
-
def
|
|
27
|
-
return
|
|
28
|
-
|
|
29
|
-
column = config[:column] || default_column
|
|
30
|
-
column = nil unless column_names.include?(column.to_s)
|
|
33
|
+
def fetch_null_option(config, column, table_column_hash)
|
|
34
|
+
return nil if column.nil?
|
|
35
|
+
return config[:null] if config.key?(:null)
|
|
31
36
|
|
|
32
|
-
|
|
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?
|
|
33
39
|
|
|
34
|
-
|
|
40
|
+
column_info.null
|
|
35
41
|
end
|
|
36
42
|
|
|
37
|
-
def
|
|
38
|
-
return
|
|
43
|
+
def method_name(scope_name, prefix)
|
|
44
|
+
return :in_time if scope_name == :in_time
|
|
39
45
|
|
|
40
|
-
|
|
41
|
-
col ? col.null : true
|
|
46
|
+
prefix ? "#{scope_name}_in_time" : "in_time_#{scope_name}"
|
|
42
47
|
end
|
|
43
48
|
|
|
44
|
-
def define_scope_methods(
|
|
45
|
-
method_name = if scope_name == :in_time
|
|
46
|
-
:in_time
|
|
47
|
-
elsif prefix
|
|
48
|
-
:"#{scope_name}_in_time"
|
|
49
|
-
else
|
|
50
|
-
:"in_time_#{scope_name}"
|
|
51
|
-
end
|
|
52
|
-
instance_method_name = :"#{method_name}?"
|
|
53
|
-
|
|
54
|
-
start_column = start_config[:column]
|
|
55
|
-
start_null = start_config[:null]
|
|
56
|
-
end_column = end_config[:column]
|
|
57
|
-
end_null = end_config[:null]
|
|
58
|
-
|
|
49
|
+
def define_scope_methods(scope_method_name, start_at_column:, start_at_null:, end_at_column:, end_at_null:)
|
|
59
50
|
# Define class-level scope
|
|
60
|
-
if
|
|
51
|
+
if start_at_column.nil? && end_at_column.nil?
|
|
61
52
|
# Both disabled - return all
|
|
62
|
-
|
|
63
|
-
elsif
|
|
53
|
+
raise ConfigurationError, "At least one of start_at or end_at must be specified"
|
|
54
|
+
elsif end_at_column.nil?
|
|
64
55
|
# Start-only pattern (history tracking)
|
|
65
|
-
define_start_only_scope(
|
|
66
|
-
elsif
|
|
56
|
+
define_start_only_scope(scope_method_name, start_at_column, start_at_null)
|
|
57
|
+
elsif start_at_column.nil?
|
|
67
58
|
# End-only pattern (expiration)
|
|
68
|
-
define_end_only_scope(
|
|
59
|
+
define_end_only_scope(scope_method_name, end_at_column, end_at_null)
|
|
69
60
|
else
|
|
70
61
|
# Both start and end
|
|
71
|
-
define_full_scope(
|
|
62
|
+
define_full_scope(scope_method_name, start_at_column, start_at_null, end_at_column, end_at_null)
|
|
72
63
|
end
|
|
73
64
|
|
|
74
65
|
# Define instance method
|
|
75
|
-
define_instance_method(
|
|
66
|
+
define_instance_method(scope_method_name, start_at_column, start_at_null, end_at_column, end_at_null)
|
|
76
67
|
end
|
|
77
68
|
|
|
78
|
-
def define_start_only_scope(
|
|
69
|
+
def define_start_only_scope(scope_method_name, start_column, start_null)
|
|
79
70
|
# Simple scope - WHERE only, no ORDER BY
|
|
80
71
|
# Users can add .order(start_at: :desc) externally if needed
|
|
81
72
|
if start_null
|
|
82
|
-
scope
|
|
73
|
+
scope scope_method_name, ->(time = Time.current) {
|
|
83
74
|
where(arel_table[start_column].eq(nil).or(arel_table[start_column].lteq(time)))
|
|
84
75
|
}
|
|
85
76
|
else
|
|
86
|
-
scope
|
|
77
|
+
scope scope_method_name, ->(time = Time.current) {
|
|
87
78
|
where(arel_table[start_column].lteq(time))
|
|
88
79
|
}
|
|
89
80
|
end
|
|
90
81
|
|
|
91
82
|
# Efficient scope for has_one + includes using NOT EXISTS subquery
|
|
92
83
|
# Usage: has_one :current_price, -> { latest_in_time(:user_id) }, class_name: 'Price'
|
|
93
|
-
|
|
84
|
+
define_latest_one_scope(scope_method_name, start_column, start_null)
|
|
85
|
+
define_earliest_one_scope(scope_method_name, start_column, start_null)
|
|
94
86
|
end
|
|
95
87
|
|
|
96
|
-
def
|
|
97
|
-
latest_method_name =
|
|
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}"
|
|
98
90
|
tbl = table_name
|
|
99
91
|
col = start_column
|
|
100
92
|
|
|
@@ -137,20 +129,59 @@ module InTimeScope
|
|
|
137
129
|
}
|
|
138
130
|
end
|
|
139
131
|
|
|
140
|
-
def
|
|
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)
|
|
141
169
|
if end_null
|
|
142
|
-
scope
|
|
170
|
+
scope scope_method_name, ->(time = Time.current) {
|
|
143
171
|
where(arel_table[end_column].eq(nil).or(arel_table[end_column].gt(time)))
|
|
144
172
|
}
|
|
145
173
|
else
|
|
146
|
-
scope
|
|
174
|
+
scope scope_method_name, ->(time = Time.current) {
|
|
147
175
|
where(arel_table[end_column].gt(time))
|
|
148
176
|
}
|
|
149
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)
|
|
150
181
|
end
|
|
151
182
|
|
|
152
|
-
def define_full_scope(
|
|
153
|
-
scope
|
|
183
|
+
def define_full_scope(scope_method_name, start_column, start_null, end_column, end_null)
|
|
184
|
+
scope scope_method_name, ->(time = Time.current) {
|
|
154
185
|
start_condition = if start_null
|
|
155
186
|
arel_table[start_column].eq(nil).or(arel_table[start_column].lteq(time))
|
|
156
187
|
else
|
|
@@ -165,10 +196,13 @@ module InTimeScope
|
|
|
165
196
|
|
|
166
197
|
where(start_condition).where(end_condition)
|
|
167
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)
|
|
168
202
|
end
|
|
169
203
|
|
|
170
|
-
def define_instance_method(
|
|
171
|
-
define_method(
|
|
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|
|
|
172
206
|
start_ok = if start_column.nil?
|
|
173
207
|
true
|
|
174
208
|
elsif start_null
|