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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ae5a251a71cec0f0ad9ac7890d9e1e88de46dade545cbfcff2cf0201d603939
4
- data.tar.gz: 9a4c17e7f1ad5a1b62ebf37038332d43a7610fcce5f770e66df70e51670dc415
3
+ metadata.gz: 12b5f84b69262b47ea6936b0658151404805b938061fd0c91e080a1a4f435125
4
+ data.tar.gz: fe4cde1d907cb57e1562f551336db91ae43b30a22d2c3db6dd11e3a3eb3202e3
5
5
  SHA512:
6
- metadata.gz: 4480bbfaee296c29a87e96b26c2f32f29a78792712765cb48b51699c01bc552526ab1764972bd6cb19bfa4ce5de115abd099a7d0abf8701a26fa69eedc780d6e
7
- data.tar.gz: b905e3bf65da95f7b4868e49c386051616b071cd67de613617d0443caa4679cbb4cc0c729ca9b6ec2776a32af4cfda32deedd48d61ceca4d326c833748b382a4
6
+ metadata.gz: 2f8766dad406ddfa8317afa597bf67fdbc2e2f1a4c7f437be46540f315f3359daf4cf8dfac317d4e36647597fe3aace454ad1549e50def596585503026305366
7
+ data.tar.gz: 0d53d1b56ebcd19b97e3a4af9f498a676ed31bd0d61b37d22ebf1d572ad7b591a86a670ea5e2adf40fd0122495362e7026dbd696b5631857edef447879a9350c
data/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # InTimeScope
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
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') /* loading for pp */ LIMIT $1 [["LIMIT", 11]]
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') /* loading for pp */ LIMIT $1 [["LIMIT", 11]]
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') /* loading for pp */ LIMIT $1 [["LIMIT", 11]]
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' ORDER BY "events"."start_at" DESC
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 ordered by `start_at DESC`, so:
125
- - Use `.first` for a single record
126
- - Use with `has_one` associations for per-parent record selection
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') /* loading for pp */ LIMIT $1 [["LIMIT", 11]]
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 two scopes for `has_one` associations:
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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module InTimeScope
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.3"
5
5
  end
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 = nil, start_at: {}, end_at: {}, prefix: false)
15
- scope_name ||= :in_time
16
- scope_prefix = scope_name == :in_time ? "" : "#{scope_name}_"
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
- start_config = normalize_config(start_at, :"#{scope_prefix}start_at")
19
- end_config = normalize_config(end_at, :"#{scope_prefix}end_at")
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
- define_scope_methods(scope_name, start_config, end_config, prefix)
22
- end
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
- def normalize_config(config, default_column)
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
- column = config[:column] || default_column
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
- def column_nullable?(column_name)
38
- return true if column_name.nil?
29
+ private
39
30
 
40
- col = columns_hash[column_name.to_s]
41
- col ? col.null : true
42
- end
31
+ def method_name(scope_name, prefix)
32
+ return :in_time if scope_name == :in_time
43
33
 
44
- def define_scope_methods(scope_name, start_config, end_config, prefix)
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]
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 start_column.nil? && end_column.nil?
39
+ if start_at_column.nil? && end_at_column.nil?
61
40
  # Both disabled - return all
62
- scope method_name, ->(_time = Time.current) { all }
63
- elsif end_column.nil?
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(method_name, start_column, start_null)
66
- elsif start_column.nil?
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(method_name, end_column, end_null)
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(method_name, start_column, start_null, end_column, end_null)
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(instance_method_name, start_column, start_null, end_column, end_null)
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(method_name, start_column, start_null)
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 method_name, ->(time = Time.current) {
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 method_name, ->(time = Time.current) {
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
- define_latest_scope(method_name, start_column, start_null)
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 define_latest_scope(method_name, start_column, start_null)
97
- latest_method_name = method_name == :in_time ? :latest_in_time : :"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 define_end_only_scope(method_name, end_column, end_null)
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 method_name, ->(time = Time.current) {
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 method_name, ->(time = Time.current) {
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(method_name, start_column, start_null, end_column, end_null)
153
- scope method_name, ->(time = Time.current) {
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(method_name, start_column, start_null, end_column, end_null)
171
- define_method(method_name) do |time = Time.current|
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: in_time_scope
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - kyohah