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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE +21 -0
- data/README.md +198 -0
- data/Rakefile +4 -0
- data/lib/scopa/base.rb +126 -0
- data/lib/scopa/filter.rb +13 -0
- data/lib/scopa/instrumentation.rb +11 -0
- data/lib/scopa/invalid_error.rb +12 -0
- data/lib/scopa/railtie.rb +12 -0
- data/lib/scopa/version.rb +3 -0
- data/lib/scopa.rb +20 -0
- data/spec/scopa/base_spec.rb +465 -0
- data/spec/scopa/filter_spec.rb +27 -0
- data/spec/scopa/instrumentation_spec.rb +91 -0
- data/spec/scopa/invalid_error_spec.rb +23 -0
- data/spec/spec_helper.rb +26 -0
- data/spec/support/mock_model.rb +48 -0
- metadata +92 -0
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
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
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
|
data/lib/scopa/filter.rb
ADDED
|
@@ -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
|
+
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
|
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
|
data/spec/spec_helper.rb
ADDED
|
@@ -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: []
|