graphiti_gql 0.1.0 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 85aecb8badace989fe7f60f3187c189a2d73ce0e99fd4725a8388ed01f2a9cd0
4
- data.tar.gz: 488756eb837219bde85b23a015bd6b6081fa2857c753c3e1837ab0fb7c758dc8
3
+ metadata.gz: 899580a6d3577a31dc7356d894eab26756c2c9876636db69ce5ece2d5eeee3ce
4
+ data.tar.gz: faf261a7614a0565c9dc3e45b4a9d14d75962aa6665b7302752733584abb5bdc
5
5
  SHA512:
6
- metadata.gz: ef8b222b5b932bbcb6f21cb8a9480341499086b3daffbb85657af89e208c7b639c4b75fc1ec7f432edb818673ae1e971689fc3f940d11176721a651220f60325
7
- data.tar.gz: 867db73b474375ab9189aa421c953f1ebb008f94ea906d163a4e70f6059ec7df3a699199322cd276f0227d6eebb80d15958587530a8e9e2282ad010139e8e926
6
+ metadata.gz: becebe36d5aabea0a75ba3e617f9f62d0830d3f4a35e5becdacef1fbe10f4991621f061521f10bc4c0e527bdc834d5380e9f38ff0b9cc2388a66a7cf25bb33b8
7
+ data.tar.gz: 37a9a4221261f112100e32f06832a948c30cbef5a06ffcb786f080538c1b9f3894bd3f7f8fc32394f0c2cb122e94c1b1e58cd6b8c38b6e0feab08a30d55bbfb5
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- graphiti_gql (0.1.0)
4
+ graphiti_gql (0.2.1)
5
5
  graphiti (~> 1.3.9)
6
6
  graphql (~> 2.0)
7
7
  graphql-batch (~> 0.5)
data/README.md CHANGED
@@ -1,3 +1,90 @@
1
1
  # GraphitiGql
2
2
 
3
- asdf
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)
@@ -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 # todo
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
- { current_user: current_user }
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
@@ -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 ResourceExtras
5
- extend ActiveSupport::Concern
4
+ module GraphitiGql
5
+ module ResourceExtras
6
+ extend ActiveSupport::Concern
6
7
 
7
- included do
8
- class << self
9
- attr_accessor :graphql_name
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
- class_methods do
14
- def attribute(*args)
15
- super(*args).tap do
16
- opts = args.extract_options!
17
- att = config[:attributes][args[0]]
18
- att[:deprecation_reason] = opts[:deprecation_reason]
19
- att[:null] = opts.key?(:null) ? opts[:null] : args[0] != :id
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
- Graphiti::Resource.send(:include, ResourceExtras)
26
-
27
- module FilterExtras
28
- def filter_param
29
- default_filter = resource.default_filter if resource.respond_to?(:default_filter)
30
- default_filter ||= {}
31
- default_filter.merge(super)
32
- end
33
- end
34
- Graphiti::Scoping::Filter.send(:prepend, FilterExtras)
35
-
36
- # ==================================================
37
- # Below is all to support pagination argument 'last'
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
- param
53
- end
54
- end
55
- Graphiti::Scoping::Sort.send(:prepend, SortExtras)
56
- module QueryExtras
57
- def hash
58
- hash = super
59
- hash[:reverse] = true if @params[:reverse]
60
- hash
61
- end
62
- end
63
- Graphiti::Query.send(:prepend, QueryExtras)
64
- module ScopeExtras
65
- def resolve(*args)
66
- results = super
67
- results.reverse! if @query.hash[:reverse]
68
- results
69
- end
70
- end
71
- Graphiti::Scope.send(:prepend, ScopeExtras)
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
- match = ->(thru) { thru.send(fk) == id }
10
- corresponding = records.select { |record| record.send(thru).any?(&match) }
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
- def initialize(name, config)
6
- @name = name
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(@name, field_type, **opts)
16
- type.define_method @name do
20
+ type.field(_name, field_type, **opts)
21
+ type.define_method _name do
17
22
  if (readable = _config[:readable]).is_a?(Symbol)
18
- resource = object.instance_variable_get(:@__graphiti_resource)
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
- instance_eval(&_config[:proc])
40
+ if _sideload
41
+ instance_exec(edge_attrs, &_config[:proc])
42
+ else
43
+ instance_eval(&_config[:proc])
44
+ end
26
45
  else
27
- object.send(_name)
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
- canonical_graphiti_type = Graphiti::Types.name_for(@config[:type])
38
- field_type = GQL_TYPE_MAP[canonical_graphiti_type.to_sym]
39
- field_type = String if @name == :id
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(GraphQL::Schema::Object)
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(GraphQL::Schema::Object)
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 = 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
- canonical_graphiti_type = Graphiti::Types
62
- .name_for(filter_config[:type])
63
- type = GQL_TYPE_MAP[canonical_graphiti_type]
64
- type = String if filter_name == :id
65
- required = !!filter_config[:required] && operator == "eq"
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: 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 || ::GraphQL::Schema::Object)
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
@@ -68,7 +68,7 @@ module GraphitiGql
68
68
  Registry.instance[registry_name][:type]
69
69
  end
70
70
  else
71
- klass = Class.new(GraphQL::Schema::Object)
71
+ klass = Class.new(Schema.base_object)
72
72
  end
73
73
 
74
74
  klass
@@ -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
@@ -1,3 +1,3 @@
1
1
  module GraphitiGql
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.2"
3
3
  end
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.1.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-03 00:00:00.000000000 Z
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: