hq-graphql 2.0.10 → 2.1.3

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.
@@ -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