graphql-constant_queries 0.1.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/LICENSE.txt +21 -0
- data/README.md +138 -0
- data/lib/graphql/constant_queries/check.rb +57 -0
- data/lib/graphql/constant_queries/configuration.rb +59 -0
- data/lib/graphql/constant_queries/exceptions.rb +64 -0
- data/lib/graphql/constant_queries/harness.rb +84 -0
- data/lib/graphql/constant_queries/inventory.rb +91 -0
- data/lib/graphql/constant_queries/ledger.rb +39 -0
- data/lib/graphql/constant_queries/operation_builder.rb +81 -0
- data/lib/graphql/constant_queries/plan.rb +14 -0
- data/lib/graphql/constant_queries/reloader.rb +27 -0
- data/lib/graphql/constant_queries/result.rb +14 -0
- data/lib/graphql/constant_queries/rspec/declaration.rb +136 -0
- data/lib/graphql/constant_queries/rspec/dsl.rb +53 -0
- data/lib/graphql/constant_queries/rspec/schema_dsl.rb +24 -0
- data/lib/graphql/constant_queries/rspec.rb +28 -0
- data/lib/graphql/constant_queries/runner.rb +33 -0
- data/lib/graphql/constant_queries/statement_log.rb +82 -0
- data/lib/graphql/constant_queries/test_schema.rb +30 -0
- data/lib/graphql/constant_queries/version.rb +7 -0
- data/lib/graphql/constant_queries.rb +58 -0
- metadata +109 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b6f017ef6ae0861d8033ecef935e85850e657f058384fbfc68d1ef210826f3b7
|
|
4
|
+
data.tar.gz: f638d4244ce7907e2952c03599a635bc48fd9de0b546957913e9c7f6ee99377d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: dc6ab17bb9a9d962f009f656ed5edd5f7737af95bd2fc3c377444eed783e53b9376fe521894becef5eec827391b688ecd6ab436512faac3e60dc379dab97b419
|
|
7
|
+
data.tar.gz: 248b26f6e0b2bff569145bd7ed0fd585284a8454e97b6340931e8527dc1287a650fa82dc78aeb5f4b2eaa3fd643a82fdc3ca4fe3d7aa6b32882ead44a9422bcd
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stephen Crosby
|
|
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,138 @@
|
|
|
1
|
+
# graphql-constant_queries
|
|
2
|
+
|
|
3
|
+
Prove, in tests, that every field of every graphql-ruby object type issues the
|
|
4
|
+
same number of ActiveRecord SQL statements whether it resolves across 2
|
|
5
|
+
objects or 3. A field whose count grows with the object count has an N+1, and
|
|
6
|
+
it fails in the spec of the type that causes it.
|
|
7
|
+
|
|
8
|
+
## Setup
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
# Gemfile
|
|
12
|
+
gem "graphql-constant_queries", group: :test
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Configure it after the app boots but before spec files load, e.g. in
|
|
16
|
+
`spec/support/constant_queries.rb` required from `rails_helper.rb` (or wherever
|
|
17
|
+
the app is loaded):
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
require "graphql/constant_queries/rspec"
|
|
21
|
+
|
|
22
|
+
GraphQL::ConstantQueries.configure do |c|
|
|
23
|
+
c.source_root = Rails.root # default: Dir.pwd
|
|
24
|
+
c.context = -> { {current_user: user} } # evaluated in each example; default: {}
|
|
25
|
+
c.schema MySchema, exceptions_path: "spec/graphql/my_schema_constant_queries.yml"
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Per-schema options (`exceptions_path`, `context`, `spec_pattern`) override the
|
|
30
|
+
globals. A type reachable from several schemas belongs to the first registered.
|
|
31
|
+
|
|
32
|
+
## The three checks
|
|
33
|
+
|
|
34
|
+
- **A. Every type has a spec.** In the schema's spec:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
RSpec.describe MySchema do
|
|
38
|
+
has_specs_for_all_types except: /Error\z/
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`except:` (a Regexp or an array of them, matched against type names) only
|
|
43
|
+
relaxes this check: an excluded type that has a spec still gets B.
|
|
44
|
+
|
|
45
|
+
- **B. Every type spec declares `has_constant_queries`.** The spec at a type's
|
|
46
|
+
conventional path gets a `declares has_constant_queries` example that fails
|
|
47
|
+
until it does.
|
|
48
|
+
|
|
49
|
+
- **C. Every field is checked.** `has_constant_queries` generates a check per
|
|
50
|
+
field:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
RSpec.describe Types::Document do
|
|
54
|
+
has_constant_queries do
|
|
55
|
+
let(:user) { create(:user, :admin) } # context for every check
|
|
56
|
+
default_objects { |n| create_list(:document, n, user: user) } # required
|
|
57
|
+
|
|
58
|
+
resolving :deleted_by_user do # customize a field's check
|
|
59
|
+
objects { |n| create_list(:document, n, :deleted, user: user) }
|
|
60
|
+
end
|
|
61
|
+
resolving :page_data, page: 1 # add a check with arguments
|
|
62
|
+
resolving :page_data, page: -> { create(:page).number } # evaluated in the example
|
|
63
|
+
resolving :owner_permissions do
|
|
64
|
+
let!(:owner) { create(:user) } # applies to this check only
|
|
65
|
+
objects { |n| create_list(:document, n, user: owner) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Keywords are the field's Ruby argument names. A `resolving` block gets its
|
|
72
|
+
own example, so its lets apply only to that check and `rspec file:LINE`
|
|
73
|
+
reruns it. Every other check shares one example, `resolving with default
|
|
74
|
+
objects`, which fails with every problem it found.
|
|
75
|
+
|
|
76
|
+
## Spec paths
|
|
77
|
+
|
|
78
|
+
`spec_pattern` defaults to `spec/%{source}_spec.rb`, where `%{source}` is the
|
|
79
|
+
type's file relative to `source_root/app` without `.rb`
|
|
80
|
+
(`app/graphql/types/foo.rb` → `spec/graphql/types/foo_spec.rb`). A type
|
|
81
|
+
defined in a file named for something else, like a generated wrapper, gets the
|
|
82
|
+
path autoloading expects: its underscored name under the same top-level
|
|
83
|
+
directory. `%{name}` is the underscored class name. `spec_pattern` may also be
|
|
84
|
+
a callable `->(type) { path }`.
|
|
85
|
+
|
|
86
|
+
## Writing good checks
|
|
87
|
+
|
|
88
|
+
- **Single objects need data.** A field returning one object must be non-null
|
|
89
|
+
on every object, because a nil foreign key skips the load and proves
|
|
90
|
+
nothing.
|
|
91
|
+
- **Vary the records resolvers walk.** Repeated loads of one record hit the
|
|
92
|
+
query cache, so objects sharing a parent hide per-object loads. Give each
|
|
93
|
+
object its own parent.
|
|
94
|
+
- **Lists and connections may be empty.** Their load still runs per object.
|
|
95
|
+
Connections select `nodes { __typename }` plus argument-free counters like
|
|
96
|
+
`totalCount`.
|
|
97
|
+
- **Hidden objects fail.** A null object means the authorizer hid it and the
|
|
98
|
+
field never resolved.
|
|
99
|
+
- **Hidden fields aren't checked.** A field not visible in the check's context
|
|
100
|
+
is left out; an example with no visible fields is skipped.
|
|
101
|
+
- **Empty builds prove nothing.** Make sure `default_objects` returns `n`
|
|
102
|
+
objects.
|
|
103
|
+
- **Single objects are checked one level deep.** A single-object field selects
|
|
104
|
+
only `__typename`, so the returned object's authorizer runs but its fields
|
|
105
|
+
don't.
|
|
106
|
+
- Runs happen under the ActiveRecord query cache, as in a real request. Each
|
|
107
|
+
run reloads its objects and the records in context, and each check runs in a
|
|
108
|
+
savepoint, so nothing one run loads or writes carries into the next. Objects
|
|
109
|
+
that aren't persisted records are rebuilt for each check.
|
|
110
|
+
|
|
111
|
+
## Exceptions file
|
|
112
|
+
|
|
113
|
+
A flat YAML list of `Type.field` keys whose checks don't pass yet:
|
|
114
|
+
|
|
115
|
+
```yaml
|
|
116
|
+
- Types::Document.pageData
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
A key covers every check of the field. Once all of them pass, the type's group
|
|
120
|
+
fails until you remove the key. Runs filtered to some of a field's checks never
|
|
121
|
+
report it. Every key must name a real field of a type in that schema, or the
|
|
122
|
+
file fails to load. The list only shrinks; don't add keys for new code.
|
|
123
|
+
|
|
124
|
+
## What it proves
|
|
125
|
+
|
|
126
|
+
Objects reach the type through a test-only subclass of the real schema whose
|
|
127
|
+
only root field is `objects: [Type]`, so every type is checked as a list even
|
|
128
|
+
if clients only reach it one at a time. That proves each field batches across
|
|
129
|
+
several objects, which also covers aliased siblings. It doesn't cover:
|
|
130
|
+
|
|
131
|
+
- root fields (query, mutation, subscription), generated mutation payloads,
|
|
132
|
+
connections, and edges;
|
|
133
|
+
- behavior that depends on what an ancestor field preloaded or put in context,
|
|
134
|
+
except as far as a spec's lets set the same context.
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module ConstantQueries
|
|
5
|
+
# One field resolved with one set of arguments. +keywords+ are as written
|
|
6
|
+
# in the spec; +arguments+ are the same values keyed by GraphQL argument
|
|
7
|
+
# name. A Proc value is evaluated per run, for values that need records.
|
|
8
|
+
Check = Data.define(:type, :field, :keywords, :arguments) do
|
|
9
|
+
def self.build(type, name, keywords = {})
|
|
10
|
+
field = type.fields.each_value.find { |f| f.original_name == name.to_sym || f.graphql_name == name.to_s }
|
|
11
|
+
raise ArgumentError, "#{label(type)} has no field #{name}" unless field
|
|
12
|
+
|
|
13
|
+
arguments = keywords.to_h do |keyword, value|
|
|
14
|
+
argument = field.all_argument_definitions.find { |arg| arg.keyword == keyword }
|
|
15
|
+
raise ArgumentError, "#{label(type)}.#{field.original_name} has no argument #{keyword}" unless argument
|
|
16
|
+
|
|
17
|
+
[argument.graphql_name, value]
|
|
18
|
+
end
|
|
19
|
+
new(type: type, field: field, keywords: keywords, arguments: arguments)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.label(type)
|
|
23
|
+
type.name || type.graphql_name
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# [type name, field name] from an exceptions key like "Types::Foo.barBaz", or nil.
|
|
27
|
+
def self.split_key(key)
|
|
28
|
+
match = /\A(?<type>[A-Z][\w:]*)\.(?<field>\w+)\z/.match(key.to_s) or return
|
|
29
|
+
[match[:type], match[:field]]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The exceptions key, shared by every check of the field.
|
|
33
|
+
def key
|
|
34
|
+
"#{self.class.label(type)}.#{field.graphql_name}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def description
|
|
38
|
+
"resolving #{field.original_name}#{qualifier}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def qualifier
|
|
42
|
+
return "" if keywords.empty?
|
|
43
|
+
|
|
44
|
+
" with #{keywords.map { |keyword, value| "#{keyword}: #{value.is_a?(Proc) ? "(computed)" : value.inspect}" }.join(", ")}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# +arguments+ with each Proc value replaced by +evaluator+.call(value).
|
|
48
|
+
def arguments_with(evaluator)
|
|
49
|
+
arguments.transform_values { |value| value.is_a?(Proc) ? evaluator.call(value) : value }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def requires_arguments?
|
|
53
|
+
field.all_argument_definitions.any? { |arg| arg.type.non_null? && !arg.default_value? }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module ConstantQueries
|
|
5
|
+
class Configuration
|
|
6
|
+
DEFAULT_SPEC_PATTERN = "spec/%{source}_spec.rb"
|
|
7
|
+
|
|
8
|
+
# One schema's settings, with the globals filled in where it set none.
|
|
9
|
+
SchemaConfig = Data.define(:schema, :exceptions_path, :context, :spec_pattern)
|
|
10
|
+
|
|
11
|
+
attr_accessor :spec_pattern, :context, :docs_url
|
|
12
|
+
attr_reader :source_root
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
self.source_root = Dir.pwd
|
|
16
|
+
@spec_pattern = DEFAULT_SPEC_PATTERN
|
|
17
|
+
@context = -> { {} }
|
|
18
|
+
@docs_url = README_URL
|
|
19
|
+
@schemas = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def source_root=(path)
|
|
23
|
+
@source_root = Pathname(path).expand_path
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Registers +schema+; options override the globals for its types.
|
|
27
|
+
# +exceptions_path+ is relative to source_root.
|
|
28
|
+
def schema(schema, exceptions_path: nil, context: nil, spec_pattern: nil)
|
|
29
|
+
unless schema.is_a?(Class) && schema < GraphQL::Schema
|
|
30
|
+
raise ArgumentError, "#{schema.inspect} isn't a GraphQL::Schema subclass"
|
|
31
|
+
end
|
|
32
|
+
raise ArgumentError, "#{schema} is already registered" if @schemas.key?(schema)
|
|
33
|
+
|
|
34
|
+
@schemas[schema] = {exceptions_path:, context:, spec_pattern:}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def registered?(klass)
|
|
38
|
+
@schemas.key?(klass)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Registered schemas' settings, in registration order.
|
|
42
|
+
def schemas
|
|
43
|
+
@schemas.keys.map { |schema| for_schema(schema) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Settings for +schema+; just the globals when it isn't registered.
|
|
47
|
+
def for_schema(schema)
|
|
48
|
+
options = @schemas.fetch(schema, {})
|
|
49
|
+
path = options[:exceptions_path]
|
|
50
|
+
SchemaConfig.new(
|
|
51
|
+
schema: schema,
|
|
52
|
+
exceptions_path: path && source_root.join(path),
|
|
53
|
+
context: options[:context] || context,
|
|
54
|
+
spec_pattern: options[:spec_pattern] || spec_pattern
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module ConstantQueries
|
|
5
|
+
# One schema's known failures: Type.field keys whose checks don't pass
|
|
6
|
+
# yet. Keys only ever get removed.
|
|
7
|
+
class Exceptions
|
|
8
|
+
attr_reader :path
|
|
9
|
+
|
|
10
|
+
# +schema+'s exceptions, loaded once from its exceptions_path.
|
|
11
|
+
def self.for(schema)
|
|
12
|
+
@cache ||= {}
|
|
13
|
+
cached = @cache[schema]
|
|
14
|
+
raise cached if cached.is_a?(ConfigurationError)
|
|
15
|
+
return cached if cached
|
|
16
|
+
|
|
17
|
+
path = GraphQL::ConstantQueries.config.for_schema(schema).exceptions_path
|
|
18
|
+
@cache[schema] = path ? load(path, Inventory.entries_for(schema)) : new
|
|
19
|
+
rescue ConfigurationError => e
|
|
20
|
+
@cache[schema] = e
|
|
21
|
+
raise
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.reset!
|
|
25
|
+
@cache = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Raises ConfigurationError unless every key names a field of one of +entries+' types.
|
|
29
|
+
def self.load(path, entries)
|
|
30
|
+
raise ConfigurationError, "#{path} doesn't exist" unless path.exist?
|
|
31
|
+
|
|
32
|
+
keys = YAML.safe_load_file(path) || []
|
|
33
|
+
raise ConfigurationError, "#{path} must be a list of Type.field keys" unless keys.is_a?(Array)
|
|
34
|
+
|
|
35
|
+
fields = entries.to_h { |entry| [entry.type.name, entry.type.fields] }
|
|
36
|
+
unknown = keys.reject do |key|
|
|
37
|
+
type, field = Check.split_key(key)
|
|
38
|
+
fields[type]&.key?(field)
|
|
39
|
+
end
|
|
40
|
+
if unknown.any?
|
|
41
|
+
raise ConfigurationError, "#{path} has keys that name no field of its schema's types:\n" \
|
|
42
|
+
"#{unknown.map { |key| " - #{key}" }.join("\n")}"
|
|
43
|
+
end
|
|
44
|
+
new(keys, path: path)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize(keys = [], path: nil)
|
|
48
|
+
@keys = Set.new(keys)
|
|
49
|
+
@path = path
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def include?(key)
|
|
53
|
+
@keys.include?(key)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The path as failure messages show it.
|
|
57
|
+
def display_path
|
|
58
|
+
return "the exceptions file" unless path
|
|
59
|
+
|
|
60
|
+
path.relative_path_from(GraphQL::ConstantQueries.config.source_root)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module ConstantQueries
|
|
5
|
+
# Resolves one field across 2 and 3 objects and reports why its SQL
|
|
6
|
+
# statement count depends on the number of objects, if it does.
|
|
7
|
+
class Harness
|
|
8
|
+
def initialize(schema:, type:, context:)
|
|
9
|
+
@schema = TestSchema.for(schema, type)
|
|
10
|
+
@context = context.except(:dataloader)
|
|
11
|
+
types = GraphQL::Query.new(@schema, "{ __typename }", context: @context).types
|
|
12
|
+
@builder = OperationBuilder.new(types: types, type: type)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The field as visible in this context, or nil.
|
|
16
|
+
def visible_field(graphql_name)
|
|
17
|
+
@builder.fields.find { |field| field.graphql_name == graphql_name }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# +arguments+ maps GraphQL argument names to values; +objects_by_n+ is
|
|
21
|
+
# {1 => objects, 2 => objects, 3 => objects}. The 1-object run warms
|
|
22
|
+
# process-level caches, so state memoized on objects Reloader can't
|
|
23
|
+
# refresh is cold in both measured runs. Returns nil when the counts match.
|
|
24
|
+
def problem(field, arguments, objects_by_n)
|
|
25
|
+
operation = @builder.build(field, arguments)
|
|
26
|
+
problem = nil
|
|
27
|
+
# A savepoint keeps a failed statement or a resolver's writes from
|
|
28
|
+
# leaking into the next check.
|
|
29
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
30
|
+
ActiveRecord::Base.clear_query_caches_for_current_thread
|
|
31
|
+
execute(operation, Reloader.call(objects_by_n.fetch(1)), Reloader.context(@context))
|
|
32
|
+
runs = objects_by_n.slice(2, 3).transform_values { |objects| measure(operation, objects) }
|
|
33
|
+
problem = compare(field, *runs.values_at(2, 3))
|
|
34
|
+
raise ActiveRecord::Rollback
|
|
35
|
+
end
|
|
36
|
+
problem
|
|
37
|
+
rescue MissingArgument => e
|
|
38
|
+
e.message
|
|
39
|
+
rescue => e
|
|
40
|
+
"raised #{e.class}: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private def measure(operation, objects)
|
|
44
|
+
reloaded = Reloader.call(objects)
|
|
45
|
+
context = Reloader.context(@context)
|
|
46
|
+
log = StatementLog.new
|
|
47
|
+
ActiveRecord::Base.clear_query_caches_for_current_thread
|
|
48
|
+
result = ActiveRecord::Base.cache { log.record { execute(operation, reloaded, context) } }
|
|
49
|
+
[result.to_h, log]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private def execute(operation, objects, context)
|
|
53
|
+
@schema.execute(
|
|
54
|
+
operation.document,
|
|
55
|
+
variables: operation.variables,
|
|
56
|
+
context: context.merge(TestSchema::OBJECTS_KEY => objects)
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private def compare(field, (result2, log2), (result3, log3))
|
|
61
|
+
errors = [result2, result3].flat_map { |result| result.fetch("errors", []) }
|
|
62
|
+
return "returned GraphQL errors: #{errors.pluck("message").uniq.join("; ")}" if errors.any?
|
|
63
|
+
|
|
64
|
+
rows = [result2, result3].flat_map { |result| result.dig("data", "objects") }
|
|
65
|
+
if rows.include?(nil)
|
|
66
|
+
return "hid #{rows.count(nil)} of #{rows.size} objects; build objects visible in this context"
|
|
67
|
+
end
|
|
68
|
+
return StatementLog.growth_report(log2, log3) if log2.count != log3.count
|
|
69
|
+
return unless single_object?(field)
|
|
70
|
+
|
|
71
|
+
# A null usually means no load ran, so nothing was proven.
|
|
72
|
+
nulls = rows.count { |row| row[field.graphql_name].nil? }
|
|
73
|
+
return if nulls.zero?
|
|
74
|
+
|
|
75
|
+
"returned null for #{nulls} of #{rows.size} objects; build objects where every one has " \
|
|
76
|
+
"#{field.graphql_name}, e.g. resolving :#{field.original_name} do objects { |n| ... } end"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private def single_object?(field)
|
|
80
|
+
!OperationBuilder.leaf?(field.type) && !field.type.list? && !field.connection?
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module ConstantQueries
|
|
5
|
+
# Every object type in the registered schemas that must prove it issues
|
|
6
|
+
# constant queries, and the conventional spec path for each.
|
|
7
|
+
module Inventory
|
|
8
|
+
Entry = Data.define(:type, :schema, :spec_path)
|
|
9
|
+
|
|
10
|
+
# A type reachable from several schemas belongs to the first registered.
|
|
11
|
+
def self.entries
|
|
12
|
+
@entries ||= begin
|
|
13
|
+
seen = Set.new
|
|
14
|
+
GraphQL::ConstantQueries.config.schemas.flat_map do |schema_config|
|
|
15
|
+
types(schema_config.schema).filter_map do |type|
|
|
16
|
+
next unless seen.add?(type)
|
|
17
|
+
|
|
18
|
+
Entry.new(type: type, schema: schema_config.schema, spec_path: spec_path(type, schema_config.spec_pattern))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.entries_for(schema)
|
|
25
|
+
entries.select { |entry| entry.schema == schema }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.entry_for(klass)
|
|
29
|
+
@entries_by_type ||= entries.index_by(&:type)
|
|
30
|
+
@entries_by_type[klass]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.reset!
|
|
34
|
+
@entries = nil
|
|
35
|
+
@entries_by_type = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Check A: "Type: path" for each of +schema+'s types with no spec file,
|
|
39
|
+
# skipping types whose name matches a Regexp in +except+.
|
|
40
|
+
def self.missing_specs(schema, except: [])
|
|
41
|
+
patterns = Array(except)
|
|
42
|
+
root = GraphQL::ConstantQueries.config.source_root
|
|
43
|
+
entries_for(schema).filter_map do |entry|
|
|
44
|
+
next if entry.spec_path.exist? || patterns.any? { |pattern| pattern.match?(entry.type.name) }
|
|
45
|
+
|
|
46
|
+
"#{entry.type.name}: #{entry.spec_path.relative_path_from(root)}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.spec_path(type, pattern)
|
|
51
|
+
relative = if pattern.respond_to?(:call)
|
|
52
|
+
pattern.call(type)
|
|
53
|
+
else
|
|
54
|
+
format(pattern, source: source(type), name: type.name.underscore)
|
|
55
|
+
end
|
|
56
|
+
GraphQL::ConstantQueries.config.source_root.join(relative.to_s)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The type's file relative to source_root/app, without .rb. A type
|
|
60
|
+
# defined in a file named for something else gets the path autoloading
|
|
61
|
+
# expects: its underscored name under the same top-level directory.
|
|
62
|
+
def self.source(type)
|
|
63
|
+
file = Pathname(Object.const_source_location(type.name).first)
|
|
64
|
+
relative = file.relative_path_from(app_root).sub_ext("")
|
|
65
|
+
return relative.to_s if relative.basename.to_s == type.name.demodulize.underscore
|
|
66
|
+
|
|
67
|
+
"#{relative.each_filename.first}/#{type.name.underscore}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Object types, excluding introspection, roots, connection and edge
|
|
71
|
+
# plumbing, and types not defined under source_root/app.
|
|
72
|
+
def self.types(schema)
|
|
73
|
+
roots = [schema.query, schema.mutation, schema.subscription].compact
|
|
74
|
+
schema.types.values.select do |type|
|
|
75
|
+
type.kind.object? && !type.introspection? && !roots.include?(type) && app_defined?(type) &&
|
|
76
|
+
!type.include?(GraphQL::Types::Relay::ConnectionBehaviors) &&
|
|
77
|
+
!type.include?(GraphQL::Types::Relay::EdgeBehaviors)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.app_defined?(klass)
|
|
82
|
+
klass.name.present? &&
|
|
83
|
+
Object.const_source_location(klass.name)&.first.to_s.start_with?("#{app_root}/")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.app_root
|
|
87
|
+
GraphQL::ConstantQueries.config.source_root.join("app")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module ConstantQueries
|
|
5
|
+
# Each check's outcome for one type. Reports problems the exceptions don't
|
|
6
|
+
# cover, and exceptions whose every check ran and passed.
|
|
7
|
+
class Ledger
|
|
8
|
+
def initialize(exceptions)
|
|
9
|
+
@exceptions = exceptions
|
|
10
|
+
@problems = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# +problem+ is the harness's message, or nil when the check passed.
|
|
14
|
+
# Returns the failure message when the problem isn't excepted.
|
|
15
|
+
def record(check, problem)
|
|
16
|
+
@problems[check] = problem
|
|
17
|
+
return if problem.nil? || @exceptions.include?(check.key)
|
|
18
|
+
|
|
19
|
+
"#{check.key}#{check.qualifier} #{problem}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Exceptions among +checks+' keys that every check proved wrong. A key
|
|
23
|
+
# with any check that didn't run, e.g. under a filtered run, is never stale.
|
|
24
|
+
def stale(checks)
|
|
25
|
+
checks.group_by(&:key).filter_map do |key, key_checks|
|
|
26
|
+
key if @exceptions.include?(key) && key_checks.all? { |check| @problems.key?(check) && @problems[check].nil? }
|
|
27
|
+
end.sort
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Failure text naming the stale exceptions among +checks+, or nil.
|
|
31
|
+
def stale_report(checks)
|
|
32
|
+
keys = stale(checks)
|
|
33
|
+
return if keys.empty?
|
|
34
|
+
|
|
35
|
+
"Now passing; remove from #{@exceptions.display_path}:\n#{keys.map { |key| " - #{key}" }.join("\n")}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module ConstantQueries
|
|
5
|
+
Operation = Data.define(:document, :variables)
|
|
6
|
+
MissingArgument = Class.new(StandardError)
|
|
7
|
+
|
|
8
|
+
# Builds the operation selecting one field on every object. +types+ is a
|
|
9
|
+
# visibility profile, so hidden fields are skipped.
|
|
10
|
+
class OperationBuilder
|
|
11
|
+
CONNECTION_PLUMBING = %w[edges nodes pageInfo].freeze
|
|
12
|
+
|
|
13
|
+
def self.leaf?(type)
|
|
14
|
+
kind = type.unwrap.kind
|
|
15
|
+
kind.scalar? || kind.enum?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(types:, type:)
|
|
19
|
+
@types = types
|
|
20
|
+
@type = type
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def fields
|
|
24
|
+
@types.fields(@type)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# +values+ maps GraphQL argument names to values.
|
|
28
|
+
def build(field, values = {})
|
|
29
|
+
arguments = arguments_for(field, values)
|
|
30
|
+
declarations = arguments.map { |arg, variable| "$#{variable}: #{arg.type.to_type_signature}" }
|
|
31
|
+
call = arguments.map { |arg, variable| "#{arg.graphql_name}: $#{variable}" }
|
|
32
|
+
Operation.new(
|
|
33
|
+
document: "query ConstantQueries#{wrap(declarations)} { objects { __typename " \
|
|
34
|
+
"#{field.graphql_name}#{wrap(call)}#{selection(field)} } }",
|
|
35
|
+
variables: arguments.to_h { |arg, variable| [variable, values.fetch(arg.graphql_name)] }
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Arguments to pass, paired with their variable names.
|
|
40
|
+
private def arguments_for(field, values)
|
|
41
|
+
@types.arguments(field).filter_map do |arg|
|
|
42
|
+
next [arg, "#{field.graphql_name}_#{arg.graphql_name}"] if values.key?(arg.graphql_name)
|
|
43
|
+
next unless arg.type.non_null? && !arg.default_value?
|
|
44
|
+
|
|
45
|
+
raise MissingArgument, "requires argument #{arg.graphql_name}; add resolving :#{field.original_name}, #{arg.keyword}: ..."
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Select items (nodes or edges) and counters from a connection.
|
|
50
|
+
private def selection(field)
|
|
51
|
+
return "" if self.class.leaf?(field.type)
|
|
52
|
+
return " { __typename }" unless field.connection?
|
|
53
|
+
|
|
54
|
+
connection_fields = @types.fields(field.type.unwrap)
|
|
55
|
+
counters = connection_fields.select do |f|
|
|
56
|
+
!CONNECTION_PLUMBING.include?(f.graphql_name) && self.class.leaf?(f.type) &&
|
|
57
|
+
@types.arguments(f).none? { |arg| arg.type.non_null? }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
nodes = connection_fields.find { |f| f.graphql_name == "nodes" }
|
|
61
|
+
if nodes
|
|
62
|
+
items_selection = self.class.leaf?(nodes.type) ? "nodes" : "nodes { __typename }"
|
|
63
|
+
else
|
|
64
|
+
edges = connection_fields.find { |f| f.graphql_name == "edges" }
|
|
65
|
+
if edges
|
|
66
|
+
node = @types.fields(edges.type.unwrap).find { |f| f.graphql_name == "node" }
|
|
67
|
+
items_selection = self.class.leaf?(node.type) ? "edges { node }" : "edges { node { __typename } }"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
parts = [items_selection, *counters.map(&:graphql_name)].compact
|
|
72
|
+
return " { __typename }" if parts.empty?
|
|
73
|
+
" { #{parts.join(" ")} }"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private def wrap(parts)
|
|
77
|
+
parts.empty? ? "" : "(#{parts.join(", ")})"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module ConstantQueries
|
|
5
|
+
# What one type declared: its checks and how to build objects for them.
|
|
6
|
+
# +builders+ maps a check to the block building its objects; every other
|
|
7
|
+
# check uses +default_builder+. Builders take n and return n objects.
|
|
8
|
+
Plan = Data.define(:type, :schema, :checks, :default_builder, :builders) do
|
|
9
|
+
def builder_for(check)
|
|
10
|
+
builders.fetch(check, default_builder)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module ConstantQueries
|
|
5
|
+
# Fresh copies of persisted records, so associations loaded while building
|
|
6
|
+
# objects or by an earlier run can't hide per-object statements. Rows
|
|
7
|
+
# without an id, like grouped selects, pass through as they are.
|
|
8
|
+
module Reloader
|
|
9
|
+
def self.call(objects)
|
|
10
|
+
objects.map { |object| reload(object) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Context with its records refreshed, as a new request would load them.
|
|
14
|
+
def self.context(context)
|
|
15
|
+
context.transform_values { |value| reload(value) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.reload(object)
|
|
19
|
+
reloadable?(object) ? object.class.unscoped.find(object.id) : object
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.reloadable?(object)
|
|
23
|
+
object.is_a?(ActiveRecord::Base) && object.persisted? && !object.id.nil?
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module ConstantQueries
|
|
5
|
+
# +problems+ maps each check that ran to its problem, or nil when it
|
|
6
|
+
# passed; +failures+ are the problems not excepted, as messages; +hidden+
|
|
7
|
+
# are the checks whose field isn't visible in the context.
|
|
8
|
+
Result = Data.define(:problems, :failures, :hidden) do
|
|
9
|
+
def passed?
|
|
10
|
+
failures.empty?
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module ConstantQueries
|
|
5
|
+
module RSpec
|
|
6
|
+
# Methods available inside has_constant_queries. A resolving block gets
|
|
7
|
+
# its own context and example, so its lets apply to that check only;
|
|
8
|
+
# every other check shares one example and one build of default_objects.
|
|
9
|
+
module Declaration
|
|
10
|
+
EXAMPLE = "issues the same SQL statements for 2 and 3 objects"
|
|
11
|
+
SHARED = "resolving with default objects"
|
|
12
|
+
|
|
13
|
+
attr_reader :constant_queries_type, :constant_queries_checks, :constant_queries_isolated,
|
|
14
|
+
:constant_queries_builders, :constant_queries_plan, :constant_queries_ledger
|
|
15
|
+
|
|
16
|
+
# Runs +checks+ in +example+, failing with every unexcepted problem at once.
|
|
17
|
+
def self.run(example, checks)
|
|
18
|
+
root = example.class.constant_queries_root
|
|
19
|
+
plan = root.constant_queries_plan
|
|
20
|
+
context = example.instance_exec(&GraphQL::ConstantQueries.config.for_schema(plan.schema).context)
|
|
21
|
+
evaluator = ->(block, *args) { example.instance_exec(*args, &block) }
|
|
22
|
+
result = Runner.call(plan, checks, evaluator: evaluator, context: context, ledger: root.constant_queries_ledger)
|
|
23
|
+
if result.hidden.size == checks.size
|
|
24
|
+
example.skip("#{checks.map(&:key).uniq.join(", ")} isn't visible in this context")
|
|
25
|
+
end
|
|
26
|
+
RSpec.fail!(result.failures.join("\n\n")) unless result.passed?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Builds n objects for every check without its own objects.
|
|
30
|
+
def default_objects(&build)
|
|
31
|
+
unless constant_queries_root.equal?(self)
|
|
32
|
+
raise ArgumentError, "default_objects goes directly inside has_constant_queries; inside resolving, use objects"
|
|
33
|
+
end
|
|
34
|
+
raise ArgumentError, "default_objects needs a block that builds n objects" unless build
|
|
35
|
+
raise ArgumentError, "default_objects is declared twice" if @constant_queries_default_builder
|
|
36
|
+
|
|
37
|
+
@constant_queries_default_builder = build
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Builds n objects for this check instead of default_objects.
|
|
41
|
+
def objects(&build)
|
|
42
|
+
unless constant_queries_check
|
|
43
|
+
raise ArgumentError, "objects goes inside resolving; directly inside has_constant_queries, use default_objects"
|
|
44
|
+
end
|
|
45
|
+
raise ArgumentError, "objects needs a block that builds n objects" unless build
|
|
46
|
+
|
|
47
|
+
constant_queries_root.constant_queries_builders[constant_queries_check] = build
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# With no keywords, customizes the field's automatic check; with
|
|
51
|
+
# keywords, adds a check passing them as the field's arguments.
|
|
52
|
+
def resolving(name, **keywords, &block)
|
|
53
|
+
raise ArgumentError, "resolving can't be nested inside another resolving" if constant_queries_check
|
|
54
|
+
|
|
55
|
+
root = constant_queries_root
|
|
56
|
+
check = Check.build(root.constant_queries_type, name, keywords)
|
|
57
|
+
if keywords.empty? && !block
|
|
58
|
+
raise ArgumentError, "#{check.description} has no arguments or block, so it changes nothing"
|
|
59
|
+
end
|
|
60
|
+
raise ArgumentError, "#{check.description} is declared twice" if root.constant_queries_checks.include?(check)
|
|
61
|
+
|
|
62
|
+
root.constant_queries_checks << check
|
|
63
|
+
return unless block
|
|
64
|
+
|
|
65
|
+
root.constant_queries_isolated << check
|
|
66
|
+
constant_queries_context(check, caller, &block)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def constant_queries_root
|
|
70
|
+
superclass.respond_to?(:constant_queries_root) ? superclass.constant_queries_root : self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def constant_queries_check
|
|
74
|
+
@constant_queries_check ||
|
|
75
|
+
(superclass.constant_queries_check if superclass.respond_to?(:constant_queries_check))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# +location+ is the has_constant_queries call site, reported for automatic checks.
|
|
79
|
+
def constant_queries_declare(type, schema, location, &declare)
|
|
80
|
+
exceptions = Exceptions.for(schema)
|
|
81
|
+
@constant_queries_type = type
|
|
82
|
+
@constant_queries_checks = []
|
|
83
|
+
@constant_queries_isolated = []
|
|
84
|
+
@constant_queries_builders = {}
|
|
85
|
+
class_exec(&declare)
|
|
86
|
+
raise ArgumentError, "has_constant_queries needs default_objects { |n| ... }" unless @constant_queries_default_builder
|
|
87
|
+
|
|
88
|
+
add_automatic_checks
|
|
89
|
+
@constant_queries_plan = Plan.new(
|
|
90
|
+
type: type, schema: schema, checks: @constant_queries_checks.dup.freeze,
|
|
91
|
+
default_builder: @constant_queries_default_builder, builders: @constant_queries_builders.dup.freeze
|
|
92
|
+
)
|
|
93
|
+
add_shared_example(location)
|
|
94
|
+
|
|
95
|
+
root = self
|
|
96
|
+
before(:context) { root.instance_variable_set(:@constant_queries_ledger, Ledger.new(exceptions)) }
|
|
97
|
+
after(:context) do
|
|
98
|
+
report = root.constant_queries_ledger.stale_report(root.constant_queries_plan.checks)
|
|
99
|
+
RSpec.fail!(report) if report
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A no-argument check for every field resolving didn't already
|
|
104
|
+
# customize, skipping fields with required arguments that have checks.
|
|
105
|
+
private def add_automatic_checks
|
|
106
|
+
@constant_queries_type.fields.each_value do |field|
|
|
107
|
+
check = Check.new(type: @constant_queries_type, field: field, keywords: {}, arguments: {})
|
|
108
|
+
declared = @constant_queries_checks.select { |c| c.field == field }
|
|
109
|
+
next if declared.include?(check) || (check.requires_arguments? && declared.any?)
|
|
110
|
+
|
|
111
|
+
@constant_queries_checks << check
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# One example for every check without a resolving block of its own.
|
|
116
|
+
private def add_shared_example(location)
|
|
117
|
+
shared = @constant_queries_checks - @constant_queries_isolated
|
|
118
|
+
return if shared.empty?
|
|
119
|
+
|
|
120
|
+
context(SHARED, caller: location) do
|
|
121
|
+
it(EXAMPLE, caller: location) { Declaration.run(self, shared) }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# +location+ is the backtrace RSpec reports and reruns the check by.
|
|
126
|
+
private def constant_queries_context(check, location, &block)
|
|
127
|
+
context(check.description, caller: location) do
|
|
128
|
+
@constant_queries_check = check
|
|
129
|
+
class_exec(&block)
|
|
130
|
+
it(EXAMPLE, caller: location) { Declaration.run(self, [check]) }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module ConstantQueries
|
|
5
|
+
module RSpec
|
|
6
|
+
# has_constant_queries for every example group, and check B's "declares
|
|
7
|
+
# has_constant_queries" example in each inventory type's conventional spec.
|
|
8
|
+
module Dsl
|
|
9
|
+
def self.extended(group)
|
|
10
|
+
return unless group.superclass == ::RSpec::Core::ExampleGroup
|
|
11
|
+
return unless group.described_class.is_a?(Class) && group.described_class < GraphQL::Schema::Object
|
|
12
|
+
|
|
13
|
+
entry = Inventory.entry_for(group.described_class)
|
|
14
|
+
return unless entry && Pathname(group.metadata[:absolute_file_path]) == entry.spec_path
|
|
15
|
+
|
|
16
|
+
group.it("declares has_constant_queries") do
|
|
17
|
+
next if self.class.constant_queries_declared?
|
|
18
|
+
|
|
19
|
+
RSpec.fail!("#{entry.type.name} has no constant queries declaration; add " \
|
|
20
|
+
"`has_constant_queries { default_objects { |n| ... } }` to this describe block")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Checks that every field of described_class issues the same SQL
|
|
25
|
+
# statements for 2 objects as for 3. +schema+ defaults to the type's
|
|
26
|
+
# inventory entry's.
|
|
27
|
+
def has_constant_queries(schema: nil, &declare)
|
|
28
|
+
unless superclass == ::RSpec::Core::ExampleGroup
|
|
29
|
+
raise ArgumentError, "call has_constant_queries at the top level of the describe block"
|
|
30
|
+
end
|
|
31
|
+
raise ArgumentError, "has_constant_queries needs a block: { default_objects { |n| ... } }" unless declare
|
|
32
|
+
raise ArgumentError, "has_constant_queries can only be declared once" if @constant_queries_declared
|
|
33
|
+
|
|
34
|
+
type = described_class
|
|
35
|
+
schema ||= Inventory.entry_for(type)&.schema
|
|
36
|
+
raise ArgumentError, "#{type.inspect} isn't a type of any registered schema; pass schema:" unless schema
|
|
37
|
+
|
|
38
|
+
@constant_queries_declared = true
|
|
39
|
+
location = caller
|
|
40
|
+
describe("has constant queries", caller: location) do
|
|
41
|
+
extend Declaration
|
|
42
|
+
|
|
43
|
+
constant_queries_declare(type, schema, location, &declare)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def constant_queries_declared?
|
|
48
|
+
@constant_queries_declared
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module ConstantQueries
|
|
5
|
+
module RSpec
|
|
6
|
+
# has_specs_for_all_types, for groups describing a registered schema.
|
|
7
|
+
module SchemaDsl
|
|
8
|
+
# Check A: fails with every type of described_class without a spec file
|
|
9
|
+
# at its expected path. +except+ is a Regexp, or an array of them,
|
|
10
|
+
# matched against type names.
|
|
11
|
+
def has_specs_for_all_types(except: [])
|
|
12
|
+
Array(except).each do |pattern|
|
|
13
|
+
raise ArgumentError, "except takes Regexps, not #{pattern.inspect}" unless pattern.is_a?(Regexp)
|
|
14
|
+
end
|
|
15
|
+
schema = described_class
|
|
16
|
+
it("has a spec for every type", caller: caller) do
|
|
17
|
+
missing = Inventory.missing_specs(schema, except: except)
|
|
18
|
+
RSpec.fail!("Missing type specs:\n#{missing.map { |line| " #{line}" }.join("\n")}") if missing.any?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/core"
|
|
4
|
+
require "rspec/expectations"
|
|
5
|
+
require "graphql/constant_queries"
|
|
6
|
+
require_relative "rspec/declaration"
|
|
7
|
+
require_relative "rspec/dsl"
|
|
8
|
+
require_relative "rspec/schema_dsl"
|
|
9
|
+
|
|
10
|
+
module GraphQL
|
|
11
|
+
module ConstantQueries
|
|
12
|
+
# RSpec adapter: has_constant_queries in type specs, has_specs_for_all_types in schema specs.
|
|
13
|
+
module RSpec
|
|
14
|
+
def self.fail!(message)
|
|
15
|
+
raise ::RSpec::Expectations::ExpectationNotMetError,
|
|
16
|
+
"#{message}\n\nSee #{GraphQL::ConstantQueries.config.docs_url}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Registration must happen before spec files load: RSpec applies the filter
|
|
23
|
+
# when each group is defined.
|
|
24
|
+
RSpec.configure do |config|
|
|
25
|
+
config.extend GraphQL::ConstantQueries::RSpec::Dsl
|
|
26
|
+
config.extend GraphQL::ConstantQueries::RSpec::SchemaDsl,
|
|
27
|
+
described_class: ->(klass) { GraphQL::ConstantQueries.config.registered?(klass) }
|
|
28
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module ConstantQueries
|
|
5
|
+
# Runs a plan's checks against objects its builders make.
|
|
6
|
+
module Runner
|
|
7
|
+
# +evaluator+ calls a block with arguments in the test's scope, e.g.
|
|
8
|
+
# ->(block, *args) { example.instance_exec(*args, &block) }.
|
|
9
|
+
def self.call(plan, checks, evaluator:, context:, ledger:)
|
|
10
|
+
harness = Harness.new(schema: plan.schema, type: plan.type, context: context)
|
|
11
|
+
fields = checks.to_h { |check| [check, harness.visible_field(check.field.graphql_name)] }.compact
|
|
12
|
+
problems = {}
|
|
13
|
+
fields.group_by { |check, _field| plan.builder_for(check) }.each do |builder, pairs|
|
|
14
|
+
problems.merge!(run(harness, pairs, builder, evaluator))
|
|
15
|
+
end
|
|
16
|
+
failures = problems.filter_map { |check, problem| ledger.record(check, problem) }
|
|
17
|
+
Result.new(problems: problems, failures: failures, hidden: checks - fields.keys)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Reloader refreshes records between runs; anything else can keep state
|
|
21
|
+
# an earlier check memoized, so each check gets its own build.
|
|
22
|
+
private_class_method def self.run(harness, pairs, builder, evaluator)
|
|
23
|
+
build = -> { [1, 2, 3].index_with { |n| evaluator.call(builder, n) } }
|
|
24
|
+
objects_by_n = build.call
|
|
25
|
+
reusable = objects_by_n.values.flatten.all? { |object| Reloader.reloadable?(object) }
|
|
26
|
+
pairs.each_with_index.to_h do |(check, field), index|
|
|
27
|
+
objects_by_n = build.call unless index.zero? || reusable
|
|
28
|
+
[check, harness.problem(field, check.arguments_with(evaluator), objects_by_n)]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module ConstantQueries
|
|
5
|
+
# Records SQL statements issued inside a block, skipping schema lookups,
|
|
6
|
+
# transaction control, and query-cache hits.
|
|
7
|
+
class StatementLog
|
|
8
|
+
IGNORED_NAMES = /SCHEMA|SAVEPOINT|TRANSACTION/
|
|
9
|
+
|
|
10
|
+
attr_reader :statements
|
|
11
|
+
|
|
12
|
+
def self.normalize(sql)
|
|
13
|
+
sql
|
|
14
|
+
.gsub(/'(?:[^']|'')*'/, "?")
|
|
15
|
+
.gsub(/\b\d+\b/, "?")
|
|
16
|
+
.gsub(/\(\s*\$?\?(?:\s*,\s*\$?\?)*\s*\)/, "(...)")
|
|
17
|
+
.squish
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Failure text for when the 3-object run issued a different number of
|
|
21
|
+
# statements than the 2-object run.
|
|
22
|
+
def self.growth_report(two, three)
|
|
23
|
+
more, fewer = (three.count > two.count) ? [three, two] : [two, three]
|
|
24
|
+
extra = more.growth_over(fewer)
|
|
25
|
+
extra = more.statements if extra.empty?
|
|
26
|
+
[
|
|
27
|
+
"issues SQL statements per object",
|
|
28
|
+
" 2 objects → #{two.count} statements, 3 objects → #{three.count} statements",
|
|
29
|
+
" statements the #{more.equal?(three) ? 3 : 2}-object run issued more often:",
|
|
30
|
+
*extra.map { |sql| " #{sql}" }
|
|
31
|
+
].join("\n")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The SQL without query-log comments and with each IN list sorted, so the
|
|
35
|
+
# same rows in another order match.
|
|
36
|
+
def self.canonical(sql)
|
|
37
|
+
sql
|
|
38
|
+
.gsub(%r{/\*.*?\*/}m, "")
|
|
39
|
+
.gsub(/\bIN \(([^()]*)\)/) { "IN (#{$1.split(/\s*,\s*/).sort.join(", ")})" }
|
|
40
|
+
.squish
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize
|
|
44
|
+
@statements = []
|
|
45
|
+
@seen = Set.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def record(&block)
|
|
49
|
+
callback = ->(*, payload) { @statements << payload[:sql] unless ignored?(payload) }
|
|
50
|
+
ActiveSupport::Notifications.subscribed(callback, "sql.active_record", &block)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def count
|
|
54
|
+
statements.size
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def growth_over(baseline)
|
|
58
|
+
mine = normalized_tally
|
|
59
|
+
theirs = baseline.normalized_tally
|
|
60
|
+
mine.select { |sql, n| n > theirs.fetch(sql, 0) }.keys
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
protected def normalized_tally
|
|
64
|
+
statements.map { |sql| self.class.normalize(sql) }.tally
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The query cache only matches identical SQL, so a repeat with its IN list
|
|
68
|
+
# in another order (e.g. ids sorted by a random name) is skipped as a hit.
|
|
69
|
+
private def ignored?(payload)
|
|
70
|
+
payload[:cached] || IGNORED_NAMES.match?(payload[:name].to_s) ||
|
|
71
|
+
!@seen.add?([self.class.canonical(payload[:sql]), binds(payload)])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Bound values, so one SQL text run for different ids counts each time.
|
|
75
|
+
private def binds(payload)
|
|
76
|
+
binds = payload[:type_casted_binds]
|
|
77
|
+
binds = binds.respond_to?(:call) ? binds.call : binds
|
|
78
|
+
Array(binds).map(&:to_s).sort
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module ConstantQueries
|
|
5
|
+
# A subclass of +schema+ whose only root field returns
|
|
6
|
+
# context[OBJECTS_KEY] as +type+. Items are nullable so unauthorized
|
|
7
|
+
# objects come back as nulls instead of errors.
|
|
8
|
+
module TestSchema
|
|
9
|
+
OBJECTS_KEY = :constant_queries_objects
|
|
10
|
+
|
|
11
|
+
def self.for(schema, type)
|
|
12
|
+
@cache ||= {}
|
|
13
|
+
@cache[[schema, type]] ||= Class.new(schema) do
|
|
14
|
+
# graphql-ruby doesn't inherit dataloader_class; without this every source runs unbatched.
|
|
15
|
+
self.dataloader_class = schema.dataloader_class
|
|
16
|
+
query(
|
|
17
|
+
Class.new(GraphQL::Schema::Object) do
|
|
18
|
+
graphql_name "ConstantQueriesRoot"
|
|
19
|
+
field :objects, [type, {null: true}], null: false
|
|
20
|
+
|
|
21
|
+
def objects
|
|
22
|
+
context[TestSchema::OBJECTS_KEY]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "active_record"
|
|
6
|
+
require "active_support/core_ext/enumerable"
|
|
7
|
+
require "active_support/core_ext/object/blank"
|
|
8
|
+
require "active_support/core_ext/string/filters"
|
|
9
|
+
require "active_support/core_ext/string/inflections"
|
|
10
|
+
require "graphql"
|
|
11
|
+
|
|
12
|
+
require_relative "constant_queries/version"
|
|
13
|
+
|
|
14
|
+
module GraphQL
|
|
15
|
+
# Proves every field of every object type issues the same SQL statements
|
|
16
|
+
# for 2 objects as for 3.
|
|
17
|
+
module ConstantQueries
|
|
18
|
+
Error = Class.new(StandardError)
|
|
19
|
+
ConfigurationError = Class.new(Error)
|
|
20
|
+
|
|
21
|
+
README_URL = "https://github.com/stevecrozz/graphql-constant_queries#readme"
|
|
22
|
+
|
|
23
|
+
def self.config
|
|
24
|
+
@config ||= Configuration.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.configure
|
|
28
|
+
yield config
|
|
29
|
+
reset_caches!
|
|
30
|
+
config
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Forgets the configuration and everything derived from it.
|
|
34
|
+
def self.reset!
|
|
35
|
+
@config = nil
|
|
36
|
+
reset_caches!
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.reset_caches!
|
|
40
|
+
Inventory.reset! if const_defined?(:Inventory)
|
|
41
|
+
Exceptions.reset! if const_defined?(:Exceptions)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
require_relative "constant_queries/configuration"
|
|
47
|
+
require_relative "constant_queries/statement_log"
|
|
48
|
+
require_relative "constant_queries/reloader"
|
|
49
|
+
require_relative "constant_queries/test_schema"
|
|
50
|
+
require_relative "constant_queries/operation_builder"
|
|
51
|
+
require_relative "constant_queries/harness"
|
|
52
|
+
require_relative "constant_queries/inventory"
|
|
53
|
+
require_relative "constant_queries/check"
|
|
54
|
+
require_relative "constant_queries/exceptions"
|
|
55
|
+
require_relative "constant_queries/ledger"
|
|
56
|
+
require_relative "constant_queries/plan"
|
|
57
|
+
require_relative "constant_queries/result"
|
|
58
|
+
require_relative "constant_queries/runner"
|
metadata
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: graphql-constant_queries
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Stephen Crosby
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-14 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activerecord
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '7.1'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '7.1'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: activesupport
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '7.1'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '7.1'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: graphql
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '2.5'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '2.5'
|
|
55
|
+
description: Generates a check for every field of every object type in a graphql-ruby
|
|
56
|
+
schema that fails when the field's ActiveRecord statement count grows with the number
|
|
57
|
+
of objects it resolves across.
|
|
58
|
+
email:
|
|
59
|
+
executables: []
|
|
60
|
+
extensions: []
|
|
61
|
+
extra_rdoc_files: []
|
|
62
|
+
files:
|
|
63
|
+
- LICENSE.txt
|
|
64
|
+
- README.md
|
|
65
|
+
- lib/graphql/constant_queries.rb
|
|
66
|
+
- lib/graphql/constant_queries/check.rb
|
|
67
|
+
- lib/graphql/constant_queries/configuration.rb
|
|
68
|
+
- lib/graphql/constant_queries/exceptions.rb
|
|
69
|
+
- lib/graphql/constant_queries/harness.rb
|
|
70
|
+
- lib/graphql/constant_queries/inventory.rb
|
|
71
|
+
- lib/graphql/constant_queries/ledger.rb
|
|
72
|
+
- lib/graphql/constant_queries/operation_builder.rb
|
|
73
|
+
- lib/graphql/constant_queries/plan.rb
|
|
74
|
+
- lib/graphql/constant_queries/reloader.rb
|
|
75
|
+
- lib/graphql/constant_queries/result.rb
|
|
76
|
+
- lib/graphql/constant_queries/rspec.rb
|
|
77
|
+
- lib/graphql/constant_queries/rspec/declaration.rb
|
|
78
|
+
- lib/graphql/constant_queries/rspec/dsl.rb
|
|
79
|
+
- lib/graphql/constant_queries/rspec/schema_dsl.rb
|
|
80
|
+
- lib/graphql/constant_queries/runner.rb
|
|
81
|
+
- lib/graphql/constant_queries/statement_log.rb
|
|
82
|
+
- lib/graphql/constant_queries/test_schema.rb
|
|
83
|
+
- lib/graphql/constant_queries/version.rb
|
|
84
|
+
homepage: https://github.com/stevecrozz/graphql-constant_queries
|
|
85
|
+
licenses:
|
|
86
|
+
- MIT
|
|
87
|
+
metadata:
|
|
88
|
+
allowed_push_host: https://rubygems.org
|
|
89
|
+
source_code_uri: https://github.com/stevecrozz/graphql-constant_queries
|
|
90
|
+
post_install_message:
|
|
91
|
+
rdoc_options: []
|
|
92
|
+
require_paths:
|
|
93
|
+
- lib
|
|
94
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
95
|
+
requirements:
|
|
96
|
+
- - ">="
|
|
97
|
+
- !ruby/object:Gem::Version
|
|
98
|
+
version: '3.2'
|
|
99
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - ">="
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '0'
|
|
104
|
+
requirements: []
|
|
105
|
+
rubygems_version: 3.5.22
|
|
106
|
+
signing_key:
|
|
107
|
+
specification_version: 4
|
|
108
|
+
summary: Prove every graphql-ruby field issues a constant number of SQL statements
|
|
109
|
+
test_files: []
|