scopa 1.0.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6e5b9095e2a8d90b5c238dd20baeac0c9e05059eb9b3b964acc26117a70ff292
4
+ data.tar.gz: 631337ffbfe9d49b350800e95b9117416b06f66b9b2e377f93aff790f3df4b1e
5
+ SHA512:
6
+ metadata.gz: 2c5aee42cfbd651e499af3886e261544aea43eb2a1b26b030f781440cfb0ddb51a394ac634f2347d44892056ca11c9b930c37b86400e7c6ba98a6b9832a2ec22
7
+ data.tar.gz: 97079279d28c3828607040efdba39dc31c673dd8496214f4606627e1c004d4669ad6c5bce9648793eea3a0d1e36913277425d513ea3ceb75ad4dc3ca3ccf5ead
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [1.0.0] - 2026-01-17
4
+
5
+ - Initial release
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 joshmn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # Scopa
2
+
3
+ Query objects for Rails. Encapsulate complex queries with validated parameters and composable filters.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "scopa"
11
+ ```
12
+
13
+ ## What it does
14
+
15
+ Scopa gives you a structured way to build query objects. You define parameters with types and validations, compose filters that apply conditionally, and get instrumentation out of the box.
16
+
17
+ ```ruby
18
+ class Users::ActiveQuery < Scopa::Base
19
+ model User
20
+
21
+ parameter :role, optional: true
22
+ parameter :created_after, :date, optional: true
23
+
24
+ filter(:active) { |scope| scope.where(active: true) }
25
+ filter(:by_role, if: :role) { |scope| scope.where(role: role) }
26
+ filter(:recent, if: :created_after) { |scope| scope.where("created_at > ?", created_after) }
27
+ end
28
+
29
+ Users::ActiveQuery.call(role: :admin)
30
+ # => User.where(active: true).where(role: :admin)
31
+
32
+ Users::ActiveQuery.call
33
+ # => User.where(active: true)
34
+ ```
35
+
36
+ ## Parameters
37
+
38
+ Parameters define the inputs your query accepts. They support types, defaults, and validation.
39
+
40
+ ### Basic parameters
41
+
42
+ ```ruby
43
+ parameter :status
44
+ ```
45
+
46
+ Required by default. The query raises `Scopa::InvalidError` if called without it.
47
+
48
+ ### Optional parameters
49
+
50
+ ```ruby
51
+ parameter :search, optional: true
52
+ ```
53
+
54
+ ### Typed parameters
55
+
56
+ Uses ActiveModel's attribute types for coercion:
57
+
58
+ ```ruby
59
+ parameter :limit, :integer, default: 25
60
+ parameter :include_archived, :boolean, default: false
61
+ parameter :start_date, :date, optional: true
62
+ ```
63
+
64
+ String `"10"` becomes integer `10`. String `"true"` becomes boolean `true`.
65
+
66
+ ### Dynamic defaults
67
+
68
+ Pass a proc for defaults that need to be evaluated at call time:
69
+
70
+ ```ruby
71
+ parameter :since, :datetime, default: -> { 1.week.ago }
72
+ ```
73
+
74
+ The proc runs in the context of the query instance, so it has access to other parameters.
75
+
76
+ ## Filters
77
+
78
+ Filters transform the scope. They run in definition order.
79
+
80
+ ### Unconditional filters
81
+
82
+ Always applied:
83
+
84
+ ```ruby
85
+ filter(:published) { |scope| scope.where(published: true) }
86
+ filter(:ordered) { |scope| scope.order(created_at: :desc) }
87
+ ```
88
+
89
+ ### Conditional filters
90
+
91
+ Applied only when a condition is met.
92
+
93
+ **Symbol condition** checks if the parameter is present:
94
+
95
+ ```ruby
96
+ parameter :category_id, optional: true
97
+
98
+ filter(:by_category, if: :category_id) { |scope| scope.where(category_id: category_id) }
99
+ ```
100
+
101
+ **Proc condition** for more complex logic:
102
+
103
+ ```ruby
104
+ parameter :min_price, :decimal, optional: true
105
+ parameter :max_price, :decimal, optional: true
106
+
107
+ filter(:price_range, if: -> { min_price.present? || max_price.present? }) do |scope|
108
+ scope = scope.where("price >= ?", min_price) if min_price.present?
109
+ scope = scope.where("price <= ?", max_price) if max_price.present?
110
+ scope
111
+ end
112
+ ```
113
+
114
+ Filters have access to all parameter values as instance methods.
115
+
116
+ ## Handling invalid parameters
117
+
118
+ By default, calling a query with invalid parameters raises `Scopa::InvalidError`. You can change this:
119
+
120
+ ```ruby
121
+ class SearchQuery < Scopa::Base
122
+ model Product
123
+ on_invalid :return_none # returns Product.none instead of raising
124
+
125
+ parameter :query
126
+ end
127
+
128
+ SearchQuery.call # => Product.none (no exception)
129
+ ```
130
+
131
+ Options:
132
+
133
+ - `:raise` (default) raises `Scopa::InvalidError`
134
+ - `:return_none` returns `Model.none`
135
+ - `:ignore` runs the query anyway, skipping validation.
136
+
137
+ ## Custom scopes
138
+
139
+ By default, queries start from `Model.all`. Pass a custom scope to narrow the base:
140
+
141
+ ```ruby
142
+ Users::ActiveQuery.call(scope: current_account.users, role: :admin)
143
+ # Starts from current_account.users instead of User.all
144
+ ```
145
+
146
+ ## Inheritance
147
+
148
+ Query classes can inherit from other query classes. Filters, parameters, and configuration are inherited and can be extended:
149
+
150
+ ```ruby
151
+ class BaseQuery < Scopa::Base
152
+ model User
153
+ filter(:active) { |scope| scope.where(active: true) }
154
+ end
155
+
156
+ class AdminQuery < BaseQuery
157
+ filter(:admins) { |scope| scope.where(role: :admin) }
158
+ end
159
+
160
+ AdminQuery.call
161
+ # => User.where(active: true).where(role: :admin)
162
+ ```
163
+
164
+ Child classes can override `on_invalid` and add their own parameters without affecting the parent.
165
+
166
+ ## Instrumentation
167
+
168
+ Every query call emits an `ActiveSupport::Notifications` event:
169
+
170
+ ```ruby
171
+ ActiveSupport::Notifications.subscribe("scopa.call") do |event|
172
+ Rails.logger.info "#{event.payload[:query_class]} took #{event.duration}ms"
173
+ Rails.logger.debug "Params: #{event.payload[:params]}"
174
+ end
175
+ ```
176
+
177
+ Payload includes:
178
+
179
+ - `query_class` the name of the query class
180
+ - `params` the parameter values passed to the query
181
+
182
+ ## Rails integration
183
+
184
+ In Rails, Scopa automatically adds `app/queries` to the autoload paths. Create query classes there:
185
+
186
+ ```
187
+ app/
188
+ queries/
189
+ users/
190
+ active_query.rb
191
+ search_query.rb
192
+ orders/
193
+ pending_query.rb
194
+ ```
195
+
196
+ ## License
197
+
198
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ task default: %i[]
data/lib/scopa/base.rb ADDED
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module Scopa
6
+ class Base
7
+ include ActiveModel::Model
8
+ include ActiveModel::Attributes
9
+ include ActiveModel::Validations
10
+
11
+ class_attribute :_model, instance_writer: false
12
+ class_attribute :_filters, instance_writer: false, default: []
13
+ class_attribute :_on_invalid, instance_writer: false, default: :raise
14
+ class_attribute :_parameters, instance_writer: false, default: {}
15
+
16
+ class << self
17
+ def inherited(subclass)
18
+ super
19
+ subclass._filters = _filters.dup
20
+ subclass._parameters = _parameters.dup
21
+ end
22
+
23
+ def model(klass = nil)
24
+ if klass
25
+ self._model = klass
26
+ else
27
+ _model
28
+ end
29
+ end
30
+
31
+ def parameter(name, type = nil, default: nil, optional: false)
32
+ _parameters[name] = { default: default, optional: optional }
33
+
34
+ if default.respond_to?(:call)
35
+ attribute name, type
36
+ define_method(name) do
37
+ value = super()
38
+ return value unless value.nil?
39
+ instance_exec(&self.class._parameters[name][:default])
40
+ end
41
+ else
42
+ attribute name, type, default: default
43
+ end
44
+
45
+ validates name, presence: true unless optional
46
+ end
47
+
48
+ def filter(name, if: nil, &block)
49
+ self._filters = _filters + [Filter.new(name, condition: binding.local_variable_get(:if), block: block)]
50
+ end
51
+
52
+ def on_invalid(behavior)
53
+ self._on_invalid = behavior
54
+ end
55
+
56
+ def call(scope: nil, **params)
57
+ new(scope: scope, **params).call
58
+ end
59
+ end
60
+
61
+ attr_reader :scope
62
+
63
+ def initialize(scope: nil, **params)
64
+ @scope = scope
65
+ super(**params)
66
+ end
67
+
68
+ def call
69
+ return handle_invalid unless valid?
70
+
71
+ instrument do
72
+ _filters.reduce(base_scope) do |relation, filter|
73
+ apply_filter(filter, relation)
74
+ end
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def base_scope
81
+ @scope || _model.all
82
+ end
83
+
84
+ def apply_filter(filter, relation)
85
+ return relation unless filter_applies?(filter)
86
+ instance_exec(relation, &filter.block)
87
+ end
88
+
89
+ def filter_applies?(filter)
90
+ return true unless filter.condition
91
+
92
+ case filter.condition
93
+ when Symbol
94
+ value = send(filter.condition)
95
+ value.present?
96
+ when Proc
97
+ instance_exec(&filter.condition)
98
+ else
99
+ filter.condition
100
+ end
101
+ end
102
+
103
+ def handle_invalid
104
+ case _on_invalid
105
+ when :raise
106
+ raise InvalidError.new(errors.full_messages)
107
+ when :return_none
108
+ _model.none
109
+ when :ignore
110
+ call_without_validation
111
+ end
112
+ end
113
+
114
+ def call_without_validation
115
+ instrument do
116
+ _filters.reduce(base_scope) do |relation, filter|
117
+ apply_filter(filter, relation)
118
+ end
119
+ end
120
+ end
121
+
122
+ def instrument(&block)
123
+ Scopa::Instrumentation.instrument(self.class.name, attributes, &block)
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scopa
4
+ class Filter
5
+ attr_reader :name, :condition, :block
6
+
7
+ def initialize(name, condition:, block:)
8
+ @name = name
9
+ @condition = condition
10
+ @block = block
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module Scopa
6
+ module Instrumentation
7
+ def self.instrument(query_class, params, &block)
8
+ ActiveSupport::Notifications.instrument("scopa.call", query_class: query_class, params: params, &block)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scopa
4
+ class InvalidError < StandardError
5
+ attr_reader :errors
6
+
7
+ def initialize(errors = [])
8
+ @errors = errors
9
+ super(errors.join(", "))
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Scopa
6
+ class Railtie < Rails::Railtie
7
+ initializer "scopa.add_autoload_paths" do |app|
8
+ app.config.autoload_paths << Rails.root.join("app", "queries")
9
+ app.config.eager_load_paths << Rails.root.join("app", "queries")
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,3 @@
1
+ module Scopa
2
+ VERSION = "1.0.0"
3
+ end
data/lib/scopa.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/object/blank"
5
+
6
+ require_relative "scopa/version"
7
+ require_relative "scopa/invalid_error"
8
+ require_relative "scopa/filter"
9
+ require_relative "scopa/instrumentation"
10
+ require_relative "scopa/base"
11
+
12
+ module Scopa
13
+ class Error < StandardError; end
14
+
15
+ def self.instrument(query_class, params, &block)
16
+ Instrumentation.instrument(query_class, params, &block)
17
+ end
18
+ end
19
+
20
+ require_relative "scopa/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,465 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Scopa::Base do
6
+ describe ".model" do
7
+ it "sets the model for the query" do
8
+ query_class = Class.new(described_class) do
9
+ model MockModel
10
+ end
11
+
12
+ expect(query_class.model).to eq(MockModel)
13
+ end
14
+ end
15
+
16
+ describe ".parameter" do
17
+ describe "with static default" do
18
+ it "uses the default value when not provided" do
19
+ query_class = Class.new(described_class) do
20
+ model MockModel
21
+ parameter :status, default: :active
22
+ filter(:noop) { |r| r }
23
+ end
24
+
25
+ query = query_class.new
26
+
27
+ expect(query.status).to eq(:active)
28
+ end
29
+
30
+ it "allows overriding the default" do
31
+ query_class = Class.new(described_class) do
32
+ model MockModel
33
+ parameter :status, default: :active
34
+ filter(:noop) { |r| r }
35
+ end
36
+
37
+ query = query_class.new(status: :inactive)
38
+
39
+ expect(query.status).to eq(:inactive)
40
+ end
41
+ end
42
+
43
+ describe "with proc default" do
44
+ it "evaluates the proc fresh on each call" do
45
+ call_count = 0
46
+ query_class = Class.new(described_class) do
47
+ model MockModel
48
+ parameter :counter, default: -> { call_count += 1 }
49
+ filter(:noop) { |r| r }
50
+ end
51
+
52
+ first = query_class.new.counter
53
+ second = query_class.new.counter
54
+
55
+ expect(first).to eq(1)
56
+ expect(second).to eq(2)
57
+ end
58
+
59
+ it "allows overriding the proc default" do
60
+ query_class = Class.new(described_class) do
61
+ model MockModel
62
+ parameter :time, default: -> { Time.now }
63
+ filter(:noop) { |r| r }
64
+ end
65
+
66
+ frozen_time = Time.new(2020, 1, 1)
67
+ query = query_class.new(time: frozen_time)
68
+
69
+ expect(query.time).to eq(frozen_time)
70
+ end
71
+ end
72
+
73
+ describe "required parameters" do
74
+ it "fails validation when required param is missing" do
75
+ query_class = Class.new(described_class) do
76
+ model MockModel
77
+ parameter :required_param
78
+ filter(:noop) { |r| r }
79
+ end
80
+
81
+ query = query_class.new
82
+
83
+ expect(query).not_to be_valid
84
+ end
85
+
86
+ it "passes validation when required param is present" do
87
+ query_class = Class.new(described_class) do
88
+ model MockModel
89
+ parameter :required_param
90
+ filter(:noop) { |r| r }
91
+ end
92
+
93
+ query = query_class.new(required_param: "value")
94
+
95
+ expect(query).to be_valid
96
+ end
97
+ end
98
+
99
+ describe "optional parameters" do
100
+ it "passes validation when optional param is missing" do
101
+ query_class = Class.new(described_class) do
102
+ model MockModel
103
+ parameter :optional_param, optional: true
104
+ filter(:noop) { |r| r }
105
+ end
106
+
107
+ query = query_class.new
108
+
109
+ expect(query).to be_valid
110
+ end
111
+ end
112
+
113
+ describe "with type" do
114
+ it "coerces string to integer" do
115
+ query_class = Class.new(described_class) do
116
+ model MockModel
117
+ parameter :limit, :integer, default: 10
118
+ filter(:noop) { |r| r }
119
+ end
120
+
121
+ query = query_class.new(limit: "25")
122
+
123
+ expect(query.limit).to eq(25)
124
+ end
125
+
126
+ it "coerces string to boolean" do
127
+ query_class = Class.new(described_class) do
128
+ model MockModel
129
+ parameter :active, :boolean, default: false
130
+ filter(:noop) { |r| r }
131
+ end
132
+
133
+ query = query_class.new(active: "true")
134
+
135
+ expect(query.active).to be true
136
+ end
137
+
138
+ it "coerces to string" do
139
+ query_class = Class.new(described_class) do
140
+ model MockModel
141
+ parameter :name, :string, default: "default"
142
+ filter(:noop) { |r| r }
143
+ end
144
+
145
+ query = query_class.new(name: 123)
146
+
147
+ expect(query.name).to eq("123")
148
+ end
149
+
150
+ it "works with type and proc default" do
151
+ query_class = Class.new(described_class) do
152
+ model MockModel
153
+ parameter :count, :integer, default: -> { 5 + 5 }
154
+ filter(:noop) { |r| r }
155
+ end
156
+
157
+ query = query_class.new
158
+
159
+ expect(query.count).to eq(10)
160
+ end
161
+
162
+ it "coerces value when overriding proc default" do
163
+ query_class = Class.new(described_class) do
164
+ model MockModel
165
+ parameter :count, :integer, default: -> { 10 }
166
+ filter(:noop) { |r| r }
167
+ end
168
+
169
+ query = query_class.new(count: "42")
170
+
171
+ expect(query.count).to eq(42)
172
+ end
173
+ end
174
+ end
175
+
176
+ describe ".filter" do
177
+ it "applies filters to the base scope" do
178
+ query_class = Class.new(described_class) do
179
+ model MockModel
180
+ filter(:active) { |r| r.where(active: true) }
181
+ end
182
+
183
+ result = query_class.call
184
+
185
+ expect(result.applied_filters).to include(where: { active: true })
186
+ end
187
+
188
+ it "applies multiple filters in definition order" do
189
+ query_class = Class.new(described_class) do
190
+ model MockModel
191
+ filter(:first) { |r| r.where(first: true) }
192
+ filter(:second) { |r| r.where(second: true) }
193
+ end
194
+
195
+ result = query_class.call
196
+
197
+ expect(result.applied_filters).to eq([
198
+ { where: { first: true } },
199
+ { where: { second: true } }
200
+ ])
201
+ end
202
+
203
+ it "has access to parameter values" do
204
+ query_class = Class.new(described_class) do
205
+ model MockModel
206
+ parameter :status, default: :active
207
+ filter(:by_status) { |r| r.where(status: status) }
208
+ end
209
+
210
+ result = query_class.call(status: :pending)
211
+
212
+ expect(result.applied_filters).to include(where: { status: :pending })
213
+ end
214
+ end
215
+
216
+ describe ".filter with conditional" do
217
+ describe "symbol condition" do
218
+ it "applies filter when param is present" do
219
+ query_class = Class.new(described_class) do
220
+ model MockModel
221
+ parameter :role, optional: true
222
+ filter(:by_role, if: :role) { |r| r.where(role: role) }
223
+ end
224
+
225
+ result = query_class.call(role: :admin)
226
+
227
+ expect(result.applied_filters).to include(where: { role: :admin })
228
+ end
229
+
230
+ it "skips filter when param is nil" do
231
+ query_class = Class.new(described_class) do
232
+ model MockModel
233
+ parameter :role, optional: true
234
+ filter(:by_role, if: :role) { |r| r.where(role: role) }
235
+ end
236
+
237
+ result = query_class.call
238
+
239
+ expect(result.applied_filters).to be_empty
240
+ end
241
+
242
+ it "skips filter when param is blank" do
243
+ query_class = Class.new(described_class) do
244
+ model MockModel
245
+ parameter :role, optional: true
246
+ filter(:by_role, if: :role) { |r| r.where(role: role) }
247
+ end
248
+
249
+ result = query_class.call(role: "")
250
+
251
+ expect(result.applied_filters).to be_empty
252
+ end
253
+ end
254
+
255
+ describe "proc condition" do
256
+ it "applies filter when proc returns true" do
257
+ query_class = Class.new(described_class) do
258
+ model MockModel
259
+ parameter :count, default: 5
260
+ filter(:expensive, if: -> { count > 3 }) { |r| r.where(expensive: true) }
261
+ end
262
+
263
+ result = query_class.call
264
+
265
+ expect(result.applied_filters).to include(where: { expensive: true })
266
+ end
267
+
268
+ it "skips filter when proc returns false" do
269
+ query_class = Class.new(described_class) do
270
+ model MockModel
271
+ parameter :count, default: 1
272
+ filter(:expensive, if: -> { count > 3 }) { |r| r.where(expensive: true) }
273
+ end
274
+
275
+ result = query_class.call
276
+
277
+ expect(result.applied_filters).to be_empty
278
+ end
279
+ end
280
+ end
281
+
282
+ describe ".on_invalid" do
283
+ describe ":raise (default)" do
284
+ it "raises InvalidError when validation fails" do
285
+ query_class = Class.new(described_class) do
286
+ model MockModel
287
+ parameter :required
288
+ filter(:noop) { |r| r }
289
+
290
+ def self.name
291
+ "TestQuery"
292
+ end
293
+ end
294
+
295
+ expect { query_class.call }.to raise_error(Scopa::InvalidError)
296
+ end
297
+
298
+ it "includes error messages in the exception" do
299
+ query_class = Class.new(described_class) do
300
+ model MockModel
301
+ parameter :required
302
+ filter(:noop) { |r| r }
303
+
304
+ def self.name
305
+ "TestQuery"
306
+ end
307
+ end
308
+
309
+ expect { query_class.call }.to raise_error do |error|
310
+ expect(error.errors).to include(/can't be blank/)
311
+ end
312
+ end
313
+ end
314
+
315
+ describe ":return_none" do
316
+ it "returns model.none when validation fails" do
317
+ query_class = Class.new(described_class) do
318
+ model MockModel
319
+ on_invalid :return_none
320
+ parameter :required
321
+ filter(:noop) { |r| r }
322
+ end
323
+
324
+ result = query_class.call
325
+
326
+ expect(result.instance_variable_get(:@none)).to be true
327
+ end
328
+ end
329
+
330
+ describe ":ignore" do
331
+ it "runs query despite invalid params" do
332
+ query_class = Class.new(described_class) do
333
+ model MockModel
334
+ on_invalid :ignore
335
+ parameter :required
336
+ filter(:always) { |r| r.where(always: true) }
337
+ end
338
+
339
+ result = query_class.call
340
+
341
+ expect(result.applied_filters).to include(where: { always: true })
342
+ end
343
+ end
344
+ end
345
+
346
+ describe ".call with scope" do
347
+ it "uses the provided scope instead of model.all" do
348
+ custom_scope = MockRelation.new([], [{ where: { account_id: 1 } }])
349
+ query_class = Class.new(described_class) do
350
+ model MockModel
351
+ filter(:active) { |r| r.where(active: true) }
352
+ end
353
+
354
+ result = query_class.call(scope: custom_scope)
355
+
356
+ expect(result.applied_filters).to eq([
357
+ { where: { account_id: 1 } },
358
+ { where: { active: true } }
359
+ ])
360
+ end
361
+ end
362
+
363
+ describe "inheritance" do
364
+ it "inherits model from parent" do
365
+ parent = Class.new(described_class) do
366
+ model MockModel
367
+ end
368
+ child = Class.new(parent)
369
+
370
+ expect(child.model).to eq(MockModel)
371
+ end
372
+
373
+ it "allows child to override model" do
374
+ other_model = Class.new(MockModel)
375
+ parent = Class.new(described_class) do
376
+ model MockModel
377
+ end
378
+ child = Class.new(parent) do
379
+ model other_model
380
+ end
381
+
382
+ expect(child.model).to eq(other_model)
383
+ end
384
+
385
+ it "inherits filters from parent" do
386
+ parent = Class.new(described_class) do
387
+ model MockModel
388
+ filter(:parent_filter) { |r| r.where(parent: true) }
389
+ end
390
+ child = Class.new(parent) do
391
+ filter(:child_filter) { |r| r.where(child: true) }
392
+ end
393
+
394
+ result = child.call
395
+
396
+ expect(result.applied_filters).to eq([
397
+ { where: { parent: true } },
398
+ { where: { child: true } }
399
+ ])
400
+ end
401
+
402
+ it "does not modify parent filters when child adds filters" do
403
+ parent = Class.new(described_class) do
404
+ model MockModel
405
+ filter(:parent_filter) { |r| r.where(parent: true) }
406
+ end
407
+ Class.new(parent) do
408
+ filter(:child_filter) { |r| r.where(child: true) }
409
+ end
410
+
411
+ result = parent.call
412
+
413
+ expect(result.applied_filters).to eq([{ where: { parent: true } }])
414
+ end
415
+
416
+ it "inherits parameters from parent" do
417
+ parent = Class.new(described_class) do
418
+ model MockModel
419
+ parameter :inherited_param, default: :from_parent
420
+ filter(:noop) { |r| r }
421
+ end
422
+ child = Class.new(parent)
423
+
424
+ query = child.new
425
+
426
+ expect(query.inherited_param).to eq(:from_parent)
427
+ end
428
+
429
+ it "inherits on_invalid behavior from parent" do
430
+ parent = Class.new(described_class) do
431
+ model MockModel
432
+ on_invalid :return_none
433
+ parameter :required
434
+ filter(:noop) { |r| r }
435
+ end
436
+ child = Class.new(parent)
437
+
438
+ result = child.call
439
+
440
+ expect(result.instance_variable_get(:@none)).to be true
441
+ end
442
+
443
+ it "allows child to override on_invalid behavior" do
444
+ parent = Class.new(described_class) do
445
+ model MockModel
446
+ on_invalid :return_none
447
+ parameter :required
448
+ filter(:noop) { |r| r }
449
+
450
+ def self.name
451
+ "ParentQuery"
452
+ end
453
+ end
454
+ child = Class.new(parent) do
455
+ on_invalid :raise
456
+
457
+ def self.name
458
+ "ChildQuery"
459
+ end
460
+ end
461
+
462
+ expect { child.call }.to raise_error(Scopa::InvalidError)
463
+ end
464
+ end
465
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Scopa::Filter do
6
+ describe "#initialize" do
7
+ it "stores the name" do
8
+ filter = described_class.new(:by_status, condition: nil, block: -> {})
9
+
10
+ expect(filter.name).to eq(:by_status)
11
+ end
12
+
13
+ it "stores the condition" do
14
+ condition = :role
15
+ filter = described_class.new(:by_role, condition: condition, block: -> {})
16
+
17
+ expect(filter.condition).to eq(:role)
18
+ end
19
+
20
+ it "stores the block" do
21
+ block = ->(r) { r.where(active: true) }
22
+ filter = described_class.new(:active, condition: nil, block: block)
23
+
24
+ expect(filter.block).to eq(block)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Scopa::Instrumentation do
6
+ describe ".instrument" do
7
+ it "publishes scopa.call event" do
8
+ events = []
9
+ ActiveSupport::Notifications.subscribe("scopa.call") do |event|
10
+ events << event
11
+ end
12
+
13
+ described_class.instrument("TestQuery", { status: :active }) { "result" }
14
+
15
+ expect(events.length).to eq(1)
16
+ ensure
17
+ ActiveSupport::Notifications.unsubscribe("scopa.call")
18
+ end
19
+
20
+ it "includes query_class in payload" do
21
+ payload = nil
22
+ ActiveSupport::Notifications.subscribe("scopa.call") do |event|
23
+ payload = event.payload
24
+ end
25
+
26
+ described_class.instrument("Users::ActiveQuery", {}) { "result" }
27
+
28
+ expect(payload[:query_class]).to eq("Users::ActiveQuery")
29
+ ensure
30
+ ActiveSupport::Notifications.unsubscribe("scopa.call")
31
+ end
32
+
33
+ it "includes params in payload" do
34
+ payload = nil
35
+ ActiveSupport::Notifications.subscribe("scopa.call") do |event|
36
+ payload = event.payload
37
+ end
38
+
39
+ described_class.instrument("TestQuery", { status: :active, limit: 10 }) { "result" }
40
+
41
+ expect(payload[:params]).to eq({ status: :active, limit: 10 })
42
+ ensure
43
+ ActiveSupport::Notifications.unsubscribe("scopa.call")
44
+ end
45
+
46
+ it "returns the block result" do
47
+ result = described_class.instrument("TestQuery", {}) { "query result" }
48
+
49
+ expect(result).to eq("query result")
50
+ end
51
+
52
+ it "measures duration" do
53
+ duration = nil
54
+ ActiveSupport::Notifications.subscribe("scopa.call") do |event|
55
+ duration = event.duration
56
+ end
57
+
58
+ described_class.instrument("TestQuery", {}) { sleep(0.01) }
59
+
60
+ expect(duration).to be >= 10
61
+ ensure
62
+ ActiveSupport::Notifications.unsubscribe("scopa.call")
63
+ end
64
+ end
65
+ end
66
+
67
+ RSpec.describe "Scopa instrumentation integration" do
68
+ it "instruments calls through Scopa::Base" do
69
+ events = []
70
+ ActiveSupport::Notifications.subscribe("scopa.call") do |event|
71
+ events << event
72
+ end
73
+
74
+ query_class = Class.new(Scopa::Base) do
75
+ model MockModel
76
+ parameter :status, default: :active
77
+ filter(:by_status) { |r| r.where(status: status) }
78
+
79
+ def self.name
80
+ "TestQuery"
81
+ end
82
+ end
83
+
84
+ query_class.call
85
+
86
+ expect(events.length).to eq(1)
87
+ expect(events.first.payload[:query_class]).to eq("TestQuery")
88
+ ensure
89
+ ActiveSupport::Notifications.unsubscribe("scopa.call")
90
+ end
91
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Scopa::InvalidError do
6
+ describe "#initialize" do
7
+ it "stores the errors" do
8
+ error = described_class.new(["Name can't be blank", "Email is invalid"])
9
+
10
+ expect(error.errors).to eq(["Name can't be blank", "Email is invalid"])
11
+ end
12
+
13
+ it "sets message from joined errors" do
14
+ error = described_class.new(["Name can't be blank", "Email is invalid"])
15
+
16
+ expect(error.message).to eq("Name can't be blank, Email is invalid")
17
+ end
18
+ end
19
+
20
+ it "is a StandardError" do
21
+ expect(described_class.ancestors).to include(StandardError)
22
+ end
23
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scopa"
5
+
6
+ Dir[File.join(__dir__, "support", "**", "*.rb")].each { |f| require f }
7
+
8
+ RSpec.configure do |config|
9
+ config.expect_with :rspec do |expectations|
10
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
11
+ end
12
+
13
+ config.mock_with :rspec do |mocks|
14
+ mocks.verify_partial_doubles = true
15
+ end
16
+
17
+ config.shared_context_metadata_behavior = :apply_to_host_groups
18
+ config.filter_run_when_matching :focus
19
+ config.disable_monkey_patching!
20
+ config.warnings = true
21
+
22
+ config.default_formatter = "doc" if config.files_to_run.one?
23
+
24
+ config.order = :random
25
+ Kernel.srand config.seed
26
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ class MockRelation
4
+ attr_reader :records, :applied_filters
5
+
6
+ def initialize(records = [], applied_filters = [])
7
+ @records = records
8
+ @applied_filters = applied_filters
9
+ end
10
+
11
+ def where(conditions)
12
+ MockRelation.new(records, applied_filters + [{ where: conditions }])
13
+ end
14
+
15
+ def joins(*tables)
16
+ MockRelation.new(records, applied_filters + [{ joins: tables }])
17
+ end
18
+
19
+ def having(*args)
20
+ MockRelation.new(records, applied_filters + [{ having: args }])
21
+ end
22
+
23
+ def group(*columns)
24
+ MockRelation.new(records, applied_filters + [{ group: columns }])
25
+ end
26
+
27
+ def distinct
28
+ MockRelation.new(records, applied_filters + [{ distinct: true }])
29
+ end
30
+
31
+ def to_a
32
+ records
33
+ end
34
+ end
35
+
36
+ class MockModel
37
+ class << self
38
+ def all
39
+ MockRelation.new
40
+ end
41
+
42
+ def none
43
+ MockRelation.new.tap do |r|
44
+ r.instance_variable_set(:@none, true)
45
+ end
46
+ end
47
+ end
48
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: scopa
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Josh
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activemodel
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ description: Scopa provides a clean DSL for building query objects in Rails applications.
41
+ Define parameters with types and validations, compose filters conditionally, and
42
+ instrument query execution.
43
+ email:
44
+ - joshdotmn@gmail.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - LICENSE
51
+ - README.md
52
+ - Rakefile
53
+ - lib/scopa.rb
54
+ - lib/scopa/base.rb
55
+ - lib/scopa/filter.rb
56
+ - lib/scopa/instrumentation.rb
57
+ - lib/scopa/invalid_error.rb
58
+ - lib/scopa/railtie.rb
59
+ - lib/scopa/version.rb
60
+ - spec/scopa/base_spec.rb
61
+ - spec/scopa/filter_spec.rb
62
+ - spec/scopa/instrumentation_spec.rb
63
+ - spec/scopa/invalid_error_spec.rb
64
+ - spec/spec_helper.rb
65
+ - spec/support/mock_model.rb
66
+ homepage: https://github.com/joshmn/scopa
67
+ licenses:
68
+ - MIT
69
+ metadata:
70
+ allowed_push_host: https://rubygems.org
71
+ homepage_uri: https://github.com/joshmn/scopa
72
+ source_code_uri: https://github.com/joshmn/scopa
73
+ changelog_uri: https://github.com/joshmn/scopa/blob/main/CHANGELOG.md
74
+ rdoc_options: []
75
+ require_paths:
76
+ - lib
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 3.2.0
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubygems_version: 4.0.1
89
+ specification_version: 4
90
+ summary: Query objects for Rails. Encapsulate complex queries with validated parameters
91
+ and composable filters.
92
+ test_files: []