hq-graphql 2.0.10 → 2.1.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hq/graphql/types"
4
+
5
+ module HQ
6
+ module GraphQL
7
+ class PaginatedAssociationLoader < ::GraphQL::Batch::Loader
8
+ def self.for(*args, scope: nil, **kwargs)
9
+ if scope
10
+ raise TypeError, "scope must be an ActiveRecord::Relation" unless scope.is_a?(::ActiveRecord::Relation)
11
+ executor = ::GraphQL::Batch::Executor.current
12
+ loader_key = loader_key_for(*args, **kwargs, scope: scope.to_sql)
13
+ executor.loader(loader_key) { new(*args, **kwargs, scope: scope) }
14
+ else
15
+ super
16
+ end
17
+ end
18
+
19
+ def initialize(model, association_name, internal_association: false, limit: nil, offset: nil, scope: nil, sort_by: nil, sort_order: nil)
20
+ @model = model
21
+ @association_name = association_name
22
+ @internal_association = internal_association
23
+ @limit = [0, limit].max if limit
24
+ @offset = [0, offset].max if offset
25
+ @scope = scope
26
+ @sort_by = sort_by || :updated_at
27
+ @sort_order = normalize_sort_order(sort_order)
28
+
29
+ validate!
30
+ end
31
+
32
+ def load(record)
33
+ raise TypeError, "#{@model} loader can't load association for #{record.class}" unless record.is_a?(@model)
34
+ super
35
+ end
36
+
37
+ def cache_key(record)
38
+ record.send(primary_key)
39
+ end
40
+
41
+ def perform(records)
42
+ values = records.map { |r| source_value(r) }
43
+ scope =
44
+ if @limit || @offset
45
+ # If a limit or offset is added, then we need to transform the query
46
+ # into a lateral join so that we can limit on groups of data.
47
+ #
48
+ # > SELECT * FROM addresses WHERE addresses.user_id IN ($1, $2, ..., $N) ORDER BY addresses.created_at DESC;
49
+ # ...becomes
50
+ # > SELECT DISTINCT a_top.*
51
+ # > FROM addresses
52
+ # > INNER JOIN LATERAL (
53
+ # > SELECT inner.*
54
+ # > FROM addresses inner
55
+ # > WHERE inner.user_id = addresses.user_id
56
+ # > ORDER BY inner.created_at DESC
57
+ # > LIMIT 1
58
+ # > ) a_top ON TRUE
59
+ # > WHERE addresses.user_id IN ($1, $2, ..., $N)
60
+ # > ORDER BY a_top.created_at DESC
61
+ inner_table = association_class.arel_table
62
+ lateral_join_table = through_reflection? ? through_association.klass.arel_table : inner_table
63
+ from_table = lateral_join_table.alias("outer")
64
+
65
+ inside_scope = default_scope.
66
+ select(inner_table[::Arel.star]).
67
+ from(inner_table).
68
+ where(lateral_join_table[target_join_key].eq(from_table[target_join_key])).
69
+ reorder(arel_order(inner_table)).
70
+ limit(@limit).
71
+ offset(@offset)
72
+
73
+ if through_reflection?
74
+ # expose the through_reflection key
75
+ inside_scope = inside_scope.select(lateral_join_table[target_join_key])
76
+ end
77
+
78
+ lateral_table = ::Arel::Table.new("top")
79
+ association_class.
80
+ select(lateral_table[::Arel.star]).distinct.
81
+ from(from_table).
82
+ where(from_table[target_join_key].in(values)).
83
+ joins("INNER JOIN LATERAL (#{inside_scope.to_sql}) #{lateral_table.name} ON TRUE").
84
+ reorder(arel_order(lateral_table))
85
+ else
86
+ scope = default_scope.reorder(arel_order(association_class.arel_table))
87
+
88
+ if through_reflection?
89
+ scope.where(through_association.name => { target_join_key => values }).
90
+ # expose the through_reflection key
91
+ select(association_class.arel_table[::Arel.star], through_association.klass.arel_table[target_join_key])
92
+ else
93
+ scope.where(target_join_key => values)
94
+ end
95
+ end
96
+
97
+ results = scope.to_a
98
+ records.each do |record|
99
+ fulfill(record, target_value(record, results)) unless fulfilled?(record)
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ def source_join_key
106
+ belongs_to? ? association.foreign_key : association.association_primary_key
107
+ end
108
+
109
+ def source_value(record)
110
+ record.send(source_join_key)
111
+ end
112
+
113
+ def target_join_key
114
+ if through_reflection?
115
+ through_association.foreign_key
116
+ elsif belongs_to?
117
+ association.association_primary_key
118
+ else
119
+ association.foreign_key
120
+ end
121
+ end
122
+
123
+ def target_value(record, results)
124
+ enumerator = has_many? ? :select : :detect
125
+ results.send(enumerator) { |r| r.send(target_join_key) == source_value(record) }
126
+ end
127
+
128
+ def default_scope
129
+ scope = association_class
130
+ scope = association.scopes.reduce(scope, &:merge)
131
+ scope = association_class.default_scopes.reduce(scope, &:merge)
132
+ scope = scope.merge(@scope) if @scope
133
+
134
+ if through_reflection?
135
+ source = association_class.arel_table
136
+ target = through_association.klass.arel_table
137
+ join = source.join(target).on(target[association.foreign_key].eq(source[source_join_key]))
138
+ scope = scope.joins(join.join_sources)
139
+ end
140
+
141
+ scope
142
+ end
143
+
144
+ def belongs_to?
145
+ association.macro == :belongs_to
146
+ end
147
+
148
+ def has_many?
149
+ association.macro == :has_many
150
+ end
151
+
152
+ def through_association
153
+ association.through_reflection
154
+ end
155
+
156
+ def through_reflection?
157
+ association.through_reflection?
158
+ end
159
+
160
+ def association
161
+ if @internal_association
162
+ Types[@model].reflect_on_association(@association_name)
163
+ else
164
+ @model.reflect_on_association(@association_name)
165
+ end
166
+ end
167
+
168
+ def association_class
169
+ association.klass
170
+ end
171
+
172
+ def primary_key
173
+ @model.primary_key
174
+ end
175
+
176
+ def arel_order(table)
177
+ table[@sort_by].send(@sort_order)
178
+ end
179
+
180
+ def normalize_sort_order(input)
181
+ if input.to_s.casecmp("asc").zero?
182
+ :asc
183
+ else
184
+ :desc
185
+ end
186
+ end
187
+
188
+ def validate!
189
+ raise ArgumentError, "No association #{@association_name} on #{@model}" unless association
190
+ end
191
+ end
192
+ end
193
+ end
@@ -1,19 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "hq/graphql/resource/mutation"
3
+ require "hq/graphql/enum/sort_by"
4
+ require "hq/graphql/field_extension/paginated_arguments"
5
+ require "hq/graphql/input_object"
6
+ require "hq/graphql/object"
7
+ require "hq/graphql/resource/auto_mutation"
8
+ require "hq/graphql/scalars"
4
9
 
