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 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: