graphsql 0.4.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 +125 -0
- data/lib/graphsql/aliased_association_error.rb +22 -0
- data/lib/graphsql/mapping.rb +53 -0
- data/lib/graphsql/resolver.rb +194 -0
- data/lib/graphsql/unknown_column_error.rb +21 -0
- data/lib/graphsql/version.rb +5 -0
- data/lib/graphsql.rb +29 -0
- metadata +135 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6cf0933b75d22d60adbda095d91985202111a72476a1d418b9b6b6c19a9e26c3
|
|
4
|
+
data.tar.gz: dc5823be1f0741913aa1166be8c4826bd7744623d5fd578457a1cc252ec0b58d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d55b842c33c1bebdfdaec2cf0fe8939f2ac7daebac8d61e4c1c85cd57b10120fec5acb7e937ffbfb50c73cc23055e94a2df3ecd009ab40dcd8a88d5f9b4c0356
|
|
7
|
+
data.tar.gz: 76c2e45232b2ee3788cb313394f8263be8a51f94e9bc257a9f0275ac1e2e1e3aaaee3386173217632d0923adbcb407ff33827e0f4a09e5efe2e5f92ec3b39f80
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dot Matrix Consulting
|
|
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,125 @@
|
|
|
1
|
+
# GraphSQL
|
|
2
|
+
|
|
3
|
+
GraphSQL maps GraphQL type fields to ActiveRecord columns and associations,
|
|
4
|
+
then applies a GraphQL lookahead to an ActiveRecord relation. It keeps query
|
|
5
|
+
types declarative while avoiding unnecessary SQL columns and common N+1 reads.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
class Types::PersonaType < Types::BaseObject
|
|
11
|
+
include GraphSQL::Mapping
|
|
12
|
+
|
|
13
|
+
graphsql_model Persona
|
|
14
|
+
graphsql_column :handle
|
|
15
|
+
|
|
16
|
+
field :handle, String, null: false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Types::GameType < Types::BaseObject
|
|
20
|
+
include GraphSQL::Mapping
|
|
21
|
+
|
|
22
|
+
graphsql_model Game
|
|
23
|
+
graphsql_column :slug
|
|
24
|
+
graphsql_column :name
|
|
25
|
+
graphsql_association :owner, to: Types::PersonaType
|
|
26
|
+
|
|
27
|
+
field :slug, String, null: false
|
|
28
|
+
field :name, String, null: false
|
|
29
|
+
field :owner, Types::PersonaType, null: false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class Types::QueryType < Types::BaseObject
|
|
33
|
+
field :games, [Types::GameType], null: false, extras: [:lookahead]
|
|
34
|
+
|
|
35
|
+
def games(lookahead:)
|
|
36
|
+
GraphSQL.resolve(Game.all, lookahead: lookahead, type: Types::GameType)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`GraphSQL.resolve` always selects the model primary key, adds requested mapped
|
|
42
|
+
columns, retains foreign keys required by requested `belongs_to` associations,
|
|
43
|
+
and preloads requested mapped associations. For STI models it also retains the
|
|
44
|
+
inheritance-discriminator column (`type` by default), so rows still
|
|
45
|
+
instantiate as the correct subclass. Unmapped GraphQL fields remain the
|
|
46
|
+
responsibility of their normal resolvers.
|
|
47
|
+
|
|
48
|
+
Mappings may expose a database attribute or association under another GraphQL
|
|
49
|
+
field name:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
graphsql_column :display_name, as: :name
|
|
53
|
+
graphsql_association :organization_memberships, as: :memberships
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`graphsql_column`/`required_columns:` must name a real column on the mapped
|
|
57
|
+
model — not a Ruby method, not a virtual `attribute` with no DB backing.
|
|
58
|
+
`GraphSQL.resolve` raises `GraphSQL::UnknownColumnError` naming the type,
|
|
59
|
+
field, and column the moment it's requested, rather than letting a bad name
|
|
60
|
+
reach Arel and fail later as an opaque `ActiveRecord::StatementInvalid`.
|
|
61
|
+
|
|
62
|
+
## Nested column-limiting (`to:`)
|
|
63
|
+
|
|
64
|
+
When an association's `to:` target type is itself GraphSQL-mapped, column
|
|
65
|
+
selection recurses: only the fields actually requested on the *nested* object
|
|
66
|
+
get selected on its preload query too, at any depth (`owner { organization {
|
|
67
|
+
name } }` limits columns on both `owner` and its own `organization`).
|
|
68
|
+
|
|
69
|
+
`to:` accepts a `-> { ... }` proc instead of a bare class for mutually
|
|
70
|
+
associated types that would otherwise have a load-order problem (a `Game`
|
|
71
|
+
type pointing at an `Organization` type that itself points back at `Game`):
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
graphsql_association :games, to: -> { Types::GameType }
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Without a resolvable `to:` (omitted, a class that turns out not to match the
|
|
78
|
+
association's actual target, a polymorphic `belongs_to`, or a `:through`
|
|
79
|
+
association), that one association just falls back to a plain full-row
|
|
80
|
+
preload — the optimization is best-effort per-association; a query mixing a
|
|
81
|
+
nestable and a non-nestable association preloads both correctly, just with
|
|
82
|
+
only the nestable one getting the narrower `SELECT`.
|
|
83
|
+
|
|
84
|
+
Mapping the *same* underlying association under two different, both-nestable
|
|
85
|
+
GraphQL fields on one type (e.g. `owner`/`employer` both -> `belongs_to
|
|
86
|
+
:organization`) and requesting both in one query raises
|
|
87
|
+
`GraphSQL::AliasedAssociationError` rather than silently applying only one
|
|
88
|
+
alias's column-limited scope to both — the two aliases share a single
|
|
89
|
+
association proxy on the record, so there's no way to preload them
|
|
90
|
+
independently. Only one of the aliased fields, not both, can be requested in
|
|
91
|
+
the same query.
|
|
92
|
+
|
|
93
|
+
## Pagination / `.count`
|
|
94
|
+
|
|
95
|
+
`GraphSQL.resolve` always applies a `.select()` covering every mapped column.
|
|
96
|
+
Rails' default `.count` column inference joins every `select_value` into
|
|
97
|
+
`COUNT(col1, col2, ...)`, invalid SQL outside Postgres — so a **bare**
|
|
98
|
+
`.count` on a relation with more than one selected column would normally
|
|
99
|
+
raise `ActiveRecord::StatementInvalid`. A relation returned by
|
|
100
|
+
`GraphSQL.resolve` avoids this: a bare `.count` (no column, no block) counts
|
|
101
|
+
by the primary key instead, since it's always among the selected columns and
|
|
102
|
+
always safe to count. `.count(:some_column)` and `.count { |r| ... }` still
|
|
103
|
+
pass straight through unchanged.
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
relation.count # counts by primary key, safe by default
|
|
107
|
+
relation.count(:id) # explicit column still works as normal
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
This only applies to the `ActiveRecord::Relation` GraphSQL returns directly.
|
|
111
|
+
`GraphSQL.resolve` returns a plain `Array` instead whenever the query also
|
|
112
|
+
selects a nested GraphSQL-mapped association (see "Nested column-limiting"
|
|
113
|
+
above) — `Array#count` has no SQL to generate and is unaffected either way.
|
|
114
|
+
|
|
115
|
+
Pagination (`.limit`/`.offset`, `geared_pagination`, Kaminari, etc.) still
|
|
116
|
+
belongs on the *pre-resolve* relation, not chained onto `GraphSQL.resolve`'s
|
|
117
|
+
return value — `GraphSQL.resolve` is meant to be the terminal step right
|
|
118
|
+
before returning the field's value.
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
bundle install
|
|
124
|
+
bundle exec rake test
|
|
125
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphSQL
|
|
4
|
+
# Raised by Resolver#nested_associations when a query requests the same
|
|
5
|
+
# underlying AR association under two different GraphQL field names (e.g.
|
|
6
|
+
# `owner`/`employer` both mapped to `belongs_to :organization`), both
|
|
7
|
+
# resolving to a nestable (`to:`-mapped) target. Both aliases share one
|
|
8
|
+
# association proxy on the record, and ActiveRecord::Associations::Preloader
|
|
9
|
+
# skips an association it's already marked loaded — so only the first
|
|
10
|
+
# alias's column-limited scope actually takes effect, and the second
|
|
11
|
+
# alias's requested fields silently go missing (up to and including a
|
|
12
|
+
# MissingAttributeError). Raising here turns that into a loud, immediate
|
|
13
|
+
# failure instead of a silent one.
|
|
14
|
+
class AliasedAssociationError < StandardError
|
|
15
|
+
def self.for_duplicate(type:, association:, fields:)
|
|
16
|
+
new("#{type} maps association `#{association}` under multiple requested GraphQL fields " \
|
|
17
|
+
"(#{fields.map(&:inspect).join(', ')}) — GraphQL.resolve can only apply one column-limited " \
|
|
18
|
+
"preload scope per underlying association per query. Request only one of these fields, or " \
|
|
19
|
+
"don't map `#{association}` under more than one GraphQL field name.")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphSQL
|
|
4
|
+
module Mapping
|
|
5
|
+
Association = Data.define(:name, :field_name, :type) do
|
|
6
|
+
# `type:` may be a class or a `-> { ... }` proc (for forward references
|
|
7
|
+
# between mutually-associated types, e.g. Game <-> Organization).
|
|
8
|
+
def resolved_type
|
|
9
|
+
type.respond_to?(:call) ? type.call : type
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.included(base)
|
|
14
|
+
base.extend(ClassMethods)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
module ClassMethods
|
|
18
|
+
def graphsql_model(model = nil)
|
|
19
|
+
return @graphsql_model = model if model
|
|
20
|
+
|
|
21
|
+
@graphsql_model || graphsql_mapping_ancestor&.graphsql_model
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def graphsql_column(name, as: name)
|
|
25
|
+
(@graphsql_columns ||= {})[as.to_sym] = name.to_sym
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def graphsql_association(name, as: name, to: nil)
|
|
29
|
+
(@graphsql_associations ||= {})[as.to_sym] = Association.new(
|
|
30
|
+
name: name.to_sym,
|
|
31
|
+
field_name: as.to_sym,
|
|
32
|
+
type: to
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def graphsql_columns
|
|
37
|
+
inherited = graphsql_mapping_ancestor&.graphsql_columns || {}
|
|
38
|
+
inherited.merge(@graphsql_columns || {})
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def graphsql_associations
|
|
42
|
+
inherited = graphsql_mapping_ancestor&.graphsql_associations || {}
|
|
43
|
+
inherited.merge(@graphsql_associations || {})
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def graphsql_mapping_ancestor
|
|
49
|
+
superclass if superclass.respond_to?(:graphsql_model)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphSQL
|
|
4
|
+
class Resolver
|
|
5
|
+
# A requested association whose target type also declares GraphSQL
|
|
6
|
+
# mappings, paired with the reflection needed to preload it safely and
|
|
7
|
+
# the nested lookahead needed to know which of ITS fields were selected.
|
|
8
|
+
NestedAssociation = Data.define(:mapping, :reflection, :target_type, :lookahead)
|
|
9
|
+
|
|
10
|
+
# select_columns always applies a multi-column .select() (primary key +
|
|
11
|
+
# every mapped column), which makes a *bare* `.count` ambiguous: Rails'
|
|
12
|
+
# default column inference joins every select_value into `COUNT(col1,
|
|
13
|
+
# col2, ...)`, invalid SQL outside Postgres. The primary key is always
|
|
14
|
+
# among those columns and is always a safe, correct thing to count, so a
|
|
15
|
+
# bare `.count`/`.count(&block)` defaults to it instead of failing —
|
|
16
|
+
# while `.count(:some_column)` still passes straight through untouched.
|
|
17
|
+
module CountsByPrimaryKey
|
|
18
|
+
def count(column_name = nil, &block)
|
|
19
|
+
return super if column_name || block
|
|
20
|
+
|
|
21
|
+
super(klass.primary_key)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(relation:, lookahead:, type:, required_associations: [], required_columns: [])
|
|
26
|
+
@relation = relation
|
|
27
|
+
@lookahead = lookahead
|
|
28
|
+
@type = type
|
|
29
|
+
@required_associations = required_associations
|
|
30
|
+
@required_columns = required_columns
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def resolve
|
|
34
|
+
return relation unless supported?(relation, type, lookahead)
|
|
35
|
+
|
|
36
|
+
resolve_relation(relation, type, lookahead)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
attr_reader :relation, :lookahead, :type, :required_associations, :required_columns
|
|
42
|
+
|
|
43
|
+
def resolve_relation(source, source_type, source_lookahead)
|
|
44
|
+
selected = select_columns(source, source_type, source_lookahead, required: required_columns)
|
|
45
|
+
nested = nested_associations(source, source_type, source_lookahead)
|
|
46
|
+
nested_names = nested.map { |assoc| assoc.mapping.name }
|
|
47
|
+
plain_names = association_mappings(source_type, source_lookahead).map(&:name).uniq - nested_names
|
|
48
|
+
eager_names = (plain_names + required_associations).uniq
|
|
49
|
+
|
|
50
|
+
return selected if nested.empty? && eager_names.empty?
|
|
51
|
+
return selected.includes(*eager_names) if nested.empty?
|
|
52
|
+
|
|
53
|
+
# Preloading a *scoped* (column-limited) association requires real
|
|
54
|
+
# record instances (ActiveRecord::Associations::Preloader operates on
|
|
55
|
+
# an array, not a relation) — this forces the load a step earlier than
|
|
56
|
+
# a plain `.includes` would, only when nested optimization applies.
|
|
57
|
+
records = selected.to_a
|
|
58
|
+
ActiveRecord::Associations::Preloader.new(records: records, associations: eager_names).call if eager_names.any?
|
|
59
|
+
preload_nested!(records, nested)
|
|
60
|
+
records
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def supported?(source, source_type, source_lookahead)
|
|
64
|
+
source.is_a?(ActiveRecord::Relation) &&
|
|
65
|
+
source_type.respond_to?(:graphsql_model) &&
|
|
66
|
+
source_type.graphsql_model == source.klass &&
|
|
67
|
+
source_lookahead.respond_to?(:selections)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def field_names(source_lookahead)
|
|
71
|
+
source_lookahead.selections.filter_map do |selection|
|
|
72
|
+
selection.name.to_sym if selection.respond_to?(:name)
|
|
73
|
+
end.uniq
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def association_mappings(source_type, source_lookahead)
|
|
77
|
+
source_type.graphsql_associations.slice(*field_names(source_lookahead)).values
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Requested associations whose target type is itself GraphSQL-mapped and
|
|
81
|
+
# matches the reflection's actual class, with a real nested lookahead to
|
|
82
|
+
# drive column selection. Anything else (no `to:`, mismatched `to:`,
|
|
83
|
+
# polymorphic, `:through`) safely falls back to a plain full-row preload —
|
|
84
|
+
# optimization is best-effort, correctness never depends on it applying.
|
|
85
|
+
def nested_associations(source, source_type, source_lookahead)
|
|
86
|
+
nested = association_mappings(source_type, source_lookahead).filter_map do |mapping|
|
|
87
|
+
reflection = source.klass.reflect_on_association(mapping.name)
|
|
88
|
+
next unless reflection
|
|
89
|
+
next if reflection.polymorphic? || reflection.through_reflection
|
|
90
|
+
|
|
91
|
+
target_type = mapping.resolved_type
|
|
92
|
+
next unless target_type.respond_to?(:graphsql_model)
|
|
93
|
+
next unless target_type.graphsql_model == reflection.klass
|
|
94
|
+
|
|
95
|
+
nested_lookahead = source_lookahead.selection(mapping.field_name)
|
|
96
|
+
next unless nested_lookahead.respond_to?(:selections)
|
|
97
|
+
|
|
98
|
+
NestedAssociation.new(mapping:, reflection:, target_type:, lookahead: nested_lookahead)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
validate_no_aliased_duplicates!(nested, source_type)
|
|
102
|
+
nested
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Two GraphQL fields mapped to the same underlying association (e.g.
|
|
106
|
+
# `owner`/`employer` both -> `belongs_to :organization`), both nestable,
|
|
107
|
+
# both requested in the same query, would otherwise silently share one
|
|
108
|
+
# preloaded (and column-limited) association proxy — see
|
|
109
|
+
# AliasedAssociationError for why that loses data instead of erroring.
|
|
110
|
+
def validate_no_aliased_duplicates!(nested, source_type)
|
|
111
|
+
nested.group_by { |assoc| assoc.reflection.name }.each_value do |group|
|
|
112
|
+
next if group.size == 1
|
|
113
|
+
|
|
114
|
+
raise AliasedAssociationError.for_duplicate(
|
|
115
|
+
type: source_type,
|
|
116
|
+
association: group.first.reflection.name,
|
|
117
|
+
fields: group.map { |assoc| assoc.mapping.field_name }
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def select_columns(source, source_type, source_lookahead, required: [])
|
|
123
|
+
names = field_names(source_lookahead)
|
|
124
|
+
mapped_fields = source_type.graphsql_columns.slice(*names)
|
|
125
|
+
validate_columns!(mapped_fields, required, source.klass, source_type)
|
|
126
|
+
mapped = mapped_fields.values
|
|
127
|
+
fks = association_mappings(source_type, source_lookahead).filter_map do |mapping|
|
|
128
|
+
reflection = source.klass.reflect_on_association(mapping.name)
|
|
129
|
+
reflection.foreign_key if reflection&.belongs_to? && !reflection.polymorphic?
|
|
130
|
+
end
|
|
131
|
+
required_fks = required_associations.filter_map do |assoc_name|
|
|
132
|
+
reflection = source.klass.reflect_on_association(assoc_name)
|
|
133
|
+
reflection.foreign_key if reflection&.belongs_to? && !reflection.polymorphic?
|
|
134
|
+
end
|
|
135
|
+
# STI models need their type-discriminator column present in the
|
|
136
|
+
# SELECT to instantiate the correct subclass — without it, every row
|
|
137
|
+
# silently comes back as the base class (no error, wrong class).
|
|
138
|
+
sti_column = source.klass.inheritance_column if source.klass.columns_hash.key?(source.klass.inheritance_column)
|
|
139
|
+
columns = [source.klass.primary_key, sti_column, *mapped, *fks, *required_fks, *required].compact.map(&:to_s).uniq
|
|
140
|
+
table = source.klass.arel_table
|
|
141
|
+
|
|
142
|
+
source.select(*columns.map { |column| table[column] }).extend(CountsByPrimaryKey)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Fails fast and legibly instead of letting a nonexistent column reach
|
|
146
|
+
# Arel, where it would only surface once the query executes as a raw
|
|
147
|
+
# ActiveRecord::StatementInvalid with no mention of GraphSQL, the
|
|
148
|
+
# GraphQL type, or which field caused it.
|
|
149
|
+
def validate_columns!(mapped_fields, required, model, source_type)
|
|
150
|
+
known = model.column_names
|
|
151
|
+
|
|
152
|
+
mapped_fields.each do |field, column|
|
|
153
|
+
next if known.include?(column.to_s)
|
|
154
|
+
|
|
155
|
+
raise UnknownColumnError.for_mapped_column(type: source_type, field: field, column: column, model: model)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
required.each do |column|
|
|
159
|
+
next if known.include?(column.to_s)
|
|
160
|
+
|
|
161
|
+
raise UnknownColumnError.for_required_column(column: column, model: model)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def preload_nested!(records, nested)
|
|
166
|
+
return if records.empty?
|
|
167
|
+
|
|
168
|
+
nested.each do |assoc|
|
|
169
|
+
scope = nested_scope(assoc)
|
|
170
|
+
|
|
171
|
+
ActiveRecord::Associations::Preloader.new(
|
|
172
|
+
records: records,
|
|
173
|
+
associations: assoc.mapping.name,
|
|
174
|
+
scope: scope
|
|
175
|
+
).call
|
|
176
|
+
|
|
177
|
+
child_records = records.flat_map { |record| Array(record.public_send(assoc.mapping.name)) }
|
|
178
|
+
preload_nested!(child_records, nested_associations(scope, assoc.target_type, assoc.lookahead))
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# belongs_to's join key lives on the *requesting* side (already selected
|
|
183
|
+
# by select_columns' `fks`, above) so the child scope needs nothing extra.
|
|
184
|
+
# has_many/has_one's join key lives on the *child* table — the preloader
|
|
185
|
+
# groups loaded children by that column, so it must be selected even
|
|
186
|
+
# though it's never GraphQL-mapped, or every association silently
|
|
187
|
+
# resolves empty (confirmed: this is not a hypothetical — see the
|
|
188
|
+
# has_many test coverage for the exact failure mode without it).
|
|
189
|
+
def nested_scope(assoc)
|
|
190
|
+
required = assoc.reflection.belongs_to? ? [] : [assoc.reflection.foreign_key]
|
|
191
|
+
select_columns(assoc.target_type.graphsql_model.all, assoc.target_type, assoc.lookahead, required: required)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphSQL
|
|
4
|
+
# Raised by Resolver#select_columns when a graphsql_column/required_columns:
|
|
5
|
+
# entry names something that isn't an actual column on the model — a plain
|
|
6
|
+
# Ruby method, a virtual `attribute` with no DB backing, or a typo. Without
|
|
7
|
+
# this check the same mistake surfaced as a raw
|
|
8
|
+
# ActiveRecord::StatementInvalid ("no such column: ...") from deep inside
|
|
9
|
+
# Arel/the SQL driver, with nothing pointing back at the offending
|
|
10
|
+
# graphsql_column call.
|
|
11
|
+
class UnknownColumnError < StandardError
|
|
12
|
+
def self.for_mapped_column(type:, field:, column:, model:)
|
|
13
|
+
new("#{type}'s graphsql_column #{field.inspect} maps to `#{column}`, " \
|
|
14
|
+
"but #{model} has no such column")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.for_required_column(column:, model:)
|
|
18
|
+
new("required_columns: referenced `#{column}`, but #{model} has no such column")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/graphsql.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
require "active_support/core_ext/hash/slice"
|
|
5
|
+
|
|
6
|
+
require_relative "graphsql/version"
|
|
7
|
+
require_relative "graphsql/mapping"
|
|
8
|
+
require_relative "graphsql/unknown_column_error"
|
|
9
|
+
require_relative "graphsql/aliased_association_error"
|
|
10
|
+
require_relative "graphsql/resolver"
|
|
11
|
+
|
|
12
|
+
module GraphSQL
|
|
13
|
+
# `required_associations:`/`required_columns:` name associations/columns
|
|
14
|
+
# the caller needs loaded regardless of what the client requested (e.g. a
|
|
15
|
+
# model's #discussion reading title/body and persona.handle to
|
|
16
|
+
# find_or_open a thread, even when the query didn't ask for those
|
|
17
|
+
# fields). Passing these through here, rather than chaining
|
|
18
|
+
# `.select`/`.includes` onto the return value, is required for
|
|
19
|
+
# correctness: #resolve returns a plain Array (not a Relation — see
|
|
20
|
+
# Resolver#resolve_relation) whenever the query also requests a nested
|
|
21
|
+
# GraphQL-mapped association, and a caller-side `.select`/`.includes`
|
|
22
|
+
# chained after the call silently breaks the moment that happens.
|
|
23
|
+
def self.resolve(relation, lookahead:, type:, required_associations: [], required_columns: [])
|
|
24
|
+
Resolver.new(
|
|
25
|
+
relation: relation, lookahead: lookahead, type: type,
|
|
26
|
+
required_associations: required_associations, required_columns: required_columns
|
|
27
|
+
).resolve
|
|
28
|
+
end
|
|
29
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: graphsql
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.4.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Dot Matrix
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '6.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '6.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: activesupport
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '6.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '6.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: graphql
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.13'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.13'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: minitest
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '5.25'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '5.25'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rake
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: sqlite3
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0'
|
|
96
|
+
description: Selects mapped ActiveRecord columns and preloads mapped associations
|
|
97
|
+
from GraphQL lookaheads.
|
|
98
|
+
email:
|
|
99
|
+
- matrix9180@proton.me
|
|
100
|
+
executables: []
|
|
101
|
+
extensions: []
|
|
102
|
+
extra_rdoc_files: []
|
|
103
|
+
files:
|
|
104
|
+
- LICENSE.txt
|
|
105
|
+
- README.md
|
|
106
|
+
- lib/graphsql.rb
|
|
107
|
+
- lib/graphsql/aliased_association_error.rb
|
|
108
|
+
- lib/graphsql/mapping.rb
|
|
109
|
+
- lib/graphsql/resolver.rb
|
|
110
|
+
- lib/graphsql/unknown_column_error.rb
|
|
111
|
+
- lib/graphsql/version.rb
|
|
112
|
+
homepage: https://gitlab.com/maqstaq/graphsql
|
|
113
|
+
licenses:
|
|
114
|
+
- MIT
|
|
115
|
+
metadata:
|
|
116
|
+
source_code_uri: https://gitlab.com/maqstaq/graphsql
|
|
117
|
+
bug_tracker_uri: https://gitlab.com/maqstaq/graphsql/-/issues
|
|
118
|
+
rdoc_options: []
|
|
119
|
+
require_paths:
|
|
120
|
+
- lib
|
|
121
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
122
|
+
requirements:
|
|
123
|
+
- - ">="
|
|
124
|
+
- !ruby/object:Gem::Version
|
|
125
|
+
version: 3.2.0
|
|
126
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - ">="
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: '0'
|
|
131
|
+
requirements: []
|
|
132
|
+
rubygems_version: 4.0.16
|
|
133
|
+
specification_version: 4
|
|
134
|
+
summary: GraphQL lookahead mapping for ActiveRecord relations.
|
|
135
|
+
test_files: []
|