5
10
  module HQ
6
11
  module GraphQL
7
12
  module Resource
8
13
  def self.included(base)
9
14
  super
10
- ::HQ::GraphQL.types << base
15
+ ::HQ::GraphQL.resources << base
11
16
  base.include Scalars
12
17
  base.include ::GraphQL::Types
13
18
  base.extend ClassMethods
14
19
  end
15
20
 
16
21
  module ClassMethods
22
+ include AutoMutation
23
+
17
24
  attr_writer :graphql_name, :model_name
18
25
 
19
26
  def scope(context)
@@ -52,12 +59,24 @@ module HQ
52
59
  @input_klass ||= build_input_object
53
60
  end
54
61
 
55
- def nil_query_klass
56
- @nil_query_klass ||= build_graphql_object(name: "#{graphql_name}Copy", auto_nil: false)
62
+ def nil_query_object
63
+ @nil_query_object ||= build_graphql_object(name: "#{graphql_name}Copy", auto_nil: false)
64
+ end
65
+
66
+ def query_object
67
+ @query_object ||= begin
68
+ if @query_object_options
69
+ options, block = @query_object_options
70
+ @query_object_options = nil
71
+ build_graphql_object(**options, &block)
72
+ else
73
+ build_graphql_object
74
+ end
75
+ end
57
76
  end
58
77
 
