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 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module ConstantQueries
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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: []