in_time_scope 0.1.2 → 0.1.3
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 +79 -57
- 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: 12b5f84b69262b47ea6936b0658151404805b938061fd0c91e080a1a4f435125
|
|
4
|
+
data.tar.gz: fe4cde1d907cb57e1562f551336db91ae43b30a22d2c3db6dd11e3a3eb3202e3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2f8766dad406ddfa8317afa597bf67fdbc2e2f1a4c7f437be46540f315f3359daf4cf8dfac317d4e36647597fe3aace454ad1549e50def596585503026305366
|
|
7
|
+
data.tar.gz: 0d53d1b56ebcd19b97e3a4af9f498a676ed31bd0d61b37d22ebf1d572ad7b591a86a670ea5e2adf40fd0122495362e7026dbd696b5631857edef447879a9350c
|
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, an error is raised at class load time:
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
class Event < ActiveRecord::Base
|
|
284
|
+
include InTimeScope
|
|
285
|
+
|
|
286
|
+
# This will raise NoMethodError if hoge_start_at or hoge_end_at columns don't exist
|
|
287
|
+
in_time_scope :hoge
|
|
288
|
+
end
|
|
289
|
+
# => NoMethodError: undefined method `null' for nil:NilClass
|
|
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
|
@@ -11,90 +11,70 @@ module InTimeScope
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
module ClassMethods
|
|
14
|
-
def in_time_scope(scope_name =
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
def in_time_scope(scope_name = :in_time, start_at: {}, end_at: {}, prefix: false)
|
|
15
|
+
table_column_hash = columns_hash
|
|
16
|
+
time_column_prefix = scope_name == :in_time ? "" : "#{scope_name}_"
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
start_at_column = start_at.fetch(:column, :"#{time_column_prefix}start_at")
|
|
19
|
+
end_at_column = end_at.fetch(:column, :"#{time_column_prefix}end_at")
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
private
|
|
21
|
+
start_at_null = start_at.fetch(:null, table_column_hash[start_at_column.to_s].null) unless start_at_column.nil?
|
|
22
|
+
end_at_null = end_at.fetch(:null, table_column_hash[end_at_column.to_s].null) unless end_at_column.nil?
|
|
25
23
|
|
|
26
|
-
|
|
27
|
-
return { column: nil, null: true } if config[:column].nil? && config.key?(:column)
|
|
24
|
+
scope_method_name = method_name(scope_name, prefix)
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
column = nil unless column_names.include?(column.to_s)
|
|
31
|
-
|
|
32
|
-
null = config.key?(:null) ? config[:null] : column_nullable?(column)
|
|
33
|
-
|
|
34
|
-
{ column: column, null: null }
|
|
26
|
+
define_scope_methods(scope_method_name, start_at_column:, start_at_null:, end_at_column:, end_at_null:)
|
|
35
27
|
end
|
|
36
28
|
|
|
37
|
-
|
|
38
|
-
return true if column_name.nil?
|
|
29
|
+
private
|
|
39
30
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
end
|
|
31
|
+
def method_name(scope_name, prefix)
|
|
32
|
+
return :in_time if scope_name == :in_time
|
|
43
33
|
|
|
44
|
-
|
|
45
|
-
|
|
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]
|
|
34
|
+
prefix ? "#{scope_name}_in_time" : "in_time_#{scope_name}"
|
|
35
|
+
end
|
|
58
36
|
|
|
37
|
+
def define_scope_methods(scope_method_name, start_at_column:, start_at_null:, end_at_column:, end_at_null:)
|
|
59
38
|
# Define class-level scope
|
|
60
|
-
if
|
|
39
|
+
if start_at_column.nil? && end_at_column.nil?
|
|
61
40
|
# Both disabled - return all
|
|
62
|
-
scope
|
|
63
|
-
elsif
|
|
41
|
+
scope scope_method_name, ->(_time = Time.current) { raise ArgumentError, "At least one of start_at or end_at must be specified." }
|
|
42
|
+
elsif end_at_column.nil?
|
|
64
43
|
# Start-only pattern (history tracking)
|
|
65
|
-
define_start_only_scope(
|
|
66
|
-
elsif
|
|
44
|
+
define_start_only_scope(scope_method_name, start_at_column, start_at_null)
|
|
45
|
+
elsif start_at_column.nil?
|
|
67
46
|
# End-only pattern (expiration)
|
|
68
|
-
define_end_only_scope(
|
|
47
|
+
define_end_only_scope(scope_method_name, end_at_column, end_at_null)
|
|
69
48
|
else
|
|
70
49
|
# Both start and end
|
|
71
|
-
define_full_scope(
|
|
50
|
+
define_full_scope(scope_method_name, start_at_column, start_at_null, end_at_column, end_at_null)
|
|
72
51
|
end
|
|
73
52
|
|
|
74
53
|
# Define instance method
|
|
75
|
-
define_instance_method(
|
|
54
|
+
define_instance_method(scope_method_name, start_at_column, start_at_null, end_at_column, end_at_null)
|
|
76
55
|
end
|
|
77
56
|
|
|
78
|
-
def define_start_only_scope(
|
|
57
|
+
def define_start_only_scope(scope_method_name, start_column, start_null)
|
|
79
58
|
# Simple scope - WHERE only, no ORDER BY
|
|
80
59
|
# Users can add .order(start_at: :desc) externally if needed
|
|
81
60
|
if start_null
|
|
82
|
-
scope
|
|
61
|
+
scope scope_method_name, ->(time = Time.current) {
|
|
83
62
|
where(arel_table[start_column].eq(nil).or(arel_table[start_column].lteq(time)))
|
|
84
63
|
}
|
|
85
64
|
else
|
|
86
|
-
scope
|
|
65
|
+
scope scope_method_name, ->(time = Time.current) {
|
|
87
66
|
where(arel_table[start_column].lteq(time))
|
|
88
67
|
}
|
|
89
68
|
end
|
|
90
69
|
|
|
91
70
|
# Efficient scope for has_one + includes using NOT EXISTS subquery
|
|
92
71
|
# Usage: has_one :current_price, -> { latest_in_time(:user_id) }, class_name: 'Price'
|
|
93
|
-
|
|
72
|
+
define_latest_one_scope(scope_method_name, start_column, start_null)
|
|
73
|
+
define_earliest_one_scope(scope_method_name, start_column, start_null)
|
|
94
74
|
end
|
|
95
75
|
|
|
96
|
-
def
|
|
97
|
-
latest_method_name =
|
|
76
|
+
def define_latest_one_scope(scope_method_name, start_column, start_null)
|
|
77
|
+
latest_method_name = scope_method_name == :in_time ? :latest_in_time : :"latest_#{scope_method_name}"
|
|
98
78
|
tbl = table_name
|
|
99
79
|
col = start_column
|
|
100
80
|
|
|
@@ -137,20 +117,59 @@ module InTimeScope
|
|
|
137
117
|
}
|
|
138
118
|
end
|
|
139
119
|
|
|
140
|
-
def
|
|
120
|
+
def define_earliest_one_scope(scope_method_name, start_column, start_null)
|
|
121
|
+
earliest_method_name = scope_method_name == :in_time ? :earliest_in_time : :"earliest_#{scope_method_name}"
|
|
122
|
+
tbl = table_name
|
|
123
|
+
col = start_column
|
|
124
|
+
scope earliest_method_name, ->(foreign_key, time = Time.current) {
|
|
125
|
+
fk = foreign_key
|
|
126
|
+
not_exists_sql = if start_null
|
|
127
|
+
<<~SQL.squish
|
|
128
|
+
NOT EXISTS (
|
|
129
|
+
SELECT 1 FROM #{tbl} p2
|
|
130
|
+
WHERE p2.#{fk} = #{tbl}.#{fk}
|
|
131
|
+
AND (p2.#{col} IS NULL OR p2.#{col} <= ?)
|
|
132
|
+
AND (p2.#{col} IS NULL OR p2.#{col} < #{tbl}.#{col} OR #{tbl}.#{col} IS NULL)
|
|
133
|
+
AND p2.id != #{tbl}.id
|
|
134
|
+
)
|
|
135
|
+
SQL
|
|
136
|
+
else
|
|
137
|
+
<<~SQL.squish
|
|
138
|
+
NOT EXISTS (
|
|
139
|
+
SELECT 1 FROM #{tbl} p2
|
|
140
|
+
WHERE p2.#{fk} = #{tbl}.#{fk}
|
|
141
|
+
AND p2.#{col} <= ?
|
|
142
|
+
AND p2.#{col} < #{tbl}.#{col}
|
|
143
|
+
)
|
|
144
|
+
SQL
|
|
145
|
+
end
|
|
146
|
+
base_condition = if start_null
|
|
147
|
+
where(arel_table[col].eq(nil).or(arel_table[col].lteq(time)))
|
|
148
|
+
else
|
|
149
|
+
where(arel_table[col].lteq(time))
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
base_condition.where(not_exists_sql, time)
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def define_end_only_scope(scope_method_name, end_column, end_null)
|
|
141
157
|
if end_null
|
|
142
|
-
scope
|
|
158
|
+
scope scope_method_name, ->(time = Time.current) {
|
|
143
159
|
where(arel_table[end_column].eq(nil).or(arel_table[end_column].gt(time)))
|
|
144
160
|
}
|
|
145
161
|
else
|
|
146
|
-
scope
|
|
162
|
+
scope scope_method_name, ->(time = Time.current) {
|
|
147
163
|
where(arel_table[end_column].gt(time))
|
|
148
164
|
}
|
|
149
165
|
end
|
|
166
|
+
|
|
167
|
+
define_latest_one_scope(scope_method_name, end_column, end_null)
|
|
168
|
+
define_earliest_one_scope(scope_method_name, end_column, end_null)
|
|
150
169
|
end
|
|
151
170
|
|
|
152
|
-
def define_full_scope(
|
|
153
|
-
scope
|
|
171
|
+
def define_full_scope(scope_method_name, start_column, start_null, end_column, end_null)
|
|
172
|
+
scope scope_method_name, ->(time = Time.current) {
|
|
154
173
|
start_condition = if start_null
|
|
155
174
|
arel_table[start_column].eq(nil).or(arel_table[start_column].lteq(time))
|
|
156
175
|
else
|
|
@@ -165,10 +184,13 @@ module InTimeScope
|
|
|
165
184
|
|
|
166
185
|
where(start_condition).where(end_condition)
|
|
167
186
|
}
|
|
187
|
+
|
|
188
|
+
define_latest_one_scope(scope_method_name, start_column, start_null)
|
|
189
|
+
define_earliest_one_scope(scope_method_name, start_column, start_null)
|
|
168
190
|
end
|
|
169
191
|
|
|
170
|
-
def define_instance_method(
|
|
171
|
-
define_method(
|
|
192
|
+
def define_instance_method(scope_method_name, start_column, start_null, end_column, end_null)
|
|
193
|
+
define_method("#{scope_method_name}?") do |time = Time.current|
|
|
172
194
|
start_ok = if start_column.nil?
|
|
173
195
|
true
|
|
174
196
|
elsif start_null
|