in_time_scope 0.1.4 → 0.1.6

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.
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
@@ -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/rulesync.jsonc ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/dyoshikawa/rulesync/refs/heads/main/config-schema.json",
3
+ "targets": ["claudecode"],
4
+ "features": ["rules", "commands"],
5
+ "baseDirs": ["."]
6
+ }
@@ -1,4 +1,93 @@
1
+ # Type definitions for InTimeScope gem
2
+
1
3
  module InTimeScope
2
4
  VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
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
+ # @return [void]
40
+ # @raise [ColumnNotFoundError] When a specified column doesn't exist
41
+ # @raise [ConfigurationError] When the configuration is invalid (at scope call time)
42
+ def in_time_scope: (
43
+ ?Symbol scope_name,
44
+ ?start_at: start_at_config,
45
+ ?end_at: end_at_config
46
+ ) -> void
47
+
48
+ private
49
+
50
+ # Private implementation methods
51
+ # These use ActiveRecord internals and are typed as untyped for flexibility
52
+ def fetch_null_option: (untyped config, untyped column, untyped table_column_hash) -> untyped
53
+ def define_scope_methods: (String suffix, start_at_column: untyped, start_at_null: untyped, end_at_column: untyped, end_at_null: untyped) -> void
54
+ def define_error_scope_and_method: (String suffix, String message) -> void
55
+ def define_start_only_scope: (String suffix, Symbol column) -> void
56
+ def define_latest_one_scope: (String suffix, Symbol column) -> void
57
+ def define_earliest_one_scope: (String suffix, Symbol column) -> void
58
+ def define_end_only_scope: (String suffix, Symbol column) -> void
59
+ def define_full_scope: (String suffix, Symbol start_column, untyped start_null, Symbol end_column, untyped end_null) -> void
60
+ def define_instance_method: (String suffix, Symbol? start_column, untyped start_null, Symbol? end_column, untyped end_null) -> void
61
+ def define_before_scope: (String suffix, Symbol? start_column, untyped start_null) -> void
62
+ def define_after_scope: (String suffix, Symbol? end_column, untyped end_null) -> void
63
+ def define_out_of_time_scope: (String suffix) -> 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 (primary - records in time window):
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
+ # Class methods (inverse - records outside time window):
77
+ # Model.before_in_time(time = Time.current) -> ActiveRecord::Relation (not yet started)
78
+ # Model.after_in_time(time = Time.current) -> ActiveRecord::Relation (already ended)
79
+ # Model.out_of_time(time = Time.current) -> ActiveRecord::Relation (before OR after)
80
+ #
81
+ # Instance methods (primary):
82
+ # model.in_time?(time = Time.current) -> bool
83
+ # model.in_time_<name>?(time = Time.current) -> bool (for named scopes)
84
+ #
85
+ # Instance methods (inverse):
86
+ # model.before_in_time?(time = Time.current) -> bool
87
+ # model.after_in_time?(time = Time.current) -> bool
88
+ # model.out_of_time?(time = Time.current) -> bool
89
+
90
+ # Extend ActiveRecord::Base to include InTimeScope
91
+ class ActiveRecord::Base
92
+ extend InTimeScope::ClassMethods
4
93
  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
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - kyohah
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-01-29 00:00:00.000000000 Z
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: '6.0'
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: '6.0'
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
@@ -101,14 +143,40 @@ extensions: []
101
143
  extra_rdoc_files: []
102
144
  files:
103
145
  - ".rubocop.yml"
146
+ - ".rulesync/commands/translate-readme.md"
147
+ - ".rulesync/rules/project.md"
104
148
  - CHANGELOG.md
105
- - CLAUDE.md
106
149
  - CODE_OF_CONDUCT.md
107
150
  - LICENSE.txt
108
151
  - README.md
109
152
  - Rakefile
153
+ - Steepfile
154
+ - docs/book.toml
155
+ - docs/de/SUMMARY.md
156
+ - docs/de/index.md
157
+ - docs/de/point-system.md
158
+ - docs/de/user-name-history.md
159
+ - docs/fr/SUMMARY.md
160
+ - docs/fr/index.md
161
+ - docs/fr/point-system.md
162
+ - docs/fr/user-name-history.md
163
+ - docs/ja/SUMMARY.md
164
+ - docs/ja/index.md
165
+ - docs/ja/point-system.md
166
+ - docs/ja/user-name-history.md
167
+ - docs/src/SUMMARY.md
168
+ - docs/src/index.md
169
+ - docs/src/point-system.md
170
+ - docs/src/user-name-history.md
171
+ - docs/zh/SUMMARY.md
172
+ - docs/zh/index.md
173
+ - docs/zh/point-system.md
174
+ - docs/zh/user-name-history.md
110
175
  - lib/in_time_scope.rb
176
+ - lib/in_time_scope/class_methods.rb
111
177
  - lib/in_time_scope/version.rb
178
+ - rbs_collection.yaml
179
+ - rulesync.jsonc
112
180
  - sig/in_time_scope.rbs
113
181
  homepage: https://github.com/kyohah/in_time_scope
114
182
  licenses:
@@ -126,14 +194,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
126
194
  requirements:
127
195
  - - ">="
128
196
  - !ruby/object:Gem::Version
129
- version: 3.1.0
197
+ version: 3.0.0
130
198
  required_rubygems_version: !ruby/object:Gem::Requirement
131
199
  requirements:
132
200
  - - ">="
133
201
  - !ruby/object:Gem::Version
134
202
  version: '0'
135
203
  requirements: []
136
- rubygems_version: 3.6.2
204
+ rubygems_version: 4.0.3
137
205
  specification_version: 4
138
206
  summary: Add time-window scopes to ActiveRecord models
139
207
  test_files: []