hq-graphql 2.0.6 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HQ
4
+ class GraphQL::Enum::SortBy < ::HQ::GraphQL::Enum
5
+ value "CreatedAt", value: :created_at
6
+ value "UpdatedAt", value: :updated_at
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HQ
4
+ class GraphQL::Enum::SortOrder < ::HQ::GraphQL::Enum
5
+ value "ASC", value: :asc
6
+ value "DESC", value: :desc
7
+ end
8
+ end
@@ -1,16 +1,15 @@
1
- # typed: false
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module HQ
5
4
  module GraphQL
6
5
  class Field < ::GraphQL::Schema::Field
7
- attr_reader :authorize_action, :authorize, :klass
6
+ attr_reader :authorize_action, :authorize
8
7
 
9
8
  def initialize(*args, authorize_action: :read, authorize: nil, klass: nil, **options, &block)
10
9
  super(*args, **options, &block)
11
10
  @authorize_action = authorize_action
12
11
  @authorize = authorize
13
- @klass = klass
12
+ @class_name = klass
14
13
  end
15
14
 
16
15
  def authorized?(object, ctx)
@@ -21,13 +20,34 @@ module HQ
21
20
 
22
21
  def resolve_field(object, args, ctx)
23
22
  if klass.present? && !!::GraphQL::Batch::Executor.current && object.object
24
- Loaders::Association.for(klass.constantize, original_name).load(object.object).then do
25
- super
26
- end
23
+ loader =
24
+ if ::HQ::GraphQL.use_experimental_associations?
25
+ limit = args[:limit]
26
+ offset = args[:offset]
27
+ sort_by = args[:sortBy]
28
+ sort_order = args[:sortOrder]
29
+
30
+ PaginatedAssociationLoader.for(
31
+ klass,
32
+ original_name,
33
+ limit: limit,
34
+ offset: offset,
35
+ sort_by: sort_by,
36
+ sort_order: sort_order
37
+ )
38
+ else
39
+ AssociationLoader.for(klass, original_name)
40
+ end
41
+
42
+ loader.load(object.object)
27
43
  else
28
44
  super
29
45
  end
30
46
  end
47
+
48
+ def klass
49
+ @klass ||= @class_name&.constantize
50
+ end
31
51
  end
32
52
  end
33
53
  end
@@ -1,10 +1,8 @@
1
- # typed: true
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module HQ
5
4
  module GraphQL
6
5
  class InputObject < ::GraphQL::Schema::InputObject
7
- extend T::Sig
8
6
  include Scalars
9
7
  include ::HQ::GraphQL::ActiveRecordExtensions
10
8
 
@@ -30,11 +28,11 @@ module HQ
30
28
  end
31
29
 
32
30
  #### Class Methods ####
33
- sig { params(model_name: String, attributes: T::Boolean, associations: T::Boolean).void }
34
- def self.with_model(model_name, attributes: true, associations: false)
31
+ def self.with_model(model_name, attributes: true, associations: false, enums: true)
35
32
  self.model_name = model_name
36
33
  self.auto_load_attributes = attributes
37
34
  self.auto_load_associations = associations
35
+ self.auto_load_enums = enums
38
36
 
39
37
  lazy_load do
40
38
  model_columns.each do |column|
@@ -62,16 +60,19 @@ module HQ
62
60
  private
63
61
 
64
62
  def argument_from_association(association)
65
- input = ::HQ::GraphQL::Inputs[association.klass]
63
+ is_enum = is_enum?(association)
64
+ input_or_type = is_enum ? ::HQ::GraphQL::Types[association.klass] : ::HQ::GraphQL::Inputs[association.klass]
66
65
  name = association.name
67
66
 
68
67
  case association.macro
69
68
  when :has_many
70
- argument name, [input], required: false
69
+ argument name, [input_or_type], required: false
71
70
  else
72
- argument name, input, required: false
71
+ argument name, input_or_type, required: false
73
72
  end
74
73
 
74
+ return if is_enum
75
+
75
76
  if !model_klass.nested_attributes_options.key?(name.to_sym)
76
77
  model_klass.accepts_nested_attributes_for name, allow_destroy: true
