hq-graphql 2.0.7 → 2.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +28 -0
  3. data/lib/hq-graphql.rb +0 -2
  4. data/lib/hq/graphql.rb +28 -21
  5. data/lib/hq/graphql/active_record_extensions.rb +23 -27
  6. data/lib/hq/graphql/association_loader.rb +49 -0
  7. data/lib/hq/graphql/comparator.rb +1 -1
  8. data/lib/hq/graphql/config.rb +17 -11
  9. data/lib/hq/graphql/engine.rb +0 -1
  10. data/lib/hq/graphql/enum.rb +77 -0
  11. data/lib/hq/graphql/enum/sort_by.rb +10 -0
  12. data/lib/hq/graphql/enum/sort_order.rb +10 -0
  13. data/lib/hq/graphql/field.rb +12 -11
  14. data/lib/hq/graphql/field_extension/association_loader_extension.rb +15 -0
  15. data/lib/hq/graphql/field_extension/paginated_arguments.rb +22 -0
  16. data/lib/hq/graphql/field_extension/paginated_loader.rb +45 -0
  17. data/lib/hq/graphql/input_object.rb +12 -7
  18. data/lib/hq/graphql/inputs.rb +4 -3
  19. data/lib/hq/graphql/mutation.rb +0 -1
  20. data/lib/hq/graphql/object.rb +42 -11
  21. data/lib/hq/graphql/object_association.rb +50 -0
  22. data/lib/hq/graphql/paginated_association_loader.rb +158 -0
  23. data/lib/hq/graphql/resource.rb +47 -156
  24. data/lib/hq/graphql/resource/auto_mutation.rb +163 -0
  25. data/lib/hq/graphql/root_mutation.rb +1 -2
  26. data/lib/hq/graphql/root_query.rb +0 -1
  27. data/lib/hq/graphql/scalars.rb +0 -1
  28. data/lib/hq/graphql/schema.rb +1 -1
  29. data/lib/hq/graphql/types.rb +22 -8
  30. data/lib/hq/graphql/types/object.rb +7 -11
  31. data/lib/hq/graphql/types/uuid.rb +7 -14
  32. data/lib/hq/graphql/version.rb +1 -2
  33. metadata +12 -39
  34. data/lib/hq/graphql/loaders.rb +0 -4
  35. data/lib/hq/graphql/loaders/association.rb +0 -52
  36. data/lib/hq/graphql/resource/mutation.rb +0 -39
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hq/graphql/enum"
4
+
5
+ module HQ
6
+ class GraphQL::Enum::SortBy < ::HQ::GraphQL::Enum
7
+ value "CreatedAt", value: :created_at
8
+ value "UpdatedAt", value: :updated_at
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hq/graphql/enum"
4
+
5
+ module HQ
6
+ class GraphQL::Enum::SortOrder < ::HQ::GraphQL::Enum
7
+ value "ASC", value: :asc
8
+ value "DESC", value: :desc
9
+ end
10
+ end
@@ -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, :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
+ @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 resolve_field(object, args, ctx)
23
- 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
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
- sig { params(model_name: String, attributes: T::Boolean, associations: T::Boolean).void }
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
- input = ::HQ::GraphQL::Inputs[association.klass]
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, [input], required: false
73
+ argument name, [input_or_type], required: false
71
74
  else
72
- argument name, input, required: false
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
@@ -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,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 ::HQ::GraphQL::ActiveRecordExtensions
15
+ include ActiveRecordExtensions
16
+ extend ObjectAssociation
9
17
 
10
- field_class ::HQ::GraphQL::Field
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
- type = ::HQ::GraphQL::Types[association.klass]
51
- name = association.name
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 ::HQ::GraphQL::Types::Error
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, ::HQ::GraphQL::Types.type_from_column(column), null: !auto_nil || column.null
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