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