77
78
  end
@@ -1,4 +1,3 @@
1
- # typed: true
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module HQ
@@ -25,8 +24,10 @@ module HQ
25
24
 
26
25
  def klass_for(klass_or_string)
27
26
  klass = klass_or_string.is_a?(String) ? klass_or_string.constantize : klass_or_string
28
- ::HQ::GraphQL.types.detect { |t| t.model_klass == klass }&.input_klass ||
29
- raise(Error, Error::MISSING_TYPE_MSG % { klass: klass.name })
27
+ resource = ::HQ::GraphQL.lookup_resource(klass)
28
+
29
+ raise(Error, Error::MISSING_TYPE_MSG % { klass: klass.name }) if !resource
30
+ resource.input_klass
30
31
  end
31
32
  end
32
33
  end
@@ -1,4 +1,3 @@
1
- # typed: true
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module HQ
@@ -1,4 +1,3 @@
1
- # typed: true
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module HQ
@@ -17,10 +16,11 @@ module HQ
17
16
  super && ::HQ::GraphQL.authorized?(authorized_action, object, context)
18
17
  end
19
18
 
20
- def self.with_model(model_name, attributes: true, associations: true, auto_nil: true)
19
+ def self.with_model(model_name, attributes: true, associations: true, auto_nil: true, enums: true)
21
20
  self.model_name = model_name
22
21
  self.auto_load_attributes = attributes
23
22
  self.auto_load_associations = associations
23
+ self.auto_load_enums = enums
24
24
 
25
25
  lazy_load do
26
26
  model_columns.each do |column|
@@ -47,11 +47,22 @@ module HQ
47
47
  end
48
48
 
49
49
  def field_from_association(association, auto_nil:)
50
- type = ::HQ::GraphQL::Types[association.klass]
50
+ # The PaginationAssociationLoader doesn't support through associations yet
51
+ return if association.through_reflection? && ::HQ::GraphQL.use_experimental_associations?
52
+
53
+ association_klass = association.klass
54
+ type = ::HQ::GraphQL::Types[association_klass]
51
55
  name = association.name
52
56
  case association.macro
53
57
  when :has_many
54
- field name, [type], null: false, klass: model_name
58
+ field name, [type], null: false, klass: model_name do
59
+ if ::HQ::GraphQL.use_experimental_associations? && (resource = ::HQ::GraphQL.lookup_resource(association_klass))
60
+ argument :offset, Integer, required: false
61
+ argument :limit, Integer, required: false
62
+ argument :sort_by, resource.sort_fields_enum, required: false
63
+ argument :sort_order, Enum::SortOrder, required: false
64
+ end
65
+ end
55
66
  else
56
67
  field name, type, null: !auto_nil || !association_required?(association), klass: model_name
57
68
  end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HQ
