graphiti_gql 0.1.0 → 0.2.2
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 +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +88 -1
- data/lib/graphiti_gql/active_resource.rb +210 -0
- data/lib/graphiti_gql/engine.rb +18 -3
- data/lib/graphiti_gql/errors.rb +16 -0
- data/lib/graphiti_gql/graphiti_hax.rb +238 -58
- data/lib/graphiti_gql/loaders/many_to_many.rb +20 -3
- data/lib/graphiti_gql/schema/fields/attribute.rb +36 -10
- data/lib/graphiti_gql/schema/fields/stats.rb +2 -2
- data/lib/graphiti_gql/schema/fields/to_many.rb +32 -1
- data/lib/graphiti_gql/schema/list_arguments.rb +10 -7
- data/lib/graphiti_gql/schema/query.rb +1 -2
- data/lib/graphiti_gql/schema/resource_type.rb +1 -1
- data/lib/graphiti_gql/schema.rb +32 -1
- data/lib/graphiti_gql/spec_helper.rb +45 -0
- data/lib/graphiti_gql/version.rb +1 -1
- data/lib/graphiti_gql.rb +13 -0
- metadata +4 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 899580a6d3577a31dc7356d894eab26756c2c9876636db69ce5ece2d5eeee3ce
|
4
|
+
data.tar.gz: faf261a7614a0565c9dc3e45b4a9d14d75962aa6665b7302752733584abb5bdc
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: becebe36d5aabea0a75ba3e617f9f62d0830d3f4a35e5becdacef1fbe10f4991621f061521f10bc4c0e527bdc834d5380e9f38ff0b9cc2388a66a7cf25bb33b8
|
7
|
+
data.tar.gz: 37a9a4221261f112100e32f06832a948c30cbef5a06ffcb786f080538c1b9f3894bd3f7f8fc32394f0c2cb122e94c1b1e58cd6b8c38b6e0feab08a30d55bbfb5
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -1,3 +1,90 @@
|
|
1
1
|
# GraphitiGql
|
2
2
|
|
3
|
-
|
3
|
+
GraphQL bindings for [Graphiti](www.graphiti.dev).
|
4
|
+
|
5
|
+
Write code like this:
|
6
|
+
|
7
|
+
```ruby
|
8
|
+
class EmployeeResource < ApplicationResource
|
9
|
+
attribute :first_name, :string
|
10
|
+
attribute :age, :integer
|
11
|
+
|
12
|
+
has_many :positions
|
13
|
+
end
|
14
|
+
```
|
15
|
+
|
16
|
+
Get an API like this:
|
17
|
+
|
18
|
+
```gql
|
19
|
+
query {
|
20
|
+
employees(
|
21
|
+
filter: { firstName: { match: "arha" } },
|
22
|
+
sort: [{ att: age, dir: desc }],
|
23
|
+
first: 10,
|
24
|
+
after: "abc123"
|
25
|
+
) {
|
26
|
+
edges {
|
27
|
+
node {
|
28
|
+
id
|
29
|
+
firstName
|
30
|
+
age
|
31
|
+
positions {
|
32
|
+
nodes {
|
33
|
+
title
|
34
|
+
}
|
35
|
+
}
|
36
|
+
}
|
37
|
+
cursor
|
38
|
+
}
|
39
|
+
stats {
|
40
|
+
total {
|
41
|
+
count
|
42
|
+
}
|
43
|
+
}
|
44
|
+
}
|
45
|
+
}
|
46
|
+
```
|
47
|
+
|
48
|
+
### Getting Started
|
49
|
+
|
50
|
+
```ruby
|
51
|
+
# Gemfile
|
52
|
+
gem 'graphiti'
|
53
|
+
gem "graphiti-rails"
|
54
|
+
gem 'graphiti_gql'
|
55
|
+
```
|
56
|
+
|
57
|
+
```ruby
|
58
|
+
# config/routes.rb
|
59
|
+
|
60
|
+
Rails.application.routes.draw do
|
61
|
+
scope path: ApplicationResource.endpoint_namespace do
|
62
|
+
mount GraphitiGql::Engine, at: "/gql"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
```
|
66
|
+
|
67
|
+
Write your Graphiti code as normal, omit controllers.
|
68
|
+
|
69
|
+
### How does it work?
|
70
|
+
|
71
|
+
This autogenerates `graphql-ruby` code by introspecting Graphiti Resources. Something like this happens under-the-hood:
|
72
|
+
|
73
|
+
```ruby
|
74
|
+
field :employees, [EmployeeType], null: false do
|
75
|
+
argument :filter, EmployeeFilter, required: false
|
76
|
+
# ... etc ...
|
77
|
+
end
|
78
|
+
|
79
|
+
def employees(**arguments)
|
80
|
+
EmployeeResource.all(**arguments).to_a
|
81
|
+
end
|
82
|
+
```
|
83
|
+
|
84
|
+
In practice it's more complicated, but this is the basic premise - use Graphiti resources to handle query and persistence operations; autogenerate `graphql-ruby` code to expose those Resources as an API. This means we play nicely with e.g. telemetry and error-handling libraries because it's all `graphql-ruby` under-the-hood...except for actually **performing** the operations, which is really more a Ruby thing than a GraphQL thing.
|
85
|
+
|
86
|
+
### Caveats
|
87
|
+
|
88
|
+
This rethinks the responsibilities of Graphiti, coupling the execution cycle to `graphql-ruby`. We do this so we can play nicely with other gems in the GQL ecosystem, and saves on development time by offloading responsibilities. The downside is we can no longer run a `JSON:API` with the same codebase, and certain documentation may be out of date.
|
89
|
+
|
90
|
+
Longer-term, we should rip out only the parts of Graphiti we really need and redocument.
|
@@ -0,0 +1,210 @@
|
|
1
|
+
module GraphitiGql
|
2
|
+
module ActiveResource
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
class Node < OpenStruct
|
6
|
+
def initialize(resource, hash)
|
7
|
+
@resource = resource
|
8
|
+
hash.each_pair do |key, value|
|
9
|
+
if value.is_a?(Hash)
|
10
|
+
if (sideload = resource.sideload(key))
|
11
|
+
if value.key?(:edges)
|
12
|
+
hash[key] = value[:edges].map { |v| Node.new(sideload.resource.class, v[:node]) }
|
13
|
+
else
|
14
|
+
hash[key] = Node.new(sideload.resource.class, value)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
super(hash)
|
20
|
+
end
|
21
|
+
|
22
|
+
def decoded_id
|
23
|
+
Base64.decode64(self.id)
|
24
|
+
end
|
25
|
+
|
26
|
+
def int_id
|
27
|
+
decoded_id.to_i
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
class Proxy
|
32
|
+
def initialize(resource, params, ctx)
|
33
|
+
@resource = resource
|
34
|
+
@ctx = ctx
|
35
|
+
@params = params.deep_transform_keys { |key| key.to_s.camelize(:lower).to_sym }
|
36
|
+
(@params[:sort] || []).each do |sort|
|
37
|
+
sort[:att] = sort[:att].to_s.camelize(:lower)
|
38
|
+
sort[:dir] = sort[:dir].to_s
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def to_h(symbolize_keys: true)
|
43
|
+
result = GraphitiGql.run(query, @params, @ctx)
|
44
|
+
result = result.deep_symbolize_keys if symbolize_keys
|
45
|
+
@response = result
|
46
|
+
result
|
47
|
+
end
|
48
|
+
|
49
|
+
def nodes
|
50
|
+
return [] unless data
|
51
|
+
nodes = edges.map { |e| underscore(e[:node]) }
|
52
|
+
nodes.map { |n| Node.new(@resource, n) }
|
53
|
+
end
|
54
|
+
alias :to_a :nodes
|
55
|
+
|
56
|
+
def response
|
57
|
+
@response ||= to_h
|
58
|
+
end
|
59
|
+
|
60
|
+
def data
|
61
|
+
if response.key?(:data)
|
62
|
+
response[:data]
|
63
|
+
else
|
64
|
+
raise "Tried to access 'data', but these errors were returned instead: #{error_messages.join(". ")}."
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def errors
|
69
|
+
response[:errors]
|
70
|
+
end
|
71
|
+
|
72
|
+
def error_messages
|
73
|
+
response[:errors].map { |e| e[:message] }
|
74
|
+
end
|
75
|
+
|
76
|
+
def edges
|
77
|
+
data[data.keys.first][:edges]
|
78
|
+
end
|
79
|
+
|
80
|
+
def stats
|
81
|
+
underscore(data[data.keys.first][:stats])
|
82
|
+
end
|
83
|
+
|
84
|
+
def page_info
|
85
|
+
underscore(data[data.keys.first][:pageInfo])
|
86
|
+
end
|
87
|
+
|
88
|
+
def query
|
89
|
+
name = Schema.registry.key_for(@resource)
|
90
|
+
filter_bang = "!" if @resource.filters.values.any? { |f| f[:required] }
|
91
|
+
sortvar = "$sort: [#{name}Sort!]," if @resource.sorts.any?
|
92
|
+
|
93
|
+
if !(fields = @params[:fields])
|
94
|
+
fields = []
|
95
|
+
@resource.attributes.each_pair do |name, config|
|
96
|
+
(fields << name) if config[:readable]
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
q = %|
|
101
|
+
query #{name} (
|
102
|
+
$filter: #{name}Filter#{filter_bang},
|
103
|
+
#{sortvar}
|
104
|
+
$first: Int,
|
105
|
+
$last: Int,
|
106
|
+
$before: String,
|
107
|
+
$after: String,
|
108
|
+
) {
|
109
|
+
#{@resource.graphql_entrypoint} (
|
110
|
+
filter: $filter,
|
111
|
+
#{ 'sort: $sort,' if sortvar }
|
112
|
+
first: $first,
|
113
|
+
last: $last,
|
114
|
+
before: $before,
|
115
|
+
after: $after,
|
116
|
+
) {
|
117
|
+
edges {
|
118
|
+
node {|
|
119
|
+
|
120
|
+
fields.each do |name|
|
121
|
+
q << %|
|
122
|
+
#{name.to_s.camelize(:lower)}|
|
123
|
+
end
|
124
|
+
|
125
|
+
if @params[:include]
|
126
|
+
includes = Array(@params[:include])
|
127
|
+
# NB HASH (?)
|
128
|
+
includes.each do |inc|
|
129
|
+
sideload = @resource.sideload(inc.to_sym)
|
130
|
+
to_one = [:belongs_to, :has_one, :polymorphic_belongs_to].include?(sideload.type)
|
131
|
+
indent = " " if !to_one
|
132
|
+
q << %|
|
133
|
+
#{inc.to_s.camelize(:lower)} {|
|
134
|
+
if !to_one
|
135
|
+
q << %|
|
136
|
+
edges {
|
137
|
+
node {|
|
138
|
+
end
|
139
|
+
|
140
|
+
r = @resource.sideload(inc.to_sym).resource
|
141
|
+
r.attributes.each_pair do |name, config|
|
142
|
+
next unless config[:readable]
|
143
|
+
q << %|
|
144
|
+
#{indent}#{name.to_s.camelize(:lower)}|
|
145
|
+
end
|
146
|
+
|
147
|
+
if to_one
|
148
|
+
q << %|
|
149
|
+
}|
|
150
|
+
else
|
151
|
+
q << %|
|
152
|
+
}
|
153
|
+
}
|
154
|
+
}|
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
q << %|
|
160
|
+
}
|
161
|
+
}
|
162
|
+
pageInfo {
|
163
|
+
startCursor
|
164
|
+
endCursor
|
165
|
+
hasNextPage
|
166
|
+
hasPreviousPage
|
167
|
+
}|
|
168
|
+
|
169
|
+
if @params[:stats]
|
170
|
+
q << %|
|
171
|
+
stats {|
|
172
|
+
@params[:stats].each_pair do |name, calculations|
|
173
|
+
q << %|
|
174
|
+
#{name.to_s.camelize(:lower)} {|
|
175
|
+
Array(calculations).each do |calc|
|
176
|
+
q << %|
|
177
|
+
#{calc.to_s.camelize(:lower)}|
|
178
|
+
end
|
179
|
+
|
180
|
+
q << %|
|
181
|
+
}|
|
182
|
+
end
|
183
|
+
q << %|
|
184
|
+
}|
|
185
|
+
end
|
186
|
+
|
187
|
+
q << %|
|
188
|
+
}
|
189
|
+
}
|
190
|
+
|
|
191
|
+
|
192
|
+
q
|
193
|
+
end
|
194
|
+
|
195
|
+
private
|
196
|
+
|
197
|
+
def underscore(hash)
|
198
|
+
hash.deep_transform_keys { |k| k.to_s.underscore.to_sym }
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
class_methods do
|
203
|
+
def gql(params = {}, ctx = {})
|
204
|
+
Proxy.new(self, params, ctx)
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
Graphiti::Resource.send(:include, GraphitiGql::ActiveResource)
|
data/lib/graphiti_gql/engine.rb
CHANGED
@@ -2,27 +2,42 @@ module GraphitiGql
|
|
2
2
|
class Engine < ::Rails::Engine
|
3
3
|
isolate_namespace GraphitiGql
|
4
4
|
|
5
|
+
# TODO improvable?
|
5
6
|
config.after_initialize do
|
6
7
|
# initializer "graphiti_gql.generate_schema" do
|
7
8
|
Dir.glob("#{Rails.root}/app/resources/**/*").each { |f| require(f) }
|
8
9
|
GraphitiGql.schema!
|
9
10
|
end
|
10
11
|
|
12
|
+
module ControllerContext
|
13
|
+
def graphql_context
|
14
|
+
ctx = { controller: self }
|
15
|
+
ctx[:current_user] = current_user if respond_to?(:current_user)
|
16
|
+
ctx
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
11
20
|
initializer "graphiti_gql.define_controller" do
|
12
21
|
require "#{Rails.root}/app/controllers/application_controller"
|
13
|
-
app_controller = ::ApplicationController
|
22
|
+
app_controller = GraphitiGql.config.application_controller || ::ApplicationController
|
23
|
+
app_controller.send(:include, ControllerContext)
|
14
24
|
|
15
25
|
# rubocop:disable Lint/ConstantDefinitionInBlock(Standard)
|
16
26
|
class GraphitiGql::ExecutionController < app_controller
|
17
|
-
# register_exception Graphiti::Errors::UnreadableAttribute, message: true
|
18
27
|
def execute
|
19
28
|
params = request.params # avoid strong_parameters
|
20
29
|
variables = params[:variables] || {}
|
21
30
|
result = GraphitiGql.run params[:query],
|
22
31
|
params[:variables],
|
23
|
-
|
32
|
+
graphql_context
|
24
33
|
render json: result
|
25
34
|
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def default_context
|
39
|
+
defined?(:current_user)
|
40
|
+
end
|
26
41
|
end
|
27
42
|
end
|
28
43
|
end
|
data/lib/graphiti_gql/errors.rb
CHANGED
@@ -17,5 +17,21 @@ module GraphitiGql
|
|
17
17
|
"You are not authorized to read field #{@field}"
|
18
18
|
end
|
19
19
|
end
|
20
|
+
|
21
|
+
class NullFilter < Base
|
22
|
+
def initialize(name)
|
23
|
+
@name = name
|
24
|
+
end
|
25
|
+
|
26
|
+
def message
|
27
|
+
"Filter '#{@name}' does not support null"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
class UnsupportedLast < Base
|
32
|
+
def message
|
33
|
+
"We do not currently support combining 'last' with 'before' or 'after'"
|
34
|
+
end
|
35
|
+
end
|
20
36
|
end
|
21
37
|
end
|
@@ -1,71 +1,251 @@
|
|
1
1
|
# These should be in Graphiti itself, but can't do it quite yet b/c GQL coupling.
|
2
2
|
# Ideally we eventually rip out the parts of Graphiti we need and roll this into
|
3
3
|
# that effort.
|
4
|
-
module
|
5
|
-
|
4
|
+
module GraphitiGql
|
5
|
+
module ResourceExtras
|
6
|
+
extend ActiveSupport::Concern
|
6
7
|
|
7
|
-
|
8
|
-
|
9
|
-
|
8
|
+
included do
|
9
|
+
class << self
|
10
|
+
attr_accessor :graphql_name
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class_methods do
|
15
|
+
def attribute(*args)
|
16
|
+
super(*args).tap do
|
17
|
+
opts = args.extract_options!
|
18
|
+
att = config[:attributes][args[0]]
|
19
|
+
att[:deprecation_reason] = opts[:deprecation_reason]
|
20
|
+
att[:null] = opts.key?(:null) ? opts[:null] : args[0] != :id
|
21
|
+
att[:name] = args.first # for easier lookup
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
Graphiti::Resource.send(:include, ResourceExtras)
|
27
|
+
|
28
|
+
module FilterExtras
|
29
|
+
def filter_param
|
30
|
+
default_filter = resource.default_filter if resource.respond_to?(:default_filter)
|
31
|
+
default_filter ||= {}
|
32
|
+
default_filter.merge(super)
|
33
|
+
end
|
34
|
+
|
35
|
+
def each_filter
|
36
|
+
super do |filter, operator, value|
|
37
|
+
unless filter.values[0][:allow_nil]
|
38
|
+
has_nil = value.nil? || value.is_a?(Array) && value.any?(&:nil?)
|
39
|
+
raise Errors::NullFilter.new(filter.keys.first) if has_nil
|
40
|
+
end
|
41
|
+
yield filter, operator, value
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Only for alias, tiny diff
|
46
|
+
def filter_via_adapter(filter, operator, value)
|
47
|
+
type_name = ::Graphiti::Types.name_for(filter.values.first[:type])
|
48
|
+
method = :"filter_#{type_name}_#{operator}"
|
49
|
+
name = filter.keys.first
|
50
|
+
name = resource.all_attributes[name][:alias] || name
|
51
|
+
|
52
|
+
if resource.adapter.respond_to?(method)
|
53
|
+
resource.adapter.send(method, @scope, name, value)
|
54
|
+
else
|
55
|
+
raise ::Graphiti::Errors::AdapterNotImplemented.new \
|
56
|
+
resource.adapter, name, method
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
Graphiti::Scoping::Filter.send(:prepend, FilterExtras)
|
61
|
+
|
62
|
+
module SortAliasExtras
|
63
|
+
def each_sort
|
64
|
+
sort_param.each do |sort_hash|
|
65
|
+
name = sort_hash.keys.first
|
66
|
+
name = resource.all_attributes[name][:alias] || name
|
67
|
+
direction = sort_hash.values.first
|
68
|
+
yield name, direction
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
Graphiti::Scoping::Sort.send(:prepend, SortAliasExtras)
|
73
|
+
|
74
|
+
module PaginateExtras
|
75
|
+
def apply
|
76
|
+
if query_hash[:reverse] && (before_cursor || after_cursor)
|
77
|
+
raise ::GraphitiGql::Errors::UnsupportedLast
|
78
|
+
end
|
79
|
+
super
|
80
|
+
end
|
81
|
+
|
82
|
+
def offset
|
83
|
+
offset = 0
|
84
|
+
|
85
|
+
if (value = page_param[:offset])
|
86
|
+
offset = value.to_i
|
87
|
+
end
|
88
|
+
|
89
|
+
if before_cursor&.key?(:offset)
|
90
|
+
if page_param.key?(:number)
|
91
|
+
raise Errors::UnsupportedBeforeCursor
|
92
|
+
end
|
93
|
+
|
94
|
+
offset = before_cursor[:offset] - (size * number) - 1
|
95
|
+
offset = 0 if offset.negative?
|
96
|
+
end
|
97
|
+
|
98
|
+
if after_cursor&.key?(:offset)
|
99
|
+
offset = after_cursor[:offset]
|
100
|
+
end
|
101
|
+
|
102
|
+
offset
|
10
103
|
end
|
104
|
+
|
105
|
+
# TODO memoize
|
106
|
+
def size
|
107
|
+
size = super
|
108
|
+
if before_cursor && after_cursor
|
109
|
+
diff = before_cursor[:offset] - after_cursor[:offset] - 1
|
110
|
+
size = [size, diff].min
|
111
|
+
elsif before_cursor
|
112
|
+
comparator = query_hash[:reverse] ? :>= : :<=
|
113
|
+
if before_cursor[:offset].send(comparator, size)
|
114
|
+
diff = before_cursor[:offset] - size
|
115
|
+
size = [size, diff].min
|
116
|
+
size = 1 if size.zero?
|
117
|
+
end
|
118
|
+
end
|
119
|
+
size
|
120
|
+
end
|
121
|
+
end
|
122
|
+
Graphiti::Scoping::Paginate.send(:prepend, PaginateExtras)
|
123
|
+
|
124
|
+
module ManyToManyExtras
|
125
|
+
extend ActiveSupport::Concern
|
126
|
+
|
127
|
+
class_methods do
|
128
|
+
attr_accessor :edge_resource
|
129
|
+
|
130
|
+
def attribute(*args, &blk)
|
131
|
+
@edge_resource = Class.new(Graphiti::Resource) do
|
132
|
+
def self.abstract_class?
|
133
|
+
true
|
134
|
+
end
|
135
|
+
end
|
136
|
+
@edge_resource.attribute(*args, &blk)
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
Graphiti::Sideload::ManyToMany.send(:include, ManyToManyExtras)
|
141
|
+
|
142
|
+
module StatsExtras
|
143
|
+
def calculate_stat(name, function)
|
144
|
+
config = @resource.all_attributes[name] || {}
|
145
|
+
name = config[:alias] || name
|
146
|
+
super(name, function)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
Graphiti::Stats::Payload.send(:prepend, StatsExtras)
|
150
|
+
|
151
|
+
Graphiti::Types[:big_integer] = Graphiti::Types[:integer].dup
|
152
|
+
Graphiti::Types[:big_integer][:graphql_type] = ::GraphQL::Types::BigInt
|
153
|
+
|
154
|
+
######## support precise_datetime ###########
|
155
|
+
#############################################
|
156
|
+
definition = Dry::Types::Nominal.new(String)
|
157
|
+
_out = definition.constructor do |input|
|
158
|
+
input.utc.round(10).iso8601(6)
|
159
|
+
end
|
160
|
+
|
161
|
+
_in = definition.constructor do |input|
|
162
|
+
Time.zone.parse(input)
|
11
163
|
end
|
12
164
|
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
165
|
+
# Register it with Graphiti
|
166
|
+
Graphiti::Types[:precise_datetime] = {
|
167
|
+
params: _in,
|
168
|
+
read: _out,
|
169
|
+
write: _in,
|
170
|
+
kind: 'scalar',
|
171
|
+
canonical_name: :precise_datetime,
|
172
|
+
description: 'Datetime with milliseconds'
|
173
|
+
}
|
174
|
+
|
175
|
+
module ActiveRecordAdapterExtras
|
176
|
+
extend ActiveSupport::Concern
|
177
|
+
|
178
|
+
included do
|
179
|
+
alias_method :filter_precise_datetime_lt, :filter_lt
|
180
|
+
alias_method :filter_precise_datetime_lte, :filter_lte
|
181
|
+
alias_method :filter_precise_datetime_gt, :filter_gt
|
182
|
+
alias_method :filter_precise_datetime_gte, :filter_gte
|
183
|
+
alias_method :filter_precise_datetime_eq, :filter_eq
|
184
|
+
alias_method :filter_precise_datetime_not_eq, :filter_not_eq
|
185
|
+
end
|
186
|
+
end
|
187
|
+
if defined?(Graphiti::Adapters::ActiveRecord)
|
188
|
+
Graphiti::Adapters::ActiveRecord.send(:include, ActiveRecordAdapterExtras)
|
189
|
+
end
|
190
|
+
|
191
|
+
Graphiti::Adapters::Abstract.class_eval do
|
192
|
+
class << self
|
193
|
+
alias :old_default_operators :default_operators
|
194
|
+
def default_operators
|
195
|
+
old_default_operators.merge(precise_datetime: numerical_operators)
|
20
196
|
end
|
21
197
|
end
|
22
198
|
end
|
23
|
-
end
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
module SortExtras
|
40
|
-
def sort_param
|
41
|
-
param = super
|
42
|
-
if query_hash[:reverse]
|
43
|
-
param = [{ id: :asc }] if param == []
|
44
|
-
param = param.map do |p|
|
45
|
-
{}.tap do |hash|
|
46
|
-
dir = p[p.keys.first]
|
47
|
-
dir = dir == :asc ? :desc : :asc
|
48
|
-
hash[p.keys.first] = dir
|
199
|
+
########## end support precise_datetime ############
|
200
|
+
|
201
|
+
# ==================================================
|
202
|
+
# Below is all to support pagination argument 'last'
|
203
|
+
# ==================================================
|
204
|
+
module SortExtras
|
205
|
+
def sort_param
|
206
|
+
param = super
|
207
|
+
if query_hash[:reverse]
|
208
|
+
param = [{ id: :asc }] if param == []
|
209
|
+
param = param.map do |p|
|
210
|
+
{}.tap do |hash|
|
211
|
+
dir = p[p.keys.first]
|
212
|
+
dir = dir == :asc ? :desc : :asc
|
213
|
+
hash[p.keys.first] = dir
|
214
|
+
end
|
49
215
|
end
|
50
216
|
end
|
217
|
+
param
|
51
218
|
end
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
end
|
70
|
-
|
71
|
-
|
219
|
+
end
|
220
|
+
Graphiti::Scoping::Sort.send(:prepend, SortExtras)
|
221
|
+
module QueryExtras
|
222
|
+
def hash
|
223
|
+
hash = super
|
224
|
+
hash[:reverse] = true if @params[:reverse]
|
225
|
+
hash
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
Graphiti::Query.send(:prepend, QueryExtras)
|
230
|
+
module ScopeExtras
|
231
|
+
def resolve(*args)
|
232
|
+
results = super
|
233
|
+
results.reverse! if @query.hash[:reverse]
|
234
|
+
results
|
235
|
+
end
|
236
|
+
end
|
237
|
+
Graphiti::Scope.send(:prepend, ScopeExtras)
|
238
|
+
|
239
|
+
module ActiveRecordManyToManyExtras
|
240
|
+
# flipping .includes to .joins
|
241
|
+
def belongs_to_many_filter(scope, value)
|
242
|
+
scope
|
243
|
+
.joins(through_relationship_name)
|
244
|
+
.where(belongs_to_many_clause(value, type))
|
245
|
+
end
|
246
|
+
end
|
247
|
+
if defined?(ActiveRecord)
|
248
|
+
::Graphiti::Adapters::ActiveRecord::ManyToManySideload
|
249
|
+
.send(:prepend, ActiveRecordManyToManyExtras)
|
250
|
+
end
|
251
|
+
end
|
@@ -2,15 +2,32 @@ module GraphitiGql
|
|
2
2
|
module Loaders
|
3
3
|
class ManyToMany < Many
|
4
4
|
def assign(ids, proxy)
|
5
|
-
records = proxy.data
|
6
5
|
thru = @sideload.foreign_key.keys.first
|
7
6
|
fk = @sideload.foreign_key[thru]
|
7
|
+
add_join_table_magic(proxy)
|
8
|
+
records = proxy.data
|
8
9
|
ids.each do |id|
|
9
|
-
|
10
|
-
|
10
|
+
corresponding = records.select do |record|
|
11
|
+
record.send(:"_edge_#{fk}") == id
|
12
|
+
end
|
11
13
|
fulfill(id, [corresponding, proxy])
|
12
14
|
end
|
13
15
|
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def add_join_table_magic(proxy)
|
20
|
+
if defined?(ActiveRecord) && proxy.resource.model.ancestors.include?(ActiveRecord::Base)
|
21
|
+
thru = @sideload.foreign_key.keys.first
|
22
|
+
thru_model = proxy.resource.model.reflect_on_association(thru).klass
|
23
|
+
names = thru_model.column_names.map do |n|
|
24
|
+
"#{thru_model.table_name}.#{n} as _edge_#{n}"
|
25
|
+
end
|
26
|
+
scope = proxy.scope.object
|
27
|
+
scope = scope.select(["#{proxy.resource.model.table_name}.*"] + names)
|
28
|
+
proxy.scope.object = scope
|
29
|
+
end
|
30
|
+
end
|
14
31
|
end
|
15
32
|
end
|
16
33
|
end
|
@@ -2,29 +2,52 @@ module GraphitiGql
|
|
2
2
|
class Schema
|
3
3
|
module Fields
|
4
4
|
class Attribute
|
5
|
-
|
6
|
-
|
5
|
+
# If sideload is present, we're applying m2m metadata to an edge
|
6
|
+
def initialize(name, config, sideload = nil)
|
7
7
|
@config = config
|
8
|
+
@name = name
|
9
|
+
@alias = config[:alias]
|
10
|
+
@sideload = sideload # is_edge: true
|
8
11
|
end
|
9
12
|
|
10
13
|
def apply(type)
|
11
14
|
is_nullable = !!@config[:null]
|
12
15
|
_config = @config
|
13
16
|
_name = @name
|
17
|
+
_alias = @alias
|
18
|
+
_sideload = @sideload
|
14
19
|
opts = @config.slice(:null, :deprecation_reason)
|
15
|
-
type.field(
|
16
|
-
type.define_method
|
20
|
+
type.field(_name, field_type, **opts)
|
21
|
+
type.define_method _name do
|
17
22
|
if (readable = _config[:readable]).is_a?(Symbol)
|
18
|
-
|
23
|
+
obj = object
|
24
|
+
obj = object.node if _sideload
|
25
|
+
resource = obj.instance_variable_get(:@__graphiti_resource)
|
19
26
|
unless resource.send(readable)
|
20
27
|
path = Graphiti.context[:object][:current_path].join(".")
|
21
28
|
raise Errors::UnauthorizedField.new(path)
|
22
29
|
end
|
23
30
|
end
|
31
|
+
|
32
|
+
edge_attrs = nil
|
33
|
+
if _sideload
|
34
|
+
edge_attrs = object.node.attributes
|
35
|
+
.select { |k, v| k.to_s.starts_with?("_edge_") }
|
36
|
+
edge_attrs.transform_keys! { |k| k.to_s.gsub("_edge_", "").to_sym }
|
37
|
+
end
|
38
|
+
|
24
39
|
value = if _config[:proc]
|
25
|
-
|
40
|
+
if _sideload
|
41
|
+
instance_exec(edge_attrs, &_config[:proc])
|
42
|
+
else
|
43
|
+
instance_eval(&_config[:proc])
|
44
|
+
end
|
26
45
|
else
|
27
|
-
|
46
|
+
if _sideload
|
47
|
+
edge_attrs[_alias || _name]
|
48
|
+
else
|
49
|
+
object.send(_alias || _name)
|
50
|
+
end
|
28
51
|
end
|
29
52
|
return if value.nil?
|
30
53
|
Graphiti::Types[_config[:type]][:read].call(value)
|
@@ -34,9 +57,12 @@ module GraphitiGql
|
|
34
57
|
private
|
35
58
|
|
36
59
|
def field_type
|
37
|
-
|
38
|
-
field_type
|
39
|
-
|
60
|
+
field_type = Graphiti::Types[@config[:type]][:graphql_type]
|
61
|
+
if !field_type
|
62
|
+
canonical_graphiti_type = Graphiti::Types.name_for(@config[:type])
|
63
|
+
field_type = GQL_TYPE_MAP[canonical_graphiti_type.to_sym]
|
64
|
+
field_type = String if @name == :id
|
65
|
+
end
|
40
66
|
field_type = [field_type] if @config[:type].to_s.starts_with?("array_of")
|
41
67
|
field_type
|
42
68
|
end
|
@@ -29,7 +29,7 @@ module GraphitiGql
|
|
29
29
|
name = Registry.instance.key_for(@resource)
|
30
30
|
stat_graphql_name = "#{name}Stats"
|
31
31
|
return Registry.instance[stat_graphql_name][:type] if Registry.instance[stat_graphql_name]
|
32
|
-
klass = Class.new(
|
32
|
+
klass = Class.new(Schema.base_object)
|
33
33
|
klass.graphql_name(stat_graphql_name)
|
34
34
|
@resource.stats.each_pair do |name, config|
|
35
35
|
calc_class = build_calc_class(stat_graphql_name, name, config.calculations.keys)
|
@@ -41,7 +41,7 @@ module GraphitiGql
|
|
41
41
|
|
42
42
|
def build_calc_class(stat_graphql_name, stat_name, calculations)
|
43
43
|
name = "#{stat_graphql_name}#{stat_name}Calculations"
|
44
|
-
klass = Class.new(
|
44
|
+
klass = Class.new(Schema.base_object)
|
45
45
|
klass.graphql_name(name)
|
46
46
|
calculations.each do |calc|
|
47
47
|
klass.field calc, Float, null: false
|
@@ -4,7 +4,11 @@ module GraphitiGql
|
|
4
4
|
class ToMany
|
5
5
|
def initialize(sideload, sideload_type)
|
6
6
|
@sideload = sideload
|
7
|
-
@sideload_type =
|
7
|
+
@sideload_type = if customized_edge?
|
8
|
+
build_customized_edge_type(sideload_type)
|
9
|
+
else
|
10
|
+
sideload_type
|
11
|
+
end
|
8
12
|
end
|
9
13
|
|
10
14
|
def apply(type)
|
@@ -31,6 +35,33 @@ module GraphitiGql
|
|
31
35
|
Loaders::Many.factory(_sideload, params).load(id)
|
32
36
|
end
|
33
37
|
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def customized_edge?
|
42
|
+
@sideload.type == :many_to_many && @sideload.class.edge_resource
|
43
|
+
end
|
44
|
+
|
45
|
+
def build_customized_edge_type(sideload_type)
|
46
|
+
# build the edge class
|
47
|
+
prior_edge_class = sideload_type.edge_type_class
|
48
|
+
edge_class = Class.new(prior_edge_class)
|
49
|
+
edge_resource = @sideload.class.edge_resource
|
50
|
+
edge_resource.attributes.each_pair do |name, config|
|
51
|
+
next if name == :id
|
52
|
+
Schema::Fields::Attribute.new(name, config, @sideload).apply(edge_class)
|
53
|
+
end
|
54
|
+
registered_parent = Schema.registry.get(@sideload.parent_resource.class)
|
55
|
+
parent_name = registered_parent[:type].graphql_name
|
56
|
+
edge_class.define_method :graphql_name do
|
57
|
+
"#{parent_name}To#{sideload_type.graphql_name}Edge"
|
58
|
+
end
|
59
|
+
|
60
|
+
# build the sideload type with new edge class applied
|
61
|
+
klass = Class.new(sideload_type)
|
62
|
+
klass.edge_type_class(edge_class)
|
63
|
+
klass
|
64
|
+
end
|
34
65
|
end
|
35
66
|
end
|
36
67
|
end
|
@@ -58,18 +58,21 @@ module GraphitiGql
|
|
58
58
|
filter_graphql_name = "#{type_name}Filter#{filter_name.to_s.camelize(:lower)}"
|
59
59
|
klass.graphql_name(filter_graphql_name)
|
60
60
|
filter_config[:operators].keys.each do |operator|
|
61
|
-
|
62
|
-
|
63
|
-
type
|
64
|
-
|
65
|
-
|
66
|
-
|
61
|
+
graphiti_type = Graphiti::Types[filter_config[:type]]
|
62
|
+
type = graphiti_type[:graphql_type]
|
63
|
+
if !type
|
64
|
+
canonical_graphiti_type = Graphiti::Types
|
65
|
+
.name_for(filter_config[:type])
|
66
|
+
type = GQL_TYPE_MAP[canonical_graphiti_type]
|
67
|
+
type = String if filter_name == :id
|
68
|
+
end
|
69
|
+
|
67
70
|
if (allowlist = filter_config[:allow])
|
68
71
|
type = define_allowlist_type(filter_graphql_name, allowlist)
|
69
72
|
end
|
70
73
|
|
71
74
|
type = [type] unless !!filter_config[:single]
|
72
|
-
klass.argument operator, type, required:
|
75
|
+
klass.argument operator, type, required: false
|
73
76
|
end
|
74
77
|
klass
|
75
78
|
end
|
@@ -3,9 +3,8 @@ module GraphitiGql
|
|
3
3
|
class Query
|
4
4
|
def initialize(resources, existing_query: nil)
|
5
5
|
@resources = resources
|
6
|
-
@query_class = Class.new(existing_query ||
|
6
|
+
@query_class = Class.new(existing_query || Schema.base_object)
|
7
7
|
@query_class.graphql_name "Query"
|
8
|
-
@query_class.field_class ::GraphQL::Schema::Field
|
9
8
|
end
|
10
9
|
|
11
10
|
def build
|
data/lib/graphiti_gql/schema.rb
CHANGED
@@ -1,21 +1,28 @@
|
|
1
1
|
module GraphitiGql
|
2
2
|
class Schema
|
3
|
+
class PreciseDatetime < GraphQL::Types::ISO8601DateTime
|
4
|
+
self.time_precision = 6
|
5
|
+
end
|
6
|
+
|
3
7
|
GQL_TYPE_MAP = {
|
4
8
|
integer_id: String,
|
5
9
|
string: String,
|
6
10
|
uuid: String,
|
7
11
|
integer: Integer,
|
12
|
+
big_integer: GraphQL::Types::BigInt,
|
8
13
|
float: Float,
|
9
14
|
boolean: GraphQL::Schema::Member::GraphQLTypeNames::Boolean,
|
10
15
|
date: GraphQL::Types::ISO8601Date,
|
11
16
|
datetime: GraphQL::Types::ISO8601DateTime,
|
17
|
+
precise_datetime: PreciseDatetime,
|
12
18
|
hash: GraphQL::Types::JSON,
|
13
19
|
array: [GraphQL::Types::JSON],
|
14
20
|
array_of_strings: [String],
|
15
21
|
array_of_integers: [Integer],
|
16
22
|
array_of_floats: [Float],
|
17
23
|
array_of_dates: [GraphQL::Types::ISO8601Date],
|
18
|
-
array_of_datetimes: [GraphQL::Types::ISO8601DateTime]
|
24
|
+
array_of_datetimes: [GraphQL::Types::ISO8601DateTime],
|
25
|
+
array_of_precise_datetimes: [PreciseDatetime]
|
19
26
|
}
|
20
27
|
|
21
28
|
class RelayConnectionExtension < GraphQL::Schema::Field::ConnectionExtension
|
@@ -25,6 +32,29 @@ module GraphitiGql
|
|
25
32
|
end
|
26
33
|
end
|
27
34
|
|
35
|
+
def self.base_object
|
36
|
+
klass = Class.new(GraphQL::Schema::Object)
|
37
|
+
# TODO make this config maybe
|
38
|
+
if defined?(ActionView)
|
39
|
+
klass.send(:include, ActionView::Helpers::TranslationHelper)
|
40
|
+
klass.class_eval do
|
41
|
+
def initialize(*)
|
42
|
+
super
|
43
|
+
@virtual_path = "."
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
klass
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.registry
|
51
|
+
Registry.instance
|
52
|
+
end
|
53
|
+
|
54
|
+
def self.print
|
55
|
+
GraphQL::Schema::Printer.print_schema(GraphitiGql.schema)
|
56
|
+
end
|
57
|
+
|
28
58
|
def initialize(resources)
|
29
59
|
@resources = resources
|
30
60
|
end
|
@@ -35,6 +65,7 @@ module GraphitiGql
|
|
35
65
|
klass.use(GraphQL::Batch)
|
36
66
|
klass.connections.add(ResponseShim, Connection)
|
37
67
|
klass.connections.add(Array, ToManyConnection)
|
68
|
+
klass.orphan_types [GraphQL::Types::JSON]
|
38
69
|
klass
|
39
70
|
end
|
40
71
|
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module GraphitiGql
|
2
|
+
module SpecHelper
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
extend Forwardable
|
7
|
+
def_delegators :result,
|
8
|
+
:page_info,
|
9
|
+
:errors,
|
10
|
+
:error_messages,
|
11
|
+
:nodes,
|
12
|
+
:stats
|
13
|
+
|
14
|
+
if defined?(RSpec)
|
15
|
+
let(:params) { {} }
|
16
|
+
let(:resource) { described_class }
|
17
|
+
let(:ctx) { {} }
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def gql_datetime(timestamp, precise = false)
|
22
|
+
if precise
|
23
|
+
timestamp.utc.round(10).iso8601(6)
|
24
|
+
else
|
25
|
+
DateTime.parse(timestamp.to_s).iso8601
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def run
|
30
|
+
lambda do
|
31
|
+
proxy = resource.gql(params, ctx)
|
32
|
+
proxy.to_h
|
33
|
+
proxy
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def run!
|
38
|
+
@result = run.call
|
39
|
+
end
|
40
|
+
|
41
|
+
def result
|
42
|
+
@result ||= run!
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
data/lib/graphiti_gql/version.rb
CHANGED
data/lib/graphiti_gql.rb
CHANGED
@@ -24,11 +24,16 @@ require "graphiti_gql/schema/fields/to_many"
|
|
24
24
|
require "graphiti_gql/schema/fields/to_one"
|
25
25
|
require "graphiti_gql/schema/fields/attribute"
|
26
26
|
require "graphiti_gql/schema/fields/stats"
|
27
|
+
require "graphiti_gql/active_resource"
|
27
28
|
require "graphiti_gql/engine" if defined?(Rails)
|
28
29
|
|
29
30
|
module GraphitiGql
|
30
31
|
class Error < StandardError; end
|
31
32
|
|
33
|
+
class Configuration
|
34
|
+
attr_accessor :application_controller
|
35
|
+
end
|
36
|
+
|
32
37
|
def self.schema!
|
33
38
|
Schema::Registry.instance.clear
|
34
39
|
resources ||= Graphiti.resources.reject(&:abstract_class?)
|
@@ -39,6 +44,14 @@ module GraphitiGql
|
|
39
44
|
@schema
|
40
45
|
end
|
41
46
|
|
47
|
+
def self.config
|
48
|
+
@config ||= Configuration.new
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.configure
|
52
|
+
yield config
|
53
|
+
end
|
54
|
+
|
42
55
|
def self.entrypoints=(val)
|
43
56
|
@entrypoints = val
|
44
57
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: graphiti_gql
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Lee Richmond
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2022-06-
|
11
|
+
date: 2022-06-26 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: graphql
|
@@ -137,6 +137,7 @@ files:
|
|
137
137
|
- config/routes.rb
|
138
138
|
- graphiti_gql.gemspec
|
139
139
|
- lib/graphiti_gql.rb
|
140
|
+
- lib/graphiti_gql/active_resource.rb
|
140
141
|
- lib/graphiti_gql/engine.rb
|
141
142
|
- lib/graphiti_gql/errors.rb
|
142
143
|
- lib/graphiti_gql/graphiti_hax.rb
|
@@ -160,6 +161,7 @@ files:
|
|
160
161
|
- lib/graphiti_gql/schema/registry.rb
|
161
162
|
- lib/graphiti_gql/schema/resource_type.rb
|
162
163
|
- lib/graphiti_gql/schema/util.rb
|
164
|
+
- lib/graphiti_gql/spec_helper.rb
|
163
165
|
- lib/graphiti_gql/version.rb
|
164
166
|
homepage: https://www.graphiti.dev
|
165
167
|
licenses:
|