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.
- checksums.yaml +7 -0
- data/AGENTS.md +19 -0
- data/LICENSE.txt +21 -0
- data/README.md +39 -0
- data/Rakefile +12 -0
- data/lib/generators/quail/channel_generator.rb +17 -0
- data/lib/generators/quail/install_generator.rb +75 -0
- data/lib/generators/quail/resource_generator.rb +79 -0
- data/lib/generators/quail/templates/graphql_channel.rb.tt +4 -0
- data/lib/generators/quail/templates/graphql_channel_custom.rb.tt +23 -0
- data/lib/generators/quail/templates/graphql_controller.rb.tt +40 -0
- data/lib/generators/quail/templates/initializer.rb.tt +5 -0
- data/lib/generators/quail/templates/resource.rb.tt +23 -0
- data/lib/generators/quail/templates/schema.rb.tt +21 -0
- data/lib/quail/channel.rb +52 -0
- data/lib/quail/controller_helpers.rb +27 -0
- data/lib/quail/railtie.rb +41 -0
- data/lib/quail/resource/dsl.rb +121 -0
- data/lib/quail/resource/mutation_builder/context.rb +22 -0
- data/lib/quail/resource/mutation_builder/resolvers.rb +42 -0
- data/lib/quail/resource/mutation_builder.rb +139 -0
- data/lib/quail/resource/query_builder.rb +42 -0
- data/lib/quail/resource/subscription_builder.rb +53 -0
- data/lib/quail/resource/type_builder/association_builder.rb +92 -0
- data/lib/quail/resource/type_builder/field_builder.rb +57 -0
- data/lib/quail/resource/type_builder.rb +68 -0
- data/lib/quail/resource.rb +33 -0
- data/lib/quail/schema_builder/discovery.rb +42 -0
- data/lib/quail/schema_builder/type_definitions.rb +44 -0
- data/lib/quail/schema_builder.rb +127 -0
- data/lib/quail/tasks/quail.rake +25 -0
- data/lib/quail/type_map.rb +31 -0
- data/lib/quail/version.rb +5 -0
- data/lib/quail.rb +99 -0
- data/quail_logo.png +0 -0
- data/quail_logo_new.svg +129 -0
- data/sig/quail.rbs +4 -0
- 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
|