4
+ module GraphQL
5
+ class PaginatedAssociationLoader < ::GraphQL::Batch::Loader
6
+ def initialize(model, association_name, limit: nil, offset: nil, sort_by: nil, sort_order: nil)
7
+ @model = model
8
+ @association_name = association_name
9
+ @limit = [0, limit].max if limit
10
+ @offset = [0, offset].max if offset
11
+ @sort_by = sort_by || :updated_at
12
+ @sort_order = normalize_sort_order(sort_order)
13
+
14
+ validate!
15
+ end
16
+
17
+ def load(record)
18
+ raise TypeError, "#{@model} loader can't load association for #{record.class}" unless record.is_a?(@model)
19
+ super
20
+ end
21
+
22
+ def cache_key(record)
23
+ record.send(primary_key)
24
+ end
25
+
26
+ def perform(records)
27
+ scope =
28
+ if @limit || @offset
29
+ # If a limit or offset is added, then we need to transform the query
30
+ # into a lateral join so that we can limit on groups of data.
31
+ #
32
+ # > SELECT * FROM addresses WHERE addresses.user_id IN ($1, $2, ..., $N) ORDER BY addresses.created_at DESC;
33
+ # ...becomes
34
+ # > SELECT DISTINCT a_top.*
35
+ # > FROM addresses
36
+ # > INNER JOIN LATERAL (
37
+ # > SELECT inner.*
38
+ # > FROM addresses inner
39
+ # > WHERE inner.user_id = addresses.user_id
40
+ # > ORDER BY inner.created_at DESC
41
+ # > LIMIT 1
42
+ # > ) a_top ON TRUE
43
+ # > WHERE addresses.user_id IN ($1, $2, ..., $N)
44
+ # > ORDER BY a_top.created_at DESC
45
+ inner_table = association_class.arel_table
46
+ association_table = inner_table.alias("outer")
47
+
48
+ inside_scope = default_scope.
49
+ select(inner_table[::Arel.star]).
50
+ from(inner_table).
51
+ where(inner_table[association_key].eq(association_table[association_key])).
52
+ reorder(arel_order(inner_table)).
53
+ limit(@limit).
54
+ offset(@offset)
55
+
56
+ outside_table = ::Arel::Table.new("top")
57
+ association_class.
58
+ select(outside_table[::Arel.star]).distinct.
59
+ from(association_table).
60
+ joins("INNER JOIN LATERAL (#{inside_scope.to_sql}) #{outside_table.name} ON TRUE").
61
+ where(association_table[association_key].in(records.map { |r| join_value(r) })).
62
+ reorder(arel_order(outside_table))
63
+ else
64
+ default_scope.
65
+ reorder(arel_order(association_class.arel_table)).
66
+ where(association_key => records.map { |r| join_value(r) })
67
+ end
68
+
69
+ results = scope.to_a
70
+ records.each do |record|
71
+ fulfill(record, association_value(record, results)) unless fulfilled?(record)
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def association_key
78
+ belongs_to? ? association.association_primary_key : association.foreign_key
79
+ end
80
+
81
+ def association_value(record, results)
82
+ enumerator = has_many? ? :select : :detect
83
+ results.send(enumerator) { |r| r.send(association_key) == join_value(record) }
84
+ end
85
+
86
+ def join_key
87
+ belongs_to? ? association.foreign_key : association.association_primary_key
88
+ end
89
+
90
+ def join_value(record)
91
+ record.send(join_key)
92
+ end
93
+
94
+ def default_scope
95
+ scope = association_class
96
+ scope = association.scopes.reduce(scope, &:merge)
97
+ scope = association_class.default_scopes.reduce(scope, &:merge)
98
+ scope
99
+ end
100
+
101
+ def belongs_to?
102
+ association.macro == :belongs_to
103
+ end
104
+
105
+ def has_many?
106
+ association.macro == :has_many
107
+ end
108
+
109
+ def association
110
+ @model.reflect_on_association(@association_name)
111
+ end
112
+
113
+ def association_class
114
+ association.klass
115
+ end
116
+
117
+ def primary_key
118
+ @model.primary_key
119
+ end
120
+
121
+ def arel_order(table)
122
+ table[@sort_by].send(@sort_order)
123
+ end
124
+
125
+ def normalize_sort_order(input)
126
+ if input.to_s.casecmp("asc").zero?
127
+ :asc
128
+ else
129
+ :desc
130
+ end
131
+ end
132
+
133
+ def validate!
134
+ raise ArgumentError, "No association #{@association_name} on #{@model}" unless association
135
+ end
136
+ end
137
+ end
138
+ end
@@ -1,23 +1,41 @@
1
- # typed: false
2
1
  # frozen_string_literal: true
3
2
 
4
- require "hq/graphql/resource/mutation"
3
+ require "hq/graphql/resource/auto_mutation"
5
4
 
6
5
  module HQ
7
6
  module GraphQL
8
7
  module Resource
9
- extend T::Helpers
10
-
11
8
  def self.included(base)
12
9
  super
13
- ::HQ::GraphQL.types << base
10
+ ::HQ::GraphQL.resources << base
14
11
  base.include Scalars
15
12
  base.include ::GraphQL::Types
13
+ base.extend ClassMethods
16
14
  end
17
15
 
18
16
  module ClassMethods
17
+ include AutoMutation
18
+
19
19
  attr_writer :graphql_name, :model_name
20
20
 
