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.
- checksums.yaml +4 -4
- data/lib/hq/graphql.rb +17 -7
- data/lib/hq/graphql/active_record_extensions.rb +1 -1
- data/lib/hq/graphql/association_loader.rb +49 -0
- data/lib/hq/graphql/config.rb +2 -0
- data/lib/hq/graphql/enum.rb +2 -0
- data/lib/hq/graphql/enum/sort_by.rb +10 -0
- data/lib/hq/graphql/enum/sort_order.rb +10 -0
- data/lib/hq/graphql/field.rb +12 -10
- data/lib/hq/graphql/field_extension/association_loader_extension.rb +15 -0
- data/lib/hq/graphql/field_extension/paginated_arguments.rb +22 -0
- data/lib/hq/graphql/field_extension/paginated_loader.rb +45 -0
- data/lib/hq/graphql/input_object.rb +4 -0
- data/lib/hq/graphql/inputs.rb +3 -7
- data/lib/hq/graphql/object.rb +37 -9
- data/lib/hq/graphql/object_association.rb +67 -0
- data/lib/hq/graphql/paginated_association_loader.rb +193 -0
- data/lib/hq/graphql/resource.rb +63 -155
- data/lib/hq/graphql/resource/auto_mutation.rb +163 -0
- data/lib/hq/graphql/root_mutation.rb +1 -1
- data/lib/hq/graphql/types.rb +14 -13
- data/lib/hq/graphql/version.rb +1 -1
- metadata +11 -5
- data/lib/hq/graphql/loaders.rb +0 -3
- data/lib/hq/graphql/loaders/association.rb +0 -51
- data/lib/hq/graphql/resource/mutation.rb +0 -38
@@ -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
|
data/lib/hq/graphql/resource.rb
CHANGED
@@ -1,19 +1,26 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "hq/graphql/
|
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.
|
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
|
56
|
-
@
|
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
|
60
|
-
@
|
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
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
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
|
-
|
180
|
-
|
181
|
-
|
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
|
-
|
211
|
-
|
103
|
+
def query_class(klass)
|
104
|
+
@query_class = klass
|
212
105
|
end
|
213
106
|
|
214
|
-
def
|
215
|
-
|
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
|
-
|
112
|
+
resource = self
|
220
113
|
resolver = -> {
|
221
114
|
Class.new(::GraphQL::Schema::Resolver) do
|
222
|
-
type = is_array ? [
|
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:
|
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
|
-
|
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 |
|
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 ||
|
258
|
-
|
259
|
-
limit = [
|
260
|
-
scope = scope.limit(limit).offset(
|
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
|
-
|
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
|