hq-graphql 2.0.9 → 2.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bf94ca5592ce063239e928dbfb76d339c44762c3c89efe709cbbee157228a1d5
4
- data.tar.gz: 8a5b48518fd85b87917d81dc10b75be7031552b010216ff956e3d7e9fefada0c
3
+ metadata.gz: 8851537c95e46beea01cd929ec57a5c29a0649eeb059e7f35b5bef1fe71f6da3
4
+ data.tar.gz: 31008afafa861554a0327285681191a4107c83faf7d3c790f402fe88056d2ca6
5
5
  SHA512:
6
- metadata.gz: 2a742fb51ad7ac2c4fa2f44c02b144a97e7e87fb9bb93c58035a23420c89550bfc895f376d372b71ee1445fdf8db91bf38e16eae8d7cea3c91dfeda9e5a2f69d
7
- data.tar.gz: d17914db4d42d8b480917d3b720220cbdcad9d5d5a88f47d1e4b7c0b1f1d8db69138560e5838be40047f42a003bb82d56c07ede142da433b541048fba1d5dc56
6
+ metadata.gz: f63e936bed969d901e14265ac4fe284d4ef18838cf27c6f4c5a2575d6b0c07e3e4d533e0ea2e8a402777351b98b3d2c0fd07b021781bfb9e618518a0b60d7988
7
+ data.tar.gz: 76b08ade067128ab0a4d96449751481413c8a6e714ae9d69c73817a5692468dee0f279d3c13456f504f145ee6f713418714b258b23540193eed48b4d0278e3e8
@@ -28,14 +28,24 @@ module HQ
28
28
  config.default_scope.call(scope, context)
29
29
  end
30
30
 
31
- def self.resource_lookup(klass)
32
- config.resource_lookup.call(klass)
31
+ def self.extract_class(klass)
32
+ config.extract_class.call(klass)
33
+ end
34
+
35
+ def self.lookup_resource(klass)
36
+ [klass, klass.base_class, klass.superclass].lazy.map do |k|
37
+ config.resource_lookup.call(k) || resources.detect { |r| r.model_klass == k }
38
+ end.reject(&:nil?).first
39
+ end
40
+
41
+ def self.use_experimental_associations?
42
+ !!config.use_experimental_associations
33
43
  end
34
44
 
35
45
  def self.reset!
36
46
  @root_queries = nil
37
47
  @enums = nil
38
- @types = nil
48
+ @resources = nil
39
49
  ::HQ::GraphQL::Inputs.reset!
40
50
  ::HQ::GraphQL::Types.reset!
41
51
  end
@@ -48,21 +58,21 @@ module HQ
48
58
  @enums ||= Set.new
49
59
  end
50
60
 
51
- def self.types
52
- @types ||= Set.new
61
+ def self.resources
62
+ @resources ||= Set.new
53
63
  end
54
64
  end
55
65
  end
56
66
 
57
- require "hq/graphql/active_record_extensions"
67
+ require "hq/graphql/association_loader"
58
68
  require "hq/graphql/scalars"
59
69
  require "hq/graphql/comparator"
60
70
  require "hq/graphql/enum"
61
71
  require "hq/graphql/inputs"
62
72
  require "hq/graphql/input_object"
63
- require "hq/graphql/loaders"
64
73
  require "hq/graphql/mutation"
65
74
  require "hq/graphql/object"
75
+ require "hq/graphql/paginated_association_loader"
66
76
  require "hq/graphql/resource"
67
77
  require "hq/graphql/root_mutation"