21
+ def sort_fields(*fields)
22
+ self.sort_fields_enum = fields
23
+ end
24
+
25
+ def sort_fields_enum
26
+ @sort_fields_enum || ::HQ::GraphQL::Enum::SortBy
27
+ end
28
+
29
+ def sort_fields_enum=(fields)
30
+ @sort_fields_enum ||= Class.new(::HQ::GraphQL::Enum::SortBy).tap do |c|
31
+ c.graphql_name "#{graphql_name}Sort"
32
+ end
33
+
34
+ Array(fields).each do |field|
35
+ @sort_fields_enum.value field.to_s.classify, value: field
36
+ end
37
+ end
38
+
21
39
  def scope(context)
22
40
  scope = model_klass
23
41
  scope = ::HQ::GraphQL.default_scope(scope, context)
@@ -39,7 +57,7 @@ module HQ
39
57
  end
40
58
 
41
59
  def model_name
42
- @model_name || name.demodulize
60
+ @model_name || ::HQ::GraphQL.extract_class(self)
43
61
  end
44
62
 
45
63
  def model_klass
@@ -73,144 +91,10 @@ module HQ
73
91
  end
74
92
 
75
93
  def mutations(create: true, copy: true, update: true, destroy: true)
76
- scoped_graphql_name = graphql_name
77
- scoped_model_name = model_name
78
- scoped_self = self
79
-
80
- if create
81
- create_mutation = ::HQ::GraphQL::Resource::Mutation.build(model_name, action: :create, graphql_name: "#{scoped_graphql_name}Create") do
82
- define_method(:resolve) do |**args|
83
- resource = scoped_self.new_record(context)
84
- resource.assign_attributes(args[:attributes].format_nested_attributes)
85
- if resource.save
86
- {
87
- resource: resource,
88
- errors: {},
89
- }
90
- else
91
- {
92
- resource: nil,
93
- errors: errors_from_resource(resource)
94
- }
95
- end
96
- end
97
-
98
- lazy_load do
99
- argument :attributes, ::HQ::GraphQL::Inputs[scoped_model_name], required: true
100
- end
101
- end
102
-
103
- mutation_klasses["create_#{scoped_graphql_name.underscore}"] = create_mutation
104
- end
105
-
106
- if copy
107
- copy_mutation = ::HQ::GraphQL::Resource::Mutation.build(
108
- model_name,
109
- action: :copy,
110
- graphql_name: "#{scoped_graphql_name}Copy",
111
- require_primary_key: true,
112
- nil_klass: true
113
- ) do
114
- define_method(:resolve) do |**args|
115
- resource = scoped_self.find_record(args, context)
116
-
117
- if resource
118
- copy = resource.copy
119
- if copy.save
120
- {
121
- resource: copy,
122
- errors: {},
123
- }
124
- else
125
- {
126
- resource: copy,
127
- errors: errors_from_resource(copy)
128
- }
129
- end
130
- else
131
- {
132
- resource: nil,
133
- errors: { resource: "Unable to find #{scoped_graphql_name}" }
134
- }
135
- end
136
- end
137
- end
138
-
139
- mutation_klasses["copy_#{scoped_graphql_name.underscore}"] = copy_mutation
140
- end
141
-
142
- if update
143
- update_mutation = ::HQ::GraphQL::Resource::Mutation.build(
144
- model_name,
145
- action: :update,
146
- graphql_name: "#{scoped_graphql_name}Update",
147
- require_primary_key: true
148
- ) do
149
- define_method(:resolve) do |**args|
150
- resource = scoped_self.find_record(args, context)
151
-
152
- if resource
153
- resource.assign_attributes(args[:attributes].format_nested_attributes)
154
- if resource.save
155
- {
156
- resource: resource,
157
- errors: {},
158
- }
159
- else
160
- {
161
- resource: nil,
162
- errors: errors_from_resource(resource)
163
- }
164
- end
165
- else
166
- {
167
- resource: nil,
168
- errors: { resource: "Unable to find #{scoped_graphql_name}" }
169
- }
170
- end
171
- end
172
-
173
- lazy_load do
174
- argument :attributes, ::HQ::GraphQL::Inputs[scoped_model_name], required: true
175
- end
176
- end
177
-
178
- mutation_klasses["update_#{scoped_graphql_name.underscore}"] = update_mutation
179
- end
180
-
181
- if destroy
182
- destroy_mutation = ::HQ::GraphQL::Resource::Mutation.build(
183
- model_name,
184
- action: :destroy,
185
- graphql_name: "#{scoped_graphql_name}Destroy",
186
- require_primary_key: true
187
- ) do
188
- define_method(:resolve) do |**attrs|
189
- resource = scoped_self.find_record(attrs, context)
190
-
191
- if resource
192
- if resource.destroy
193
- {
194
- resource: resource,
195
- errors: {},
196
- }
197
- else
198
- {
199
- resource: nil,
200
- errors: errors_from_resource(resource)
201
- }
202
- end
203
- else
204
- {
205
- resource: nil,
206
- errors: { resource: "Unable to find #{scoped_graphql_name}" }
207
- }
208
- end
209
- end
210
- end
211
-
212
- mutation_klasses["destroy_#{scoped_graphql_name.underscore}"] = destroy_mutation
213
- end
94
+ mutation_klasses["create_#{graphql_name.underscore}"] = build_create if create
95
+ mutation_klasses["copy_#{graphql_name.underscore}"] = build_copy if copy
96
+ mutation_klasses["update_#{graphql_name.underscore}"] = build_update if update
97
+ mutation_klasses["destroy_#{graphql_name.underscore}"] = build_destroy if destroy
214
98
  end
