quail-graphql 0.1.0

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 (38) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +19 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +39 -0
  5. data/Rakefile +12 -0
  6. data/lib/generators/quail/channel_generator.rb +17 -0
  7. data/lib/generators/quail/install_generator.rb +75 -0
  8. data/lib/generators/quail/resource_generator.rb +79 -0
  9. data/lib/generators/quail/templates/graphql_channel.rb.tt +4 -0
  10. data/lib/generators/quail/templates/graphql_channel_custom.rb.tt +23 -0
  11. data/lib/generators/quail/templates/graphql_controller.rb.tt +40 -0
  12. data/lib/generators/quail/templates/initializer.rb.tt +5 -0
  13. data/lib/generators/quail/templates/resource.rb.tt +23 -0
  14. data/lib/generators/quail/templates/schema.rb.tt +21 -0
  15. data/lib/quail/channel.rb +52 -0
  16. data/lib/quail/controller_helpers.rb +27 -0
  17. data/lib/quail/railtie.rb +41 -0
  18. data/lib/quail/resource/dsl.rb +121 -0
  19. data/lib/quail/resource/mutation_builder/context.rb +22 -0
  20. data/lib/quail/resource/mutation_builder/resolvers.rb +42 -0
  21. data/lib/quail/resource/mutation_builder.rb +139 -0
  22. data/lib/quail/resource/query_builder.rb +42 -0
  23. data/lib/quail/resource/subscription_builder.rb +53 -0
  24. data/lib/quail/resource/type_builder/association_builder.rb +92 -0
  25. data/lib/quail/resource/type_builder/field_builder.rb +57 -0
  26. data/lib/quail/resource/type_builder.rb +68 -0
  27. data/lib/quail/resource.rb +33 -0
  28. data/lib/quail/schema_builder/discovery.rb +42 -0
  29. data/lib/quail/schema_builder/type_definitions.rb +44 -0
  30. data/lib/quail/schema_builder.rb +127 -0
  31. data/lib/quail/tasks/quail.rake +25 -0
  32. data/lib/quail/type_map.rb +31 -0
  33. data/lib/quail/version.rb +5 -0
  34. data/lib/quail.rb +99 -0
  35. data/quail_logo.png +0 -0
  36. data/quail_logo_new.svg +129 -0
  37. data/sig/quail.rbs +4 -0
  38. metadata +123 -0
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quail
4
+ module Resource
5
+ module MutationBuilder
6
+ # Runtime resolve logic for auto-generated mutations.
7
+ module Resolvers
8
+ def self.create(model, name, subs, gql_context, attrs)
9
+ record = model.new(attrs)
10
+ return { name.to_sym => nil, errors: record.errors.full_messages } unless record.save
11
+
12
+ MutationBuilder.trigger_subscription(gql_context, subs, :create, name, record)
13
+ { name.to_sym => record, errors: [] }
14
+ end
15
+
16
+ def self.update(model, name, subs, gql_context, params)
17
+ record = model.find_by(id: params[:id])
18
+ return { name.to_sym => nil, errors: ["#{model.name} not found"] } unless record
19
+
20
+ unless record.update(params[:attrs].compact)
21
+ return { name.to_sym => nil,
22
+ errors: record.errors.full_messages }
23
+ end
24
+
25
+ MutationBuilder.trigger_subscription(gql_context, subs, :update, name, record)
26
+ { name.to_sym => record, errors: [] }
27
+ end
28
+
29
+ def self.delete(model, name, subs, gql_context, id)
30
+ record = model.find_by(id: id)
31
+ return { success: false, errors: ["#{model.name} not found"] } unless record
32
+
33
+ snapshot = MutationBuilder.capture_delete_snapshot(subs, record)
34
+ return { success: false, errors: record.errors.full_messages } unless record.destroy
35
+
36
+ MutationBuilder.trigger_delete_event(gql_context, name, snapshot)
37
+ { success: true, errors: [] }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mutation_builder/context"
4
+ require_relative "mutation_builder/resolvers"
5
+
6
+ module Quail
7
+ module Resource
8
+ # Builds create, update, and delete GraphQL mutations for a resource.
9
+ module MutationBuilder # rubocop:disable Metrics/ModuleLength
10
+ def self.call(resource_class)
11
+ skipped = resource_class.skipped_mutations
12
+ overrides = resource_class.resolved_mutation_overrides
13
+ ctx = MutationContext.new(resource_class)
14
+ mutations = {}
15
+
16
+ %i[create update delete].each do |action|
17
+ next if skipped.include?(action)
18
+
19
+ mutations[action] = overrides[action] || build_mutation(action, ctx)
20
+ end
21
+ mutations
22
+ end
23
+
24
+ def self.build_mutation(action, ctx)
25
+ case action
26
+ when :create then build_create(ctx)
27
+ when :update then build_update(ctx)
28
+ when :delete then build_delete(ctx)
29
+ end
30
+ end
31
+
32
+ def self.default_writable(model, resource_class = nil)
33
+ excluded = %i[id created_at updated_at]
34
+ excluded += polymorphic_columns(resource_class) if resource_class
35
+ model.column_names.map(&:to_sym).reject { |c| excluded.include?(c) }
36
+ end
37
+
38
+ def self.polymorphic_columns(resource_class)
39
+ return [] unless resource_class
40
+
41
+ resource_class.association_definitions
42
+ .select { |_, config| config[:polymorphic] }
43
+ .flat_map do |name, _|
44
+ [
45
+ :"#{name}_type", :"#{name}_id"
46
+ ]
47
+ end
48
+ end
49
+
50
+ def self.resolve_scope(scope_config, record)
51
+ return {} unless scope_config
52
+
53
+ case scope_config
54
+ when Symbol then { scope_config => record.public_send(scope_config) }
55
+ when Hash
56
+ key, value_proc = scope_config.first
57
+ { key.to_sym => value_proc.call(record) }
58
+ else {}
59
+ end
60
+ end
61
+
62
+ def self.add_writable_arguments(klass, model, writable, required:)
63
+ writable.each do |attr|
64
+ col = model.columns_hash[attr.to_s]
65
+ next unless col
66
+
67
+ klass.argument attr, TypeMap.graphql_types(col), required: required ? !TypeMap.nullable?(col) : false
68
+ end
69
+ end
70
+
71
+ def self.add_result_fields(klass, underscore_name, type_class)
72
+ klass.field underscore_name.to_sym, type_class, null: true
73
+ klass.field :errors, [GraphQL::Types::String], null: false
74
+ end
75
+
76
+ def self.trigger_subscription(gql_context, subs, event, underscore_name, record)
77
+ sub_config = subs[event]
78
+ return unless sub_config
79
+
80
+ scope_args = resolve_scope(sub_config[:scope], record)
81
+ gql_context.schema.subscriptions&.trigger(:"#{underscore_name}_#{event}d", scope_args, record)
82
+ end
83
+
84
+ def self.capture_delete_snapshot(subs, record)
85
+ return nil unless subs[:delete]
86
+
87
+ { attributes: record.attributes, scope_args: resolve_scope(subs[:delete][:scope], record) }
88
+ end
89
+
90
+ def self.trigger_delete_event(gql_context, name, snapshot)
91
+ return unless snapshot
92
+
93
+ gql_context.schema.subscriptions&.trigger(:"#{name}_deleted", snapshot[:scope_args], snapshot[:attributes])
94
+ end
95
+
96
+ def self.build_create(ctx)
97
+ model = ctx.model
98
+ name = ctx.underscore_name
99
+ subs = ctx.subscriptions
100
+ klass = new_mutation_class(ctx, "Create", model)
101
+ add_writable_arguments(klass, model, ctx.writable, required: true)
102
+ add_result_fields(klass, name, ctx.type_class)
103
+ klass.define_method(:resolve) { |**attrs| Resolvers.create(model, name, subs, context, attrs) }
104
+ klass
105
+ end
106
+
107
+ def self.build_update(ctx)
108
+ model, name, subs = ctx.model, ctx.underscore_name, ctx.subscriptions
109
+ klass = new_mutation_class(ctx, "Update", model)
110
+ klass.argument :id, GraphQL::Types::ID, required: true
111
+ add_writable_arguments(klass, model, ctx.writable, required: false)
112
+ add_result_fields(klass, name, ctx.type_class)
113
+ klass.define_method(:resolve) do |id:, **attrs|
114
+ Resolvers.update(model, name, subs, context, { id: id, attrs: attrs })
115
+ end
116
+ klass
117
+ end
118
+
119
+ def self.build_delete(ctx)
120
+ model = ctx.model
121
+ name = ctx.underscore_name
122
+ subs = ctx.subscriptions
123
+ klass = new_mutation_class(ctx, "Delete", model)
124
+ klass.argument :id, GraphQL::Types::ID, required: true
125
+ klass.field :success, GraphQL::Types::Boolean, null: false
126
+ klass.field :errors, [GraphQL::Types::String], null: false
127
+ klass.define_method(:resolve) { |id:| Resolvers.delete(model, name, subs, context, id) }
128
+ klass
129
+ end
130
+
131
+ def self.new_mutation_class(ctx, prefix, model)
132
+ Class.new(ctx.base) do
133
+ graphql_name "#{prefix}#{model.name}"
134
+ description "#{prefix}s a #{model.name}"
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quail
4
+ module Resource
5
+ # Builds find and list GraphQL query fields for a resource.
6
+ module QueryBuilder
7
+ def self.call(resource_class)
8
+ return {} if resource_class.skipped_queries.include?(:all)
9
+
10
+ model = resource_class.model_class
11
+ type_class = resource_class.graphql_type
12
+ skipped = resource_class.skipped_queries
13
+ fields = {}
14
+
15
+ fields.merge!(build_find_field(model, type_class)) unless skipped.include?(:find)
16
+ fields.merge!(build_list_field(model, type_class)) unless skipped.include?(:list)
17
+ fields
18
+ end
19
+
20
+ def self.build_find_field(model, type_class)
21
+ {
22
+ model.name.underscore.to_sym => {
23
+ type: type_class,
24
+ null: true,
25
+ arguments: { id: { type: GraphQL::Types::ID, required: true } },
26
+ resolve: ->(_obj, args, _ctx) { model.find_by(id: args[:id]) }
27
+ }
28
+ }
29
+ end
30
+
31
+ def self.build_list_field(model, type_class)
32
+ {
33
+ model.name.underscore.pluralize.to_sym => {
34
+ type: type_class.connection_type,
35
+ null: false,
36
+ resolve: ->(_obj, _args, _ctx) { model.all }
37
+ }
38
+ }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quail
4
+ module Resource
5
+ # Builds GraphQL subscription fields for a resource based on its subscription definitions.
6
+ module SubscriptionBuilder
7
+ def self.call(resource_class)
8
+ subs = resource_class.subscription_definitions
9
+ return {} if subs.empty?
10
+
11
+ model_name = resource_class.model_class.name
12
+ type_class = resource_class.graphql_type
13
+ fields = {}
14
+
15
+ subs.each do |event, config|
16
+ field_name = :"#{model_name.underscore}_#{event}d"
17
+ fields[field_name] = build_subscription_class(type_class, model_name, event, config)
18
+ end
19
+
20
+ fields
21
+ end
22
+
23
+ def self.build_subscription_class(type_class, model_name, event, config)
24
+ sub_class = Class.new(GraphQL::Schema::Subscription) do
25
+ graphql_name "#{model_name}#{event.to_s.capitalize}d"
26
+ description "Triggered when a #{model_name} is #{event}d"
27
+ payload_type type_class
28
+ end
29
+
30
+ define_hash_rehydration(sub_class, model_name.constantize)
31
+ apply_scope(sub_class, config[:scope])
32
+ sub_class
33
+ end
34
+
35
+ # Rehydrate Hash payloads (e.g. from delete snapshots) into model
36
+ # instances so computed attributes that call associations still work.
37
+ def self.define_hash_rehydration(sub_class, model_class)
38
+ sub_class.define_method(:update) do |**_args|
39
+ return object unless object.is_a?(Hash)
40
+
41
+ model_class.new(object).tap(&:readonly!)
42
+ end
43
+ end
44
+
45
+ def self.apply_scope(sub_class, scope)
46
+ return unless scope
47
+
48
+ scope_key = scope.is_a?(Hash) ? scope.keys.first.to_sym : scope.to_sym
49
+ sub_class.argument scope_key, GraphQL::Types::ID, required: true
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quail
4
+ module Resource
5
+ module TypeBuilder
6
+ # Wires up association fields (belongs_to, has_one, has_many, polymorphic)
7
+ # on a GraphQL type.
8
+ module AssociationBuilder
9
+ def self.add_fields(resource_class)
10
+ model = resource_class.model_class
11
+ type_class = resource_class.graphql_type
12
+
13
+ resource_class.association_definitions.each do |name, config|
14
+ next if type_class.own_fields.key?(name.to_s)
15
+
16
+ add_single(type_class, model, name, config)
17
+ end
18
+ end
19
+
20
+ def self.add_single(type_class, model, name, config)
21
+ if config[:polymorphic]
22
+ add_polymorphic_field(type_class, name, config)
23
+ elsif config[:resource]
24
+ add_explicit_resource_field(type_class, model, name, config)
25
+ else
26
+ add_reflected_field(type_class, model, name, config)
27
+ end
28
+ end
29
+
30
+ def self.add_polymorphic_field(type_class, name, config)
31
+ union_type = build_union_type(name, config)
32
+ type_class.field name, union_type, null: true
33
+ end
34
+
35
+ def self.add_explicit_resource_field(type_class, model, name, config)
36
+ resource_class = TypeBuilder.resolve_resource_ref(config[:resource])
37
+ assoc_type = resource_class&.graphql_type
38
+ return unless assoc_type
39
+
40
+ ar_assoc = model.reflect_on_association(name)
41
+ add_association_field(type_class, name, config[:kind], assoc_type, ar_assoc)
42
+ end
43
+
44
+ def self.add_reflected_field(type_class, model, name, config)
45
+ ar_assoc = model.reflect_on_association(name)
46
+ return unless ar_assoc
47
+
48
+ assoc_type = Quail.resource_for(ar_assoc.klass)&.graphql_type
49
+ return unless assoc_type
50
+
51
+ add_association_field(type_class, name, config[:kind], assoc_type, ar_assoc)
52
+ end
53
+
54
+ def self.build_union_type(name, config)
55
+ gql_name = config[:union_name] || "#{name.to_s.camelize}Union"
56
+ resolved_types = resolve_polymorphic_types(config[:polymorphic_types])
57
+ assoc_name = name
58
+
59
+ Class.new(GraphQL::Schema::Union) do
60
+ graphql_name gql_name
61
+ description "Union type for polymorphic association #{assoc_name}"
62
+ possible_types(*resolved_types)
63
+ define_method(:resolve_type) { |obj, _ctx| TypeBuilder.resolve_polymorphic_type(obj, assoc_name) }
64
+ end
65
+ end
66
+
67
+ def self.resolve_polymorphic_types(types)
68
+ types.map do |t|
69
+ resource = TypeBuilder.resolve_resource_ref(t)
70
+ gql_type = resource.graphql_type
71
+ unless gql_type
72
+ raise ArgumentError,
73
+ "Polymorphic type #{t.inspect} resolved to #{resource.name} but its graphql_type is nil. " \
74
+ "Ensure the resource is registered before building associations."
75
+ end
76
+ gql_type
77
+ end
78
+ end
79
+
80
+ def self.add_association_field(type_class, name, kind, assoc_type, ar_assoc)
81
+ case kind
82
+ when :has_many
83
+ type_class.field name, [assoc_type], null: false
84
+ when :has_one, :belongs_to
85
+ nullable = kind != :belongs_to || !ar_assoc || ar_assoc.options[:optional] != false
86
+ type_class.field name, assoc_type, null: nullable
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quail
4
+ module Resource
5
+ module TypeBuilder
6
+ # Defines scalar (column-backed) and computed fields on a GraphQL type.
7
+ module FieldBuilder
8
+ # Full block signature: |object, args, context|
9
+ CONTEXT_AWARE_ARITY = 3
10
+
11
+ def self.define_column_fields(type_class, model, attrs)
12
+ attrs.each do |name, config|
13
+ next unless config[:type] == :column
14
+
15
+ col = model.columns_hash[name.to_s]
16
+ if col
17
+ type_class.field name, TypeMap.graphql_types(col), null: TypeMap.nullable?(col)
18
+ else
19
+ type_class.field name, GraphQL::Types::String, null: true
20
+ end
21
+ end
22
+ end
23
+
24
+ def self.define_computed_fields(type_class, attrs)
25
+ attrs.each do |name, config|
26
+ next unless config[:type] == :computed
27
+
28
+ define_single_computed_field(type_class, name, config)
29
+ end
30
+ end
31
+
32
+ def self.define_single_computed_field(type_class, name, config)
33
+ gql_type = config[:graphql_type] || GraphQL::Types::String
34
+ nullable = config[:null].nil? || config[:null]
35
+ type_class.field name, gql_type, null: nullable
36
+ define_computed_resolver(type_class, name, config[:block])
37
+ end
38
+
39
+ def self.define_computed_resolver(type_class, name, blk)
40
+ type_class.define_method(name) do
41
+ if blk.arity.abs >= CONTEXT_AWARE_ARITY
42
+ FieldBuilder.safe_call(blk, object, nil, context)
43
+ else
44
+ FieldBuilder.safe_call(blk, object)
45
+ end
46
+ end
47
+ end
48
+
49
+ def self.safe_call(blk, *)
50
+ blk.call(*)
51
+ rescue LocalJumpError => e
52
+ e.exit_value
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "type_builder/field_builder"
4
+ require_relative "type_builder/association_builder"
5
+
6
+ module Quail
7
+ module Resource
8
+ # Generates GraphQL object types from resource attribute and association definitions.
9
+ module TypeBuilder
10
+ def self.build_all
11
+ # Two-pass build: first create all GraphQL types so that @graphql_type
12
+ # is available on every resource, then wire up associations (which may
13
+ # reference other resources' types, e.g. polymorphic unions).
14
+ Quail.registry.each_value { |rc| build_scalar_fields(rc) unless rc.graphql_type }
15
+ Quail.registry.each_value { |rc| AssociationBuilder.add_fields(rc) } # rubocop:disable Style/CombinableLoops
16
+ end
17
+
18
+ def self.build_scalar_fields(resource_class)
19
+ model = resource_class.model_class
20
+ attrs = resource_class.attribute_definitions
21
+ type_class = create_type_class(model)
22
+
23
+ FieldBuilder.define_column_fields(type_class, model, attrs)
24
+ FieldBuilder.define_computed_fields(type_class, attrs)
25
+ resource_class.instance_variable_set(:@graphql_type, type_class)
26
+ register_type_constant(model, type_class)
27
+ end
28
+
29
+ def self.create_type_class(model)
30
+ base = Quail.base_object_class || GraphQL::Schema::Object
31
+ Class.new(base) do
32
+ graphql_name "#{model.name}Type"
33
+ description "Auto-generated type for #{model.name}"
34
+ end
35
+ end
36
+
37
+ def self.register_type_constant(model, type_class)
38
+ const_name = "#{model.name}Type"
39
+ Object.const_set(const_name, type_class) unless Object.const_defined?(const_name)
40
+ end
41
+
42
+ # Resolve a resource reference that can be a Class or a String class name.
43
+ def self.resolve_resource_ref(ref)
44
+ case ref
45
+ when Class then ref
46
+ when String then ref.constantize
47
+ else raise ArgumentError, "Expected a resource class or string class name, got #{ref.inspect}"
48
+ end
49
+ end
50
+
51
+ def self.resolve_polymorphic_type(obj, assoc_name)
52
+ resource = Quail.resource_for(obj.class)
53
+ unless resource
54
+ raise GraphQL::ExecutionError,
55
+ "Cannot resolve polymorphic type '#{obj.class.name}' " \
56
+ "for association :#{assoc_name} — no resource registered"
57
+ end
58
+ resource.graphql_type
59
+ end
60
+
61
+ # Delegate public API so existing callers (and tests) still work.
62
+ def self.add_association_fields(resource_class) = AssociationBuilder.add_fields(resource_class)
63
+ def self.add_single_association(...) = AssociationBuilder.add_single(...)
64
+ def self.add_polymorphic_field(...) = AssociationBuilder.add_polymorphic_field(...)
65
+ def self.build_union_type(...) = AssociationBuilder.build_union_type(...)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quail
4
+ # Mixin that turns a class into a Quail resource with auto-generated GraphQL types,
5
+ # queries, mutations, and subscriptions.
6
+ module Resource
7
+ def self.included(base)
8
+ base.include DSL
9
+ base.extend Lookup
10
+
11
+ Quail.register(base)
12
+ end
13
+
14
+ # Class-level accessors for the generated GraphQL type, mutations, queries, and subscriptions.
15
+ module Lookup
16
+ def graphql_type
17
+ @graphql_type
18
+ end
19
+
20
+ def mutations
21
+ @mutations ||= MutationBuilder.call(self)
22
+ end
23
+
24
+ def query_fields
25
+ @query_fields ||= QueryBuilder.call(self)
26
+ end
27
+
28
+ def subscription_fields
29
+ @subscription_fields ||= SubscriptionBuilder.call(self)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quail
4
+ module SchemaBuilder
5
+ # Discovers custom mutation and query classes from the app/graphql directory.
6
+ module Discovery
7
+ def self.custom_mutations
8
+ discover_classes("mutations", Quail::Mutation)
9
+ end
10
+
11
+ def self.custom_queries
12
+ discover_classes("queries", Quail::Query)
13
+ end
14
+
15
+ def self.discover_classes(dir, base_class)
16
+ return {} unless defined?(Rails)
17
+
18
+ result = {}
19
+ Dir[Rails.root.join("app/graphql/#{dir}/**/*.rb")].each do |f|
20
+ name, klass = resolve_class(f, dir, base_class)
21
+ result[name] = klass if klass
22
+ end
23
+ result
24
+ end
25
+
26
+ def self.resolve_class(file, dir, base_class)
27
+ relative = Pathname.new(file).relative_path_from(Rails.root.join("app/graphql/#{dir}"))
28
+ base_name = relative.to_s.delete_suffix(".rb").camelize
29
+
30
+ # Try top-level constant first (autoload root convention), fall back to namespaced
31
+ klass = begin
32
+ base_name.constantize
33
+ rescue NameError
34
+ "#{dir.camelize}::#{base_name}".constantize
35
+ end
36
+ return nil unless klass < base_class
37
+
38
+ [klass.name.demodulize.camelize(:lower).to_sym, klass]
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quail
4
+ module SchemaBuilder
5
+ # Helpers for defining fields and arguments on dynamically-built GraphQL types.
6
+ module TypeDefinitions
7
+ def self.define_query_fields(type_class, fields)
8
+ fields.each do |name, config|
9
+ f = type_class.field name, config[:type], null: config[:null]
10
+ add_arguments(f, config[:arguments])
11
+ next unless config[:resolve]
12
+
13
+ type_class.define_method(name) do |**args|
14
+ config[:resolve].call(object, args, context)
15
+ end
16
+ end
17
+ end
18
+
19
+ def self.define_extra_query_fields(type_class, extra_fields)
20
+ extra_fields.each do |name, config|
21
+ f = type_class.field name, config[:type], null: config[:null]
22
+ add_arguments(f, config[:arguments])
23
+
24
+ resolver = config[:resolver]
25
+ type_class.define_method(name) do |**args|
26
+ resolver.new(object, args, context).call
27
+ end
28
+ end
29
+ end
30
+
31
+ def self.define_subscription_fields(type_class, fields)
32
+ fields.each do |name, sub_class|
33
+ type_class.field name, subscription: sub_class
34
+ end
35
+ end
36
+
37
+ def self.add_arguments(field, arguments)
38
+ arguments&.each do |arg_name, arg_config|
39
+ field.argument arg_name, arg_config[:type], required: arg_config[:required]
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end