68
78
  require "hq/graphql/root_query"
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HQ
4
+ module GraphQL
5
+ class AssociationLoader < ::GraphQL::Batch::Loader
6
+ def initialize(model, association_name)
7
+ @model = model
8
+ @association_name = association_name
9
+ validate
10
+ end
11
+
12
+ def load(record)
13
+ raise TypeError, "#{@model} loader can't load association for #{record.class}" unless record.is_a?(@model)
14
+ return Promise.resolve(read_association(record)) if association_loaded?(record)
15
+ super
16
+ end
17
+
18
+ # We want to load the associations on all records, even if they have the same id
19
+ def cache_key(record)
20
+ record.object_id
21
+ end
22
+
23
+ def perform(records)
24
+ preload_association(records)
25
+ records.each { |record| fulfill(record, read_association(record)) }
26
+ end
27
+
28
+ private
29
+
30
+ def validate
31
+ unless @model.reflect_on_association(@association_name)
32
+ raise ArgumentError, "No association #{@association_name} on #{@model}"
33
+ end
34
+ end
35
+
36
+ def preload_association(records)
37
+ ::ActiveRecord::Associations::Preloader.new.preload(records, @association_name)
38
+ end
39
+
40
+ def read_association(record)
41
+ record.public_send(@association_name)
42
+ end
43
+
44
+ def association_loaded?(record)
45
+ record.association(@association_name).loaded?
46
+ end
47
+ end
48
+ end
49
+ end
@@ -2,10 +2,19 @@
2
2
 
3
3
  module HQ
4
4
  module GraphQL
5
- class Config < Struct.new(:authorize, :authorize_field, :default_scope, :resource_lookup, keyword_init: true)
5
+ class Config < Struct.new(
6
+ :authorize,
7
+ :authorize_field,
8
+ :default_scope,
9
+ :extract_class,
10
+ :resource_lookup,
11
+ :use_experimental_associations,
12
+ keyword_init: true
13
+ )
6
14
  def initialize(
7
15
  default_scope: ->(scope, _context) { scope },
8
- resource_lookup: ->(klass) { "::Resources::#{klass}".safe_constantize },
16
+ extract_class: ->(klass) { klass.to_s.gsub(/^Resources|Resource$/, "") },
17
+ resource_lookup: ->(klass) { "::Resources::#{klass}Resource".safe_constantize || "::Resources::#{klass}".safe_constantize },
9
18
  **options
10
19
  )
11
20
  super
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "hq/graphql/types"
4
+
3
5
  module HQ::GraphQL
4
6
  class Enum < ::GraphQL::Schema::Enum
5
7
  ## Auto generate enums from the database using ActiveRecord
@@ -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
@@ -3,13 +3,21 @@
3
3
  module HQ
4
4
  module GraphQL
5
5
  class Field < ::GraphQL::Schema::Field
6
- attr_reader :authorize_action, :authorize, :klass
6
+ attr_reader :authorize_action, :authorize
7
7
 
8
8
  def initialize(*args, authorize_action: :read, authorize: nil, klass: nil, **options, &block)
9
9
  super(*args, **options, &block)
10
10
  @authorize_action = authorize_action
11
11
  @authorize = authorize
12
- @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
13
21
  end
14
22
 
15
23
  def authorized?(object, ctx)
@@ -18,14 +26,8 @@ module HQ
18
26
  ::HQ::GraphQL.authorize_field(authorize_action, self, object, ctx)
19
27
  end
20
28
 
21
- def resolve_field(object, args, ctx)
22
- if klass.present? && !!::GraphQL::Batch::Executor.current && object.object
23
- Loaders::Association.for(klass.constantize, original_name).load(object.object).then do
24
- super
25
- end
26
- else
27
- super
28
- end
29
+ def klass
30
+ @klass ||= @klass_or_string.is_a?(String) ? @klass_or_string.constantize : @klass_or_string
29
31
  end
30
32
  end
31
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,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "hq/graphql/active_record_extensions"
4
+ require "hq/graphql/inputs"
5
+ require "hq/graphql/types"
6
+
3
7
  module HQ
4
8
  module GraphQL
5
9
  class InputObject < ::GraphQL::Schema::InputObject
@@ -24,14 +24,10 @@ module HQ
24
24
 
25
25
  def klass_for(klass_or_string)
26
26
  klass = klass_or_string.is_a?(String) ? klass_or_string.constantize : klass_or_string
27
- type = find_type(klass)
27
+ resource = ::HQ::GraphQL.lookup_resource(klass)
28
28
 
29
- raise(Error, Error::MISSING_TYPE_MSG % { klass: klass.name }) if !type
30
- type.input_klass
31
- end
32
-
33
- def find_type(klass)
34
- ::HQ::GraphQL.resource_lookup(klass) || ::HQ::GraphQL.types.detect { |t| t.model_klass == klass }
29
+ raise(Error, Error::MISSING_TYPE_MSG % { klass: klass.name }) if !resource
30
+ resource.input_klass
35
31
  end
