hq-graphql 2.0.6 → 2.1.0

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