59
- def query_klass
60
- @query_klass ||= build_graphql_object
78
+ def sort_fields_enum
79
+ @sort_fields_enum || ::HQ::GraphQL::Enum::SortBy
61
80
  end
62
81
 
63
82
  protected
@@ -71,155 +90,29 @@ module HQ
71
90
  end
72
91
 
73
92
  def mutations(create: true, copy: true, update: true, destroy: true)
74
- scoped_graphql_name = graphql_name
75
- scoped_model_name = model_name
76
- scoped_self = self
77
-
78
- if create
79
- create_mutation = ::HQ::GraphQL::Resource::Mutation.build(model_name, action: :create, graphql_name: "#{scoped_graphql_name}Create") do
80
- define_method(:resolve) do |**args|
81
- resource = scoped_self.new_record(context)
82
- resource.assign_attributes(args[:attributes].format_nested_attributes)
83
- if resource.save
84
- {
85
- resource: resource,
86
- errors: {},
87
- }
88
- else
89
- {
90
- resource: nil,
91
- errors: errors_from_resource(resource)
92
- }
93
- end
94
- end
95
-
96
- lazy_load do
97
- argument :attributes, ::HQ::GraphQL::Inputs[scoped_model_name], required: true
98
- end
99
- end
100
-
101
- mutation_klasses["create_#{scoped_graphql_name.underscore}"] = create_mutation
102
- end
103
-
104
- if copy
105
- copy_mutation = ::HQ::GraphQL::Resource::Mutation.build(
106
- model_name,
107
- action: :copy,
108
- graphql_name: "#{scoped_graphql_name}Copy",
109
- require_primary_key: true,
110
- nil_klass: true
111
- ) do
112
- define_method(:resolve) do |**args|
113
- resource = scoped_self.find_record(args, context)
114
-
115
- if resource
116
- copy = resource.copy
117
- if copy.save
118
- {
119
- resource: copy,
120
- errors: {},
121
- }
122
- else
123
- {
124
- resource: copy,
125
- errors: errors_from_resource(copy)
126
- }
127
- end
128
- else
129
- {
130
- resource: nil,
131
- errors: { resource: "Unable to find #{scoped_graphql_name}" }
132
- }
133
- end
134
- end
135
- end
136
-
137
- mutation_klasses["copy_#{scoped_graphql_name.underscore}"] = copy_mutation
138
- end
139
-
140
- if update
141
- update_mutation = ::HQ::GraphQL::Resource::Mutation.build(
142
- model_name,
143
- action: :update,
144
- graphql_name: "#{scoped_graphql_name}Update",
145
- require_primary_key: true
146
- ) do
147
- define_method(:resolve) do |**args|
148
- resource = scoped_self.find_record(args, context)
149
-
150
- if resource
151
- resource.assign_attributes(args[:attributes].format_nested_attributes)
152
- if resource.save
153
- {
154
- resource: resource,
155
- errors: {},
156
- }
157
- else
158
- {
159
- resource: nil,
160
- errors: errors_from_resource(resource)
161
- }
162
- end
163
- else
164
- {
165
- resource: nil,
166
- errors: { resource: "Unable to find #{scoped_graphql_name}" }
167
- }
168
- end
169
- end
170
-
171
- lazy_load do
172
- argument :attributes, ::HQ::GraphQL::Inputs[scoped_model_name], required: true
173
- end
174
- end
175
-
176
- mutation_klasses["update_#{scoped_graphql_name.underscore}"] = update_mutation
177
- end
93
+ mutation_klasses["create_#{graphql_name.underscore}"] = build_create if create
94
+ mutation_klasses["copy_#{graphql_name.underscore}"] = build_copy if copy
95
+ mutation_klasses["update_#{graphql_name.underscore}"] = build_update if update
96
+ mutation_klasses["destroy_#{graphql_name.underscore}"] = build_destroy if destroy
97
+ end
178
98
 