215
99
 
216
100
  def query(**options, &block)
@@ -218,10 +102,10 @@ module HQ
218
102
  end
219
103
 
220
104
  def def_root(field_name, is_array: false, null: true, &block)
221
- graphql = self
105
+ resource = self
222
106
  resolver = -> {
223
107
  Class.new(::GraphQL::Schema::Resolver) do
224
- type = is_array ? [graphql.query_klass] : graphql.query_klass
108
+ type = is_array ? [resource.query_klass] : resource.query_klass
225
109
  type type, null: null
226
110
  class_eval(&block) if block
227
111
  end
@@ -231,7 +115,7 @@ module HQ
231
115
  }
232
116
  end
233
117
 
234
- def root_query(find_one: true, find_all: true, pagination: false, per_page_max: 250)
118
+ def root_query(find_one: true, find_all: true, pagination: true, limit_max: 250)
235
119
  field_name = graphql_name.underscore
236
120
  scoped_self = self
237
121
 
@@ -250,18 +134,27 @@ module HQ
250
134
 
251
135
  if find_all
252
136
  def_root field_name.pluralize, is_array: true, null: false do
253
- argument :page, Integer, required: false
254
- argument :per_page, Integer, required: false
137
+ if pagination
138
+ argument :offset, Integer, required: false
139
+ argument :limit, Integer, required: false
140
+ end
141
+ argument :sort_by, scoped_self.sort_fields_enum, required: false
142
+ argument :sort_order, Enum::SortOrder, required: false
255
143
 
256
- define_method(:resolve) do |page: nil, per_page: nil, **_attrs|
144
+ define_method(:resolve) do |limit: nil, offset: nil, sort_by: nil, sort_order: nil, **_attrs|
257
145
  scope = scoped_self.scope(context).all
258
146
 
259
- if pagination || page || per_page
260
- page ||= 0
261
- limit = [per_page_max, *per_page].min
262
- scope = scope.limit(limit).offset(page * limit)
147
+ if pagination || page || limit
148
+ offset = [0, *offset].max
149
+ limit = [[limit_max, *limit].min, 0].max
150
+ scope = scope.limit(limit).offset(offset)
263
151
  end
264
152
 
153
+ sort_by ||= :updated_at
154
+ sort_order ||= :desc
155
+ # There should be no risk for SQL injection since an enum is being used for both sort_by and sort_order
156
+ scope = scope.reorder(sort_by => sort_order)
157
+
265
158
  scope
266
159
  end
267
160
  end
@@ -294,8 +187,6 @@ module HQ
294
187
  end
295
188
  end
296
189
  end
297
-
298
- mixes_in_class_methods(ClassMethods)
299
190
  end
300
191
  end
301
192
  end