36
32
  end
37
33
  end
@@ -1,12 +1,21 @@
1
1
  # frozen_string_literal: true
2
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
+
3
11
  module HQ
4
12
  module GraphQL
5
13
  class Object < ::GraphQL::Schema::Object
6
14
  include Scalars
7
- include ::HQ::GraphQL::ActiveRecordExtensions
15
+ include ActiveRecordExtensions
16
+ extend ObjectAssociation
8
17
 
9
- field_class ::HQ::GraphQL::Field
18
+ field_class Field
10
19
 
11
20
  def self.authorize_action(action)
12
21
  self.authorized_action = action
@@ -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
@@ -1,19 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "hq/graphql/resource/mutation"
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.types << base
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)
@@ -37,7 +44,7 @@ module HQ
37
44
  end
38
45
 
39
46
  def model_name
40
- @model_name || name.demodulize
47
+ @model_name || ::HQ::GraphQL.extract_class(self)
41
48
  end
42
49
 
43
50
  def model_klass
@@ -60,6 +67,10 @@ module HQ
60
67
  @query_klass ||= build_graphql_object
61
68
  end
62
69
 
70
+ def sort_fields_enum
71
+ @sort_fields_enum || ::HQ::GraphQL::Enum::SortBy
72
+ end
73
+
63
74
  protected
64
75
 
65
76
  def default_scope(&block)
@@ -71,155 +82,25 @@ module HQ
71
82
  end
72
83
 
73
84
  def mutations(create: true, copy: true, update: true, destroy: true)