179
- if destroy
180
- destroy_mutation = ::HQ::GraphQL::Resource::Mutation.build(
181
- model_name,
182
- action: :destroy,
183
- graphql_name: "#{scoped_graphql_name}Destroy",
184
- require_primary_key: true
185
- ) do
186
- define_method(:resolve) do |**attrs|
187
- resource = scoped_self.find_record(attrs, context)
188
-
189
- if resource
190
- if resource.destroy
191
- {
192
- resource: resource,
193
- errors: {},
194
- }
195
- else
196
- {
197
- resource: nil,
198
- errors: errors_from_resource(resource)
199
- }
200
- end
201
- else
202
- {
203
- resource: nil,
204
- errors: { resource: "Unable to find #{scoped_graphql_name}" }
205
- }
206
- end
207
- end
208
- end
99
+ def query(**options, &block)
100
+ @query_object_options = [options, block]
101
+ end
209
102
 
210
- mutation_klasses["destroy_#{scoped_graphql_name.underscore}"] = destroy_mutation
211
- end
103
+ def query_class(klass)
104
+ @query_class = klass
212
105
  end
213
106
 
214
- def query(**options, &block)
215
- @query_klass = build_graphql_object(**options, &block)
107
+ def sort_fields(*fields)
108
+ self.sort_fields_enum = fields
216
109
  end
217
110
 
218
111
  def def_root(field_name, is_array: false, null: true, &block)
219
- graphql = self
112
+ resource = self
220
113
  resolver = -> {
221
114
  Class.new(::GraphQL::Schema::Resolver) do
222
- type = is_array ? [graphql.query_klass] : graphql.query_klass
115
+ type = is_array ? [resource.query_object] : resource.query_object
223
116
  type type, null: null
224
117
  class_eval(&block) if block
225
118
  end
@@ -229,7 +122,7 @@ module HQ
229
122
  }
230
123
  end
231
124
 
232
- def root_query(find_one: true, find_all: true, pagination: false, per_page_max: 250)
125
+ def root_query(find_one: true, find_all: true, pagination: true, limit_max: 250)
233
126
  field_name = graphql_name.underscore
234
127
  scoped_self = self
235
128
 
@@ -248,18 +141,22 @@ module HQ
248
141
 
249
142
  if find_all
250
143
  def_root field_name.pluralize, is_array: true, null: false do
251
- argument :page, Integer, required: false
252
- argument :per_page, Integer, required: false
144
+ extension FieldExtension::PaginatedArguments, klass: scoped_self.model_klass if pagination
253
145
 
254
- define_method(:resolve) do |page: nil, per_page: nil, **_attrs|
146
+ define_method(:resolve) do |limit: nil, offset: nil, sort_by: nil, sort_order: nil, **_attrs|
255
147
  scope = scoped_self.scope(context).all
256
148
 
257
- if pagination || page || per_page
258
- page ||= 0
259
- limit = [per_page_max, *per_page].min
260
- scope = scope.limit(limit).offset(page * limit)
149
+ if pagination || page || limit
150
+ offset = [0, *offset].max
151
+ limit = [[limit_max, *limit].min, 0].max
152
+ scope = scope.limit(limit).offset(offset)
261
153
  end
262
154
 
155
+ sort_by ||= :updated_at
156
+ sort_order ||= :desc
157
+ # There should be no risk for SQL injection since an enum is being used for both sort_by and sort_order
158
+ scope = scope.reorder(sort_by => sort_order)
159
+
263
160
  scope
264
161
  end
265
162
  end
@@ -271,7 +168,8 @@ module HQ
271
168
  def build_graphql_object(name: graphql_name, **options, &block)
272
169
  scoped_graphql_name = name
273
170
  scoped_model_name = model_name
274
- Class.new(::HQ::GraphQL::Object) do
171
+ object_class = @query_class || ::HQ::GraphQL.default_object_class || ::HQ::GraphQL::Object
172
+ Class.new(object_class) do
275
173
  graphql_name scoped_graphql_name
276
174
 
277
175
  with_model scoped_model_name, **options
@@ -291,6 +189,16 @@ module HQ
291
189
  class_eval(&block) if block
292
190
  end
293
191
  end
192
+
193
+ def sort_fields_enum=(fields)
194
+ @sort_fields_enum ||= Class.new(::HQ::GraphQL::Enum::SortBy).tap do |c|
195
+ c.graphql_name "#{graphql_name}Sort"
196
+ end
197
+
198
+ Array(fields).each do |field|
199
+ @sort_fields_enum.value field.to_s.classify, value: field
200
+ end
201
+ end
294
202
  end
295
203
  end
296
204
  end