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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ae5a251a71cec0f0ad9ac7890d9e1e88de46dade545cbfcff2cf0201d603939
4
- data.tar.gz: 9a4c17e7f1ad5a1b62ebf37038332d43a7610fcce5f770e66df70e51670dc415
3
+ metadata.gz: c5ab9dff9b5a5abce4d0418d2a8ec3895126a910a0926f1a449fc55bad3cebd4
4
+ data.tar.gz: '082a9f889d860c0dc7f587fafb77baaa932ccc864e8dfe06b10b92ab9644308a'
5
5
  SHA512:
6
- metadata.gz: 4480bbfaee296c29a87e96b26c2f32f29a78792712765cb48b51699c01bc552526ab1764972bd6cb19bfa4ce5de115abd099a7d0abf8701a26fa69eedc780d6e
7
- data.tar.gz: b905e3bf65da95f7b4868e49c386051616b071cd67de613617d0443caa4679cbb4cc0c729ca9b6ec2776a32af4cfda32deedd48d61ceca4d326c833748b382a4
6
+ metadata.gz: 7df8620094c225af106a3ed909dfa3af5abd328630e1a571cbe0943ecf5a8f965bc1c56286493181305a44fc87a148b055247c12291ee9110b49ca700b45b1bc
7
+ data.tar.gz: 7dc373ec631d291cbdb9c73acaec4bc21d5271495a1131681a3a8aa6929480a28a886010eb3bcc209951825b5c92590a13730aae7ec119a09d96fb66d17f8218
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, 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.
@@ -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.4"
5
5
  end
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 = nil, start_at: {}, end_at: {}, prefix: false)
15
- scope_name ||= :in_time
16
- scope_prefix = scope_name == :in_time ? "" : "#{scope_name}_"
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
- start_config = normalize_config(start_at, :"#{scope_prefix}start_at")
19
- end_config = normalize_config(end_at, :"#{scope_prefix}end_at")
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
- define_scope_methods(scope_name, start_config, end_config, prefix)
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 normalize_config(config, default_column)
27
- return { column: nil, null: true } if config[:column].nil? && config.key?(:column)
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
- null = config.key?(:null) ? config[:null] : column_nullable?(column)
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
- { column: column, null: null }
40
+ column_info.null
35
41
  end
36
42
 
37
- def column_nullable?(column_name)
38
- return true if column_name.nil?
43
+ def method_name(scope_name, prefix)
44
+ return :in_time if scope_name == :in_time
39
45
 
40
- col = columns_hash[column_name.to_s]
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(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]
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 start_column.nil? && end_column.nil?
51
+ if start_at_column.nil? && end_at_column.nil?
61
52
  # Both disabled - return all
62
- scope method_name, ->(_time = Time.current) { all }
63
- elsif end_column.nil?
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(method_name, start_column, start_null)
66
- elsif start_column.nil?
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(method_name, end_column, end_null)
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(method_name, start_column, start_null, end_column, end_null)
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(instance_method_name, start_column, start_null, end_column, end_null)
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(method_name, start_column, start_null)
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 method_name, ->(time = Time.current) {
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 method_name, ->(time = Time.current) {
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
- define_latest_scope(method_name, start_column, start_null)
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 define_latest_scope(method_name, start_column, start_null)
97
- latest_method_name = method_name == :in_time ? :latest_in_time : :"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 define_end_only_scope(method_name, end_column, end_null)
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 method_name, ->(time = Time.current) {
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 method_name, ->(time = Time.current) {
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(method_name, start_column, start_null, end_column, end_null)
153
- scope method_name, ->(time = Time.current) {
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(method_name, start_column, start_null, end_column, end_null)
171
- define_method(method_name) do |time = Time.current|
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
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.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - kyohah