hq-graphql 2.0.7 → 2.1.1
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/README.md +28 -0
- data/lib/hq-graphql.rb +0 -2
- data/lib/hq/graphql.rb +28 -21
- data/lib/hq/graphql/active_record_extensions.rb +23 -27
- data/lib/hq/graphql/association_loader.rb +49 -0
- data/lib/hq/graphql/comparator.rb +1 -1
- data/lib/hq/graphql/config.rb +17 -11
- data/lib/hq/graphql/engine.rb +0 -1
- data/lib/hq/graphql/enum.rb +77 -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 -11
- 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 +12 -7
- data/lib/hq/graphql/inputs.rb +4 -3
- data/lib/hq/graphql/mutation.rb +0 -1
- data/lib/hq/graphql/object.rb +42 -11
- data/lib/hq/graphql/object_association.rb +50 -0
- data/lib/hq/graphql/paginated_association_loader.rb +158 -0
- data/lib/hq/graphql/resource.rb +47 -156
- data/lib/hq/graphql/resource/auto_mutation.rb +163 -0
- data/lib/hq/graphql/root_mutation.rb +1 -2
- data/lib/hq/graphql/root_query.rb +0 -1
- data/lib/hq/graphql/scalars.rb +0 -1
- data/lib/hq/graphql/schema.rb +1 -1
- data/lib/hq/graphql/types.rb +22 -8
- data/lib/hq/graphql/types/object.rb +7 -11
- data/lib/hq/graphql/types/uuid.rb +7 -14
- data/lib/hq/graphql/version.rb +1 -2
- metadata +12 -39
- data/lib/hq/graphql/loaders.rb +0 -4
- data/lib/hq/graphql/loaders/association.rb +0 -52
- data/lib/hq/graphql/resource/mutation.rb +0 -39
data/lib/hq/graphql/field.rb
CHANGED
@@ -1,16 +1,23 @@
|
|
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
|
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
|
-
@
|
12
|
+
@klass_or_string = klass
|
13
|
+
end
|
14
|
+
|
15
|
+
def scope(&block)
|
16
|
+
if block
|
17
|
+
@scope = block
|
18
|
+
else
|
19
|
+
@scope
|
20
|
+
end
|
14
21
|
end
|
15
22
|
|
16
23
|
def authorized?(object, ctx)
|
@@ -19,14 +26,8 @@ module HQ
|
|
19
26
|
::HQ::GraphQL.authorize_field(authorize_action, self, object, ctx)
|
20
27
|
end
|
21
28
|
|
22
|
-
def
|
23
|
-
|
24
|
-
Loaders::Association.for(klass.constantize, original_name).load(object.object).then do
|
25
|
-
super
|
26
|
-
end
|
27
|
-
else
|
28
|
-
super
|
29
|
-
end
|
29
|
+
def klass
|
30
|
+
@klass ||= @klass_or_string.is_a?(String) ? @klass_or_string.constantize : @klass_or_string
|
30
31
|
end
|
31
32
|
end
|
32
33
|
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "hq/graphql/association_loader"
|
4
|
+
|
5
|
+
module HQ
|
6
|
+
module GraphQL
|
7
|
+
module FieldExtension
|
8
|
+
class AssociationLoaderExtension < ::GraphQL::Schema::FieldExtension
|
9
|
+
def resolve(object:, **_kwargs)
|
10
|
+
AssociationLoader.for(options[:klass], field.original_name).load(object.object)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "hq/graphql/enum/sort_by"
|
4
|
+
require "hq/graphql/enum/sort_order"
|
5
|
+
|
6
|
+
module HQ
|
7
|
+
module GraphQL
|
8
|
+
module FieldExtension
|
9
|
+
class PaginatedArguments < ::GraphQL::Schema::FieldExtension
|
10
|
+
def apply
|
11
|
+
field.argument :offset, Integer, required: false
|
12
|
+
field.argument :limit, Integer, required: false
|
13
|
+
field.argument :sort_order, Enum::SortOrder, required: false
|
14
|
+
|
15
|
+
resource = ::HQ::GraphQL.lookup_resource(options[:klass])
|
16
|
+
enum = resource ? resource.sort_fields_enum : ::HQ::GraphQL::Enum::SortBy
|
17
|
+
field.argument :sort_by, enum, required: false
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "hq/graphql/paginated_association_loader"
|
4
|
+
|
5
|
+
module HQ
|
6
|
+
module GraphQL
|
7
|
+
module FieldExtension
|
8
|
+
class PaginatedLoader < ::GraphQL::Schema::FieldExtension
|
9
|
+
def resolve(object:, arguments:, **_options)
|
10
|
+
limit = arguments[:limit]
|
11
|
+
offset = arguments[:offset]
|
12
|
+
sort_by = arguments[:sort_by]
|
13
|
+
sort_order = arguments[:sort_order]
|
14
|
+
scope = field.scope.call(**arguments.except(:limit, :offset, :sort_by, :sort_order)) if field.scope
|
15
|
+
loader = PaginatedAssociationLoader.for(
|
16
|
+
klass,
|
17
|
+
association,
|
18
|
+
internal_association: internal_association,
|
19
|
+
scope: scope,
|
20
|
+
limit: limit,
|
21
|
+
offset: offset,
|
22
|
+
sort_by: sort_by,
|
23
|
+
sort_order: sort_order
|
24
|
+
)
|
25
|
+
|
26
|
+
loader.load(object.object)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def association
|
32
|
+
options[:association]
|
33
|
+
end
|
34
|
+
|
35
|
+
def internal_association
|
36
|
+
options[:internal_association]
|
37
|
+
end
|
38
|
+
|
39
|
+
def klass
|
40
|
+
options[:klass]
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -1,10 +1,12 @@
|
|
1
|
-
# typed: true
|
2
1
|
# frozen_string_literal: true
|
3
2
|
|
3
|
+
require "hq/graphql/active_record_extensions"
|
4
|
+
require "hq/graphql/inputs"
|
5
|
+
require "hq/graphql/types"
|
6
|
+
|
4
7
|
module HQ
|
5
8
|
module GraphQL
|
6
9
|
class InputObject < ::GraphQL::Schema::InputObject
|
7
|
-
extend T::Sig
|
8
10
|
include Scalars
|
9
11
|
include ::HQ::GraphQL::ActiveRecordExtensions
|
10
12
|
|
@@ -30,11 +32,11 @@ module HQ
|
|
30
32
|
end
|
31
33
|
|
32
34
|
#### Class Methods ####
|
33
|
-
|
34
|
-
def self.with_model(model_name, attributes: true, associations: false)
|
35
|
+
def self.with_model(model_name, attributes: true, associations: false, enums: true)
|
35
36
|
self.model_name = model_name
|
36
37
|
self.auto_load_attributes = attributes
|
37
38
|
self.auto_load_associations = associations
|
39
|
+
self.auto_load_enums = enums
|
38
40
|
|
39
41
|
lazy_load do
|
40
42
|
model_columns.each do |column|
|
@@ -62,16 +64,19 @@ module HQ
|
|
62
64
|
private
|
63
65
|
|
64
66
|
def argument_from_association(association)
|
65
|
-
|
67
|
+
is_enum = is_enum?(association)
|
68
|
+
input_or_type = is_enum ? ::HQ::GraphQL::Types[association.klass] : ::HQ::GraphQL::Inputs[association.klass]
|
66
69
|
name = association.name
|
67
70
|
|
68
71
|
case association.macro
|
69
72
|
when :has_many
|
70
|
-
argument name, [
|
73
|
+
argument name, [input_or_type], required: false
|
71
74
|
else
|
72
|
-
argument name,
|
75
|
+
argument name, input_or_type, required: false
|
73
76
|
end
|
74
77
|
|
78
|
+
return if is_enum
|
79
|
+
|
75
80
|
if !model_klass.nested_attributes_options.key?(name.to_sym)
|
76
81
|
model_klass.accepts_nested_attributes_for name, allow_destroy: true
|
77
82
|
end
|
data/lib/hq/graphql/inputs.rb
CHANGED
@@ -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.
|
29
|
-
|
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
|
data/lib/hq/graphql/mutation.rb
CHANGED
data/lib/hq/graphql/object.rb
CHANGED
@@ -1,13 +1,21 @@
|
|
1
|
-
# typed: true
|
2
1
|
# frozen_string_literal: true
|
3
2
|
|
3
|
+
require "hq/graphql/active_record_extensions"
|
4
|
+
require "hq/graphql/field"
|
5
|
+
require "hq/graphql/field_extension/association_loader_extension"
|
6
|
+
require "hq/graphql/field_extension/paginated_arguments"
|
7
|
+
require "hq/graphql/field_extension/paginated_loader"
|
8
|
+
require "hq/graphql/object_association"
|
9
|
+
require "hq/graphql/types"
|
10
|
+
|
4
11
|
module HQ
|
5
12
|
module GraphQL
|
6
13
|
class Object < ::GraphQL::Schema::Object
|
7
14
|
include Scalars
|
8
|
-
include
|
15
|
+
include ActiveRecordExtensions
|
16
|
+
extend ObjectAssociation
|
9
17
|
|
10
|
-
field_class
|
18
|
+
field_class Field
|
11
19
|
|
12
20
|
def self.authorize_action(action)
|
13
21
|
self.authorized_action = action
|
@@ -17,10 +25,11 @@ module HQ
|
|
17
25
|
super && ::HQ::GraphQL.authorized?(authorized_action, object, context)
|
18
26
|
end
|
19
27
|
|
20
|
-
def self.with_model(model_name, attributes: true, associations: true, auto_nil: true)
|
28
|
+
def self.with_model(model_name, attributes: true, associations: true, auto_nil: true, enums: true)
|
21
29
|
self.model_name = model_name
|
22
30
|
self.auto_load_attributes = attributes
|
23
31
|
self.auto_load_associations = associations
|
32
|
+
self.auto_load_enums = enums
|
24
33
|
|
25
34
|
lazy_load do
|
26
35
|
model_columns.each do |column|
|
@@ -28,8 +37,15 @@ module HQ
|
|
28
37
|
end
|
29
38
|
|
30
39
|
model_associations.each do |association|
|
40
|
+
next if resource_reflections[association.name.to_s]
|
31
41
|
field_from_association(association, auto_nil: auto_nil)
|
32
42
|
end
|
43
|
+
|
44
|
+
resource_reflections.values.each do |resource_reflection|
|
45
|
+
reflection = resource_reflection.reflection(model_klass)
|
46
|
+
next unless reflection
|
47
|
+
field_from_association(reflection, auto_nil: auto_nil, internal_association: true, &resource_reflection.block)
|
48
|
+
end
|
33
49
|
end
|
34
50
|
end
|
35
51
|
|
@@ -46,21 +62,36 @@ module HQ
|
|
46
62
|
@authorized_action ||= :read
|
47
63
|
end
|
48
64
|
|
49
|
-
def field_from_association(association, auto_nil:)
|
50
|
-
|
51
|
-
|
65
|
+
def field_from_association(association, auto_nil:, internal_association: false, &block)
|
66
|
+
# The PaginationAssociationLoader doesn't support through associations yet
|
67
|
+
return if association.through_reflection? && ::HQ::GraphQL.use_experimental_associations?
|
68
|
+
|
69
|
+
association_klass = association.klass
|
70
|
+
name = association.name
|
71
|
+
klass = model_klass
|
72
|
+
type = Types[association_klass]
|
52
73
|
case association.macro
|
53
74
|
when :has_many
|
54
|
-
field name, [type], null: false, klass: model_name
|
75
|
+
field name, [type], null: false, klass: model_name do
|
76
|
+
if ::HQ::GraphQL.use_experimental_associations?
|
77
|
+
extension FieldExtension::PaginatedArguments, klass: association_klass
|
78
|
+
extension FieldExtension::PaginatedLoader, klass: klass, association: name, internal_association: internal_association
|
79
|
+
else
|
80
|
+
extension FieldExtension::AssociationLoaderExtension, klass: klass
|
81
|
+
end
|
82
|
+
instance_eval(&block) if block
|
83
|
+
end
|
55
84
|
else
|
56
|
-
field name, type, null: !auto_nil || !association_required?(association), klass: model_name
|
85
|
+
field name, type, null: !auto_nil || !association_required?(association), klass: model_name do
|
86
|
+
extension FieldExtension::AssociationLoaderExtension, klass: klass
|
87
|
+
end
|
57
88
|
end
|
58
|
-
rescue
|
89
|
+
rescue Types::Error
|
59
90
|
nil
|
60
91
|
end
|
61
92
|
|
62
93
|
def field_from_column(column, auto_nil:)
|
63
|
-
field column.name,
|
94
|
+
field column.name, Types.type_from_column(column), null: !auto_nil || column.null
|
64
95
|
end
|
65
96
|
|
66
97
|
def association_required?(association)
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HQ
|
4
|
+
module GraphQL
|
5
|
+
module ObjectAssociation
|
6
|
+
class ResourceReflection
|
7
|
+
attr_reader :name, :scope, :options, :macro, :block
|
8
|
+
|
9
|
+
def initialize(name, scope, options, macro, block)
|
10
|
+
@name = name
|
11
|
+
@scope = scope
|
12
|
+
@options = options
|
13
|
+
@macro = macro
|
14
|
+
@block = block
|
15
|
+
end
|
16
|
+
|
17
|
+
def reflection(model_klass)
|
18
|
+
if macro == :has_many
|
19
|
+
::ActiveRecord::Associations::Builder::HasMany.create_reflection(model_klass, name, scope, options)
|
20
|
+
elsif macro == :belongs_to
|
21
|
+
::ActiveRecord::Associations::Builder::BelongsTo.create_reflection(model_klass, name, scope, options)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def reflect_on_association(association)
|
27
|
+
resource_reflections[association.to_s]&.reflection(model_klass)
|
28
|
+
end
|
29
|
+
|
30
|
+
def belongs_to(name, scope = nil, **options, &block)
|
31
|
+
add_reflection(name, scope, options, :belongs_to, block)
|
32
|
+
end
|
33
|
+
|
34
|
+
def has_many(name, scope = nil, through: nil, **options, &block)
|
35
|
+
raise TypeError, "has_many through is unsupported" if through
|
36
|
+
add_reflection(name, scope, options, :has_many, block)
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def resource_reflections
|
42
|
+
@resource_reflections ||= {}
|
43
|
+
end
|
44
|
+
|
45
|
+
def add_reflection(name, scope, options, macro, block)
|
46
|
+
resource_reflections[name.to_s] = ResourceReflection.new(name, scope, options, macro, block)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,158 @@
|
|
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
|
+
scope =
|
43
|
+
if @limit || @offset
|
44
|
+
# If a limit or offset is added, then we need to transform the query
|
45
|
+
# into a lateral join so that we can limit on groups of data.
|
46
|
+
#
|
47
|
+
# > SELECT * FROM addresses WHERE addresses.user_id IN ($1, $2, ..., $N) ORDER BY addresses.created_at DESC;
|
48
|
+
# ...becomes
|
49
|
+
# > SELECT DISTINCT a_top.*
|
50
|
+
# > FROM addresses
|
51
|
+
# > INNER JOIN LATERAL (
|
52
|
+
# > SELECT inner.*
|
53
|
+
# > FROM addresses inner
|
54
|
+
# > WHERE inner.user_id = addresses.user_id
|
55
|
+
# > ORDER BY inner.created_at DESC
|
56
|
+
# > LIMIT 1
|
57
|
+
# > ) a_top ON TRUE
|
58
|
+
# > WHERE addresses.user_id IN ($1, $2, ..., $N)
|
59
|
+
# > ORDER BY a_top.created_at DESC
|
60
|
+
inner_table = association_class.arel_table
|
61
|
+
association_table = inner_table.alias("outer")
|
62
|
+
|
63
|
+
inside_scope = default_scope.
|
64
|
+
select(inner_table[::Arel.star]).
|
65
|
+
from(inner_table).
|
66
|
+
where(inner_table[association_key].eq(association_table[association_key])).
|
67
|
+
reorder(arel_order(inner_table)).
|
68
|
+
limit(@limit).
|
69
|
+
offset(@offset)
|
70
|
+
|
71
|
+
outside_table = ::Arel::Table.new("top")
|
72
|
+
association_class.
|
73
|
+
select(outside_table[::Arel.star]).distinct.
|
74
|
+
from(association_table).
|
75
|
+
joins("INNER JOIN LATERAL (#{inside_scope.to_sql}) #{outside_table.name} ON TRUE").
|
76
|
+
where(association_table[association_key].in(records.map { |r| join_value(r) })).
|
77
|
+
reorder(arel_order(outside_table))
|
78
|
+
else
|
79
|
+
default_scope.
|
80
|
+
reorder(arel_order(association_class.arel_table)).
|
81
|
+
where(association_key => records.map { |r| join_value(r) })
|
82
|
+
end
|
83
|
+
|
84
|
+
results = scope.to_a
|
85
|
+
records.each do |record|
|
86
|
+
fulfill(record, association_value(record, results)) unless fulfilled?(record)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
def association_key
|
93
|
+
belongs_to? ? association.association_primary_key : association.foreign_key
|
94
|
+
end
|
95
|
+
|
96
|
+
def association_value(record, results)
|
97
|
+
enumerator = has_many? ? :select : :detect
|
98
|
+
results.send(enumerator) { |r| r.send(association_key) == join_value(record) }
|
99
|
+
end
|
100
|
+
|
101
|
+
def join_key
|
102
|
+
belongs_to? ? association.foreign_key : association.association_primary_key
|
103
|
+
end
|
104
|
+
|
105
|
+
def join_value(record)
|
106
|
+
record.send(join_key)
|
107
|
+
end
|
108
|
+
|
109
|
+
def default_scope
|
110
|
+
scope = association_class
|
111
|
+
scope = association.scopes.reduce(scope, &:merge)
|
112
|
+
scope = association_class.default_scopes.reduce(scope, &:merge)
|
113
|
+
scope = scope.merge(@scope) if @scope
|
114
|
+
scope
|
115
|
+
end
|
116
|
+
|
117
|
+
def belongs_to?
|
118
|
+
association.macro == :belongs_to
|
119
|
+
end
|
120
|
+
|
121
|
+
def has_many?
|
122
|
+
association.macro == :has_many
|
123
|
+
end
|
124
|
+
|
125
|
+
def association
|
126
|
+
if @internal_association
|
127
|
+
Types[@model].reflect_on_association(@association_name)
|
128
|
+
else
|
129
|
+
@model.reflect_on_association(@association_name)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def association_class
|
134
|
+
association.klass
|
135
|
+
end
|
136
|
+
|
137
|
+
def primary_key
|
138
|
+
@model.primary_key
|
139
|
+
end
|
140
|
+
|
141
|
+
def arel_order(table)
|
142
|
+
table[@sort_by].send(@sort_order)
|
143
|
+
end
|
144
|
+
|
145
|
+
def normalize_sort_order(input)
|
146
|
+
if input.to_s.casecmp("asc").zero?
|
147
|
+
:asc
|
148
|
+
else
|
149
|
+
:desc
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def validate!
|
154
|
+
raise ArgumentError, "No association #{@association_name} on #{@model}" unless association
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|