74
- scoped_graphql_name = graphql_name
75
- scoped_model_name = model_name
76
- scoped_self = self
77
-
78
- if create
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
178
-
179
- if destroy
180
- destroy_mutation = ::HQ::GraphQL::Resource::Mutation.build(
181
- model_name,
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
209
-
210
- mutation_klasses["destroy_#{scoped_graphql_name.underscore}"] = destroy_mutation
211
- end
85
+ mutation_klasses["create_#{graphql_name.underscore}"] = build_create if create
86
+ mutation_klasses["copy_#{graphql_name.underscore}"] = build_copy if copy
87
+ mutation_klasses["update_#{graphql_name.underscore}"] = build_update if update
88
+ mutation_klasses["destroy_#{graphql_name.underscore}"] = build_destroy if destroy
212
89
  end
213
90
 
214
91
  def query(**options, &block)
215
92
  @query_klass = build_graphql_object(**options, &block)
216
93
  end
217
94
 
95
+ def sort_fields(*fields)
96
+ self.sort_fields_enum = fields
97
+ end
98
+
218
99
  def def_root(field_name, is_array: false, null: true, &block)
219
- graphql = self
100
+ resource = self
220
101
  resolver = -> {
221
102
  Class.new(::GraphQL::Schema::Resolver) do
222
- type = is_array ? [graphql.query_klass] : graphql.query_klass
103
+ type = is_array ? [resource.query_klass] : resource.query_klass
223
104
  type type, null: null
224
105
  class_eval(&block) if block
225
106
  end
@@ -229,7 +110,7 @@ module HQ
229
110
  }
230
111
  end
231
112
 
232
- def root_query(find_one: true, find_all: true, pagination: false, per_page_max: 250)
113
+ def root_query(find_one: true, find_all: true, pagination: true, limit_max: 250)
233
114
  field_name = graphql_name.underscore
234
115
  scoped_self = self
235
116
 
@@ -248,18 +129,22 @@ module HQ
248
129
 
249
130
  if find_all
250
131
  def_root field_name.pluralize, is_array: true, null: false do
251
- argument :page, Integer, required: false
252
- argument :per_page, Integer, required: false
132
+ extension FieldExtension::PaginatedArguments, klass: scoped_self.model_klass if pagination
253
133
 
254
- define_method(:resolve) do |page: nil, per_page: nil, **_attrs|
134
+ define_method(:resolve) do |limit: nil, offset: nil, sort_by: nil, sort_order: nil, **_attrs|
255
135
  scope = scoped_self.scope(context).all
256
136
 
257
- if pagination || page || per_page
258
- page ||= 0
259
- limit = [per_page_max, *per_page].min
260
- scope = scope.limit(limit).offset(page * limit)
137
+ if pagination || page || limit
138
+ offset = [0, *offset].max
139
+ limit = [[limit_max, *limit].min, 0].max
140
+ scope = scope.limit(limit).offset(offset)
261
141
  end
262
142
 
143
+ sort_by ||= :updated_at
144
+ sort_order ||= :desc
145
+ # There should be no risk for SQL injection since an enum is being used for both sort_by and sort_order
146
+ scope = scope.reorder(sort_by => sort_order)
147
+
263
148
  scope
264
149
  end
265
150
  end
@@ -291,6 +176,16 @@ module HQ
291
176
  class_eval(&block) if block
292
177
  end
293
178
  end
179
+
180
+ def sort_fields_enum=(fields)
181
+ @sort_fields_enum ||= Class.new(::HQ::GraphQL::Enum::SortBy).tap do |c|
182
+ c.graphql_name "#{graphql_name}Sort"
183
+ end
184
+
185
+ Array(fields).each do |field|
186
+ @sort_fields_enum.value field.to_s.classify, value: field
187
+ end
188
+ end
294
189
  end
295
190
  end
296
191
  end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hq/graphql/inputs"
4
+ require "hq/graphql/mutation"
5
+ require "hq/graphql/types"
6
+
7
+ module HQ
8
+ module GraphQL
9
+ module Resource
10
+ module AutoMutation
11
+ def build_create
12
+ scoped_self = self
13
+
14
+ build_mutation(action: :create) do
15
+ define_method(:resolve) do |**args|
16
+ resource = scoped_self.new_record(context)
17
+ resource.assign_attributes(args[:attributes].format_nested_attributes)
18
+ if resource.save
19
+ {
20
+ resource: resource,
21
+ errors: {},
22
+ }
23
+ else
24
+ {
25
+ resource: nil,
26
+ errors: errors_from_resource(resource)
27
+ }
28
+ end
29
+ end
30
+
31
+ lazy_load do
32
+ argument :attributes, ::HQ::GraphQL::Inputs[scoped_self.model_name], required: true
33
+ end
34
+ end
35
+ end
36
+
37
+ def build_update
38
+ scoped_self = self
39
+
40
+ build_mutation(action: :update, require_primary_key: true) do
41
+ define_method(:resolve) do |**args|
42
+ resource = scoped_self.find_record(args, context)
43
+
44
+ if resource
45
+ resource.assign_attributes(args[:attributes].format_nested_attributes)
46
+ if resource.save
47
+ {
48
+ resource: resource,
49
+ errors: {},
50
+ }
51
+ else
52
+ {
53
+ resource: nil,
54
+ errors: errors_from_resource(resource)
55
+ }
56
+ end
57
+ else
58
+ {
59
+ resource: nil,
60
+ errors: { resource: "Unable to find #{self.class.graphql_name}" }
61
+ }
62
+ end
63
+ end
64
+
65
+ lazy_load do
66
+ argument :attributes, ::HQ::GraphQL::Inputs[scoped_self.model_name], required: true
67
+ end
68
+ end
69
+ end
70
+
71
+ def build_copy
72
+ scoped_self = self
73
+
74
+ build_mutation(action: :copy, require_primary_key: true, nil_klass: true) do
75
+ define_method(:resolve) do |**args|
76
+ resource = scoped_self.find_record(args, context)
77
+
78
+ if resource
79
+ copy = resource.copy
80
+ if copy.save
81
+ {
82
+ resource: copy,
83
+ errors: {},
84
+ }
85
+ else
86
+ {
87
+ resource: copy,
88
+ errors: errors_from_resource(copy)
89
+ }
90
+ end
91
+ else
92
+ {
93
+ resource: nil,
94
+ errors: { resource: "Unable to find #{self.class.graphql_name}" }
95
+ }
96
+ end
97
+ end
98
+ end
99
+ end
100
+
101
+ def build_destroy
102
+ scoped_self = self
103
+
104
+ build_mutation(action: :destroy, require_primary_key: true) do
105
+ define_method(:resolve) do |**attrs|
106
+ resource = scoped_self.find_record(attrs, context)
107
+
108
+ if resource
109
+ if resource.destroy
110
+ {
111
+ resource: resource,
112
+ errors: {},
113
+ }
114
+ else
115
+ {
116
+ resource: nil,
117
+ errors: errors_from_resource(resource)
118
+ }
119
+ end
120
+ else
121
+ {
122
+ resource: nil,
123
+ errors: { resource: "Unable to find #{self.class.graphql_name}" }
124
+ }
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ def build_mutation(action:, require_primary_key: false, nil_klass: false, &block)
131
+ gql_name = "#{graphql_name}#{action.to_s.titleize}"
132
+ scoped_model_name = model_name
133
+ Class.new(::HQ::GraphQL::Mutation) do
134
+ graphql_name gql_name
135
+
136
+ define_method(:ready?) do |*args|
137
+ super(*args) && ::HQ::GraphQL.authorized?(action, scoped_model_name, context)
138
+ end
139
+
140
+ lazy_load do
141
+ field :errors, ::HQ::GraphQL::Types::Object, null: false
142
+ field :resource, ::HQ::GraphQL::Types[scoped_model_name, nil_klass], null: true
143
+ end
144
+
145
+ instance_eval(&block)
146
+
147
+ if require_primary_key
148
+ lazy_load do
149
+ klass = scoped_model_name.constantize
150
+ primary_key = klass.primary_key
151
+ argument primary_key, ::GraphQL::Types::ID, required: true
152
+ end
153
+ end
154
+
155
+ def errors_from_resource(resource)
156
+ resource.errors.to_h.deep_transform_keys { |k| k.to_s.camelize(:lower) }
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -7,7 +7,7 @@ module HQ
7
7
  super
8
8
  base.class_eval do
9
9
  lazy_load do
10
- ::HQ::GraphQL.types.each do |type|
10
+ ::HQ::GraphQL.resources.each do |type|
11
11
  type.mutation_klasses.each do |mutation_name, klass|
12
12
  field mutation_name, mutation: klass
13
13
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "hq/graphql/types/object"
4
+ require "hq/graphql/types/uuid"
5
+
3
6
  module HQ
4
7
  module GraphQL
5
8
  module Types
@@ -39,6 +42,10 @@ module HQ
39
42
  ::GraphQL::Types::Float
40
43
  when :boolean
41
44
  ::GraphQL::Types::Boolean
45
+ when :date
46
+ ::GraphQL::Types::ISO8601Date
47
+ when :datetime
48
+ ::GraphQL::Types::ISO8601DateTime
42
49
  else
43
50
  ::GraphQL::Types::String
44
51
  end
@@ -64,16 +71,10 @@ module HQ
64
71
 
65
72
  def find_klass(klass_or_string, method)
66
73
  klass = klass_or_string.is_a?(String) ? klass_or_string.constantize : klass_or_string
67
- type = find_type(klass)
68
- type ||= find_type(klass.base_class)
69
- type ||= find_type(klass.superclass)
70
-
71
- raise(Error, Error::MISSING_TYPE_MSG % { klass: klass.name }) if !type
72
- type.send(method)
73
- end
74
+ resource = ::HQ::GraphQL.lookup_resource(klass)
74
75
 
75
- def find_type(klass)
76
- ::HQ::GraphQL.resource_lookup(klass) || ::HQ::GraphQL.types.detect { |t| t.model_klass == klass }
76
+ raise(Error, Error::MISSING_TYPE_MSG % { klass: klass.name }) if !resource
77
+ resource.send(method)
77
78
  end
78
79
  end
79
80
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module HQ
4
4
  module GraphQL
5
- VERSION = "2.0.9"
5
+ VERSION = "2.1.2"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hq-graphql
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.9
4
+ version: 2.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Danny Jones
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-05-13 00:00:00.000000000 Z
11
+ date: 2020-06-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -247,19 +247,25 @@ files:
247
247
  - lib/hq-graphql.rb
248
248
  - lib/hq/graphql.rb
249
249
  - lib/hq/graphql/active_record_extensions.rb
250
+ - lib/hq/graphql/association_loader.rb
250
251
  - lib/hq/graphql/comparator.rb
251
252
  - lib/hq/graphql/config.rb
252
253
  - lib/hq/graphql/engine.rb
253
254
  - lib/hq/graphql/enum.rb
255
+ - lib/hq/graphql/enum/sort_by.rb
256
+ - lib/hq/graphql/enum/sort_order.rb
254
257
  - lib/hq/graphql/field.rb
258
+ - lib/hq/graphql/field_extension/association_loader_extension.rb
259
+ - lib/hq/graphql/field_extension/paginated_arguments.rb
260
+ - lib/hq/graphql/field_extension/paginated_loader.rb
255
261
  - lib/hq/graphql/input_object.rb
256
262
  - lib/hq/graphql/inputs.rb
257
- - lib/hq/graphql/loaders.rb
258
- - lib/hq/graphql/loaders/association.rb
259
263
  - lib/hq/graphql/mutation.rb
260
264
  - lib/hq/graphql/object.rb
265
+ - lib/hq/graphql/object_association.rb
266
+ - lib/hq/graphql/paginated_association_loader.rb
261
267
  - lib/hq/graphql/resource.rb
262
- - lib/hq/graphql/resource/mutation.rb
268
+ - lib/hq/graphql/resource/auto_mutation.rb
263
269
  - lib/hq/graphql/root_mutation.rb
264
270
  - lib/hq/graphql/root_query.rb
265
271
  - lib/hq/graphql/scalars.rb
@@ -1,3 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "hq/graphql/loaders/association"
@@ -1,51 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module HQ
4
- module GraphQL
5
- module Loaders
6
- class Association < ::GraphQL::Batch::Loader
7
- def initialize(model, association_name)
8
- @model = model
9
- @association_name = association_name
10
- validate
11
- end
12
-
13
- def load(record)
14
- raise TypeError, "#{@model} loader can't load association for #{record.class}" unless record.is_a?(@model)
15
- return Promise.resolve(read_association(record)) if association_loaded?(record)
16
- super
17
- end
18
-
19
- # We want to load the associations on all records, even if they have the same id
20
- def cache_key(record)
21
- record.object_id
22
- end
23
-
24
- def perform(records)
25
- preload_association(records)
26
- records.each { |record| fulfill(record, read_association(record)) }
27
- end
28
-
29
- private
30
-
31
- def validate
32
- unless @model.reflect_on_association(@association_name)
33
- raise ArgumentError, "No association #{@association_name} on #{@model}"
34
- end
35
- end
36
-
37
- def preload_association(records)
38
- ::ActiveRecord::Associations::Preloader.new.preload(records, @association_name)
39
- end
40
-
41
- def read_association(record)
42
- record.public_send(@association_name)
43
- end
44
-
45
- def association_loaded?(record)
46
- record.association(@association_name).loaded?
47
- end
48
- end
49
- end
50
- end
51
- end
@@ -1,38 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module HQ
4
- module GraphQL
5
- module Resource
6
- module Mutation
7
- def self.build(model_name, action:, graphql_name:, require_primary_key: false, nil_klass: false, &block)
8
- Class.new(::HQ::GraphQL::Mutation) do
9
- graphql_name graphql_name
10
-
11
- define_method(:ready?) do |*args|
12
- super(*args) && ::HQ::GraphQL.authorized?(action, model_name, context)
13
- end
14
-
15
- lazy_load do
16
- field :errors, ::HQ::GraphQL::Types::Object, null: false
17
- field :resource, ::HQ::GraphQL::Types[model_name, nil_klass], null: true
18
- end
19
-
20
- instance_eval(&block)
21
-
22
- if require_primary_key
23
- lazy_load do
24
- klass = model_name.constantize
25
- primary_key = klass.primary_key
26
- argument primary_key, ::GraphQL::Types::ID, required: true
27
- end
28
- end
29
-
30
- def errors_from_resource(resource)
31
- resource.errors.to_h.deep_transform_keys { |k| k.to_s.camelize(:lower) }
32
- end
33
- end
34
- end
35
- end
36
- end
37
- end
38
- end