active_graphql 0.2.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +15 -0
- data/.hound.yml +4 -0
- data/.rspec +3 -0
- data/.rubocop.yml +48 -0
- data/.ruby-version +1 -0
- data/.travis.yml +7 -0
- data/CHANGELOG.md +25 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +15 -0
- data/Gemfile.lock +134 -0
- data/LICENSE.txt +21 -0
- data/Rakefile +6 -0
- data/active_graphql.gemspec +49 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/docs/.nojekyll +0 -0
- data/docs/README.md +95 -0
- data/docs/_sidebar.md +4 -0
- data/docs/client.md +69 -0
- data/docs/index.html +70 -0
- data/docs/model.md +464 -0
- data/lib/active_graphql.rb +10 -0
- data/lib/active_graphql/client.rb +38 -0
- data/lib/active_graphql/client/actions.rb +15 -0
- data/lib/active_graphql/client/actions/action.rb +116 -0
- data/lib/active_graphql/client/actions/action/format_inputs.rb +80 -0
- data/lib/active_graphql/client/actions/action/format_outputs.rb +40 -0
- data/lib/active_graphql/client/actions/mutation_action.rb +29 -0
- data/lib/active_graphql/client/actions/query_action.rb +23 -0
- data/lib/active_graphql/client/adapters.rb +10 -0
- data/lib/active_graphql/client/adapters/graphlient_adapter.rb +32 -0
- data/lib/active_graphql/client/response.rb +47 -0
- data/lib/active_graphql/errors.rb +11 -0
- data/lib/active_graphql/model.rb +174 -0
- data/lib/active_graphql/model/action_formatter.rb +96 -0
- data/lib/active_graphql/model/build_or_relation.rb +66 -0
- data/lib/active_graphql/model/configuration.rb +83 -0
- data/lib/active_graphql/model/find_in_batches.rb +54 -0
- data/lib/active_graphql/model/relation_proxy.rb +321 -0
- data/lib/active_graphql/version.rb +5 -0
- metadata +254 -0
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveGraphql
|
4
|
+
module Errors
|
5
|
+
class Error < StandardError; end
|
6
|
+
class RecordNotFoundError < ActiveGraphql::Errors::Error; end
|
7
|
+
class ResponseError < ActiveGraphql::Errors::Error; end
|
8
|
+
class WrongTypeError < ActiveGraphql::Errors::Error; end
|
9
|
+
class RecordNotValidError < ActiveGraphql::Errors::Error; end
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,174 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveGraphql
|
4
|
+
# Allows to have ActiveRecord-like models which comunicates with graphql endpoint instead of DB:
|
5
|
+
# class RemoteUser
|
6
|
+
# include ActiveGraphql::Model
|
7
|
+
#
|
8
|
+
# graphql_url('http://localhost:3001/graphql')
|
9
|
+
# graphql_attributes :id, :full_name
|
10
|
+
# end
|
11
|
+
#
|
12
|
+
# Now you can do:
|
13
|
+
# RemoteUser.where(created_at { from: '2000-01-01', to: '2010-01-01' })
|
14
|
+
# RemoteUser.all.for_each { |user| ... }
|
15
|
+
# RemoteUser.where(...).count
|
16
|
+
#
|
17
|
+
# Model expects that graphql has GraphqlRails CRUD actions with default naming (createRemoteUser, remoteUsers, etc.)
|
18
|
+
module Model
|
19
|
+
require 'active_graphql/model/configuration'
|
20
|
+
require 'active_graphql/model/action_formatter'
|
21
|
+
require 'active_graphql/model/relation_proxy'
|
22
|
+
require 'active_support'
|
23
|
+
require 'active_support/core_ext/hash'
|
24
|
+
require 'active_model'
|
25
|
+
|
26
|
+
extend ActiveSupport::Concern
|
27
|
+
|
28
|
+
included do # rubocop:disable Metrics/BlockLength
|
29
|
+
include ActiveModel::Validations
|
30
|
+
|
31
|
+
validate :validate_graphql_errors
|
32
|
+
|
33
|
+
attr_reader :attributes
|
34
|
+
attr_writer :graphql_errors
|
35
|
+
|
36
|
+
def initialize(attributes)
|
37
|
+
@attributes = attributes.deep_transform_keys { |it| it.to_s.underscore.to_sym }
|
38
|
+
end
|
39
|
+
|
40
|
+
def mutate(action_name, params = {})
|
41
|
+
all_params = { primary_key => primary_key_value }.merge(params)
|
42
|
+
response = exec_graphql { |api| api.mutation(action_name.to_s).input(all_params) }
|
43
|
+
self.attributes = response.result.to_h
|
44
|
+
self.graphql_errors = response.detailed_errors
|
45
|
+
valid?
|
46
|
+
end
|
47
|
+
|
48
|
+
def update(params)
|
49
|
+
action_name = "update_#{self.class.active_graphql.resource_name}"
|
50
|
+
mutate(action_name, params)
|
51
|
+
end
|
52
|
+
|
53
|
+
def update!(params)
|
54
|
+
success = update(params)
|
55
|
+
return true if success
|
56
|
+
|
57
|
+
error_message = (errors['graphql'] || errors.full_messages).first
|
58
|
+
raise Errors::RecordNotValidError, error_message
|
59
|
+
end
|
60
|
+
|
61
|
+
def attributes=(new_attributes)
|
62
|
+
formatted_new_attributes = new_attributes.deep_transform_keys { |it| it.to_s.underscore.to_sym }
|
63
|
+
@attributes = attributes.merge(formatted_new_attributes)
|
64
|
+
end
|
65
|
+
|
66
|
+
def destroy
|
67
|
+
action_name = "destroy_#{self.class.active_graphql.resource_name}"
|
68
|
+
response = exec_graphql { |api| api.mutation(action_name).input(primary_key => primary_key_value) }
|
69
|
+
response.success?
|
70
|
+
end
|
71
|
+
|
72
|
+
def reload
|
73
|
+
self.attributes = self.class.find(primary_key_value).attributes
|
74
|
+
self
|
75
|
+
end
|
76
|
+
|
77
|
+
protected
|
78
|
+
|
79
|
+
def exec_graphql(*args, &block)
|
80
|
+
self.class.exec_graphql(*args, &block)
|
81
|
+
end
|
82
|
+
|
83
|
+
def read_graphql_attribute(attribute)
|
84
|
+
value = attributes[attribute.name]
|
85
|
+
if attribute.decorate_with
|
86
|
+
send(attribute.decorate_with, value)
|
87
|
+
else
|
88
|
+
value
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
private
|
93
|
+
|
94
|
+
def graphql_errors
|
95
|
+
@graphql_errors ||= []
|
96
|
+
end
|
97
|
+
|
98
|
+
def validate_graphql_errors
|
99
|
+
graphql_errors.each do |error|
|
100
|
+
error_key = error[:field] || 'graphql'
|
101
|
+
error_message = error[:short_message] || error[:message]
|
102
|
+
|
103
|
+
errors.add(error_key, error_message)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def read_attribute_for_validation(key)
|
108
|
+
key == 'graphql' ? key : super
|
109
|
+
end
|
110
|
+
|
111
|
+
def primary_key
|
112
|
+
self.class.active_graphql.primary_key
|
113
|
+
end
|
114
|
+
|
115
|
+
def primary_key_value
|
116
|
+
send(primary_key)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
class_methods do # rubocop:disable Metrics/BlockLength
|
121
|
+
delegate :first, :last, :limit, :count, :where, :select, :select_attributes, :find_each, :find, to: :all
|
122
|
+
|
123
|
+
def inherited(sublass)
|
124
|
+
sublass.instance_variable_set(:@active_graphql, active_graphql.dup)
|
125
|
+
end
|
126
|
+
|
127
|
+
def active_graphql
|
128
|
+
@active_graphql ||= ActiveGraphql::Model::Configuration.new
|
129
|
+
if block_given?
|
130
|
+
yield(@active_graphql)
|
131
|
+
@active_graphql.attributes.each do |attribute|
|
132
|
+
define_method(attribute.name) do
|
133
|
+
read_graphql_attribute(attribute)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
@active_graphql
|
138
|
+
end
|
139
|
+
|
140
|
+
def create(params)
|
141
|
+
action_name = "create_#{active_graphql.resource_name}"
|
142
|
+
response = exec_graphql { |api| api.mutation(action_name).input(params) }
|
143
|
+
new(response.result.to_h).tap do |record|
|
144
|
+
record.graphql_errors = response.detailed_errors if !response.success? || !record.valid?
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def create!(params)
|
149
|
+
record = create(params)
|
150
|
+
|
151
|
+
return record if record.valid?
|
152
|
+
|
153
|
+
error_message = (record.errors['graphql'] || record.errors.full_messages).first
|
154
|
+
raise Errors::RecordNotValidError, error_message
|
155
|
+
end
|
156
|
+
|
157
|
+
def all
|
158
|
+
@all ||= ::ActiveGraphql::Model::RelationProxy.new(self)
|
159
|
+
end
|
160
|
+
|
161
|
+
def exec_graphql
|
162
|
+
formatter = active_graphql.formatter
|
163
|
+
api = active_graphql.graphql_client
|
164
|
+
|
165
|
+
raw_action = \
|
166
|
+
yield(api)
|
167
|
+
.output(*select_attributes)
|
168
|
+
.meta(primary_key: active_graphql.primary_key)
|
169
|
+
|
170
|
+
formatter.call(raw_action).response
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveGraphql
|
4
|
+
module Model
|
5
|
+
# reformats action to default format which very opinionated and based in following assumptions:
|
6
|
+
# * all attributes and fields are camel cased
|
7
|
+
# * all mutation actions accept one or two fields: id and input (input is everyting except 'id')
|
8
|
+
# * collection actions are paginated and accepts input attribute `filter`
|
9
|
+
#
|
10
|
+
# github graphql structure was used as inspiration
|
11
|
+
class ActionFormatter
|
12
|
+
require 'active_support/core_ext/string'
|
13
|
+
|
14
|
+
def self.call(action)
|
15
|
+
new(action).call
|
16
|
+
end
|
17
|
+
|
18
|
+
def initialize(action)
|
19
|
+
@action = action
|
20
|
+
end
|
21
|
+
|
22
|
+
def call
|
23
|
+
action.class.new(
|
24
|
+
name: formatted_name,
|
25
|
+
client: action.client,
|
26
|
+
output_values: formatted_outputs,
|
27
|
+
input_attributes: formatted_inputs.symbolize_keys
|
28
|
+
)
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
attr_reader :action
|
34
|
+
|
35
|
+
def primary_key
|
36
|
+
action.meta_attributes.fetch(:primary_key, 'id').to_s
|
37
|
+
end
|
38
|
+
|
39
|
+
def formatted_name
|
40
|
+
action.name.camelize(:lower)
|
41
|
+
end
|
42
|
+
|
43
|
+
def formatted_inputs
|
44
|
+
attributes = action.input_attributes.deep_transform_keys do |key|
|
45
|
+
key.to_s.starts_with?('__') ? key : key.to_s.camelize(:lower)
|
46
|
+
end
|
47
|
+
|
48
|
+
if mutation?
|
49
|
+
formatted_mutation_inputs(attributes)
|
50
|
+
else
|
51
|
+
attributes
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def formatted_mutation_inputs(attributes)
|
56
|
+
{
|
57
|
+
'input' => attributes.except(primary_key).presence,
|
58
|
+
primary_key => attributes[primary_key]
|
59
|
+
}.compact
|
60
|
+
end
|
61
|
+
|
62
|
+
def mutation?
|
63
|
+
action.type == :mutation
|
64
|
+
end
|
65
|
+
|
66
|
+
def paginated?
|
67
|
+
action.meta_attributes[:paginated]
|
68
|
+
end
|
69
|
+
|
70
|
+
def formatted_outputs
|
71
|
+
outputs = formatted_output_values(action.output_values)
|
72
|
+
|
73
|
+
if paginated?
|
74
|
+
{
|
75
|
+
edges: { node: outputs },
|
76
|
+
pageInfo: [:hasNextPage]
|
77
|
+
}
|
78
|
+
else
|
79
|
+
outputs.is_a?(Hash) ? outputs.symbolize_keys : outputs
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def formatted_output_values(attributes)
|
84
|
+
if attributes.is_a?(Array)
|
85
|
+
attributes.map { |it| formatted_output_values(it) }
|
86
|
+
elsif attributes.is_a?(Hash)
|
87
|
+
attributes
|
88
|
+
.transform_keys { |key| formatted_output_values(key) }
|
89
|
+
.transform_values { |value| formatted_output_values(value) }
|
90
|
+
else
|
91
|
+
attributes.to_s.camelize(:lower)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveGraphql
|
4
|
+
module Model
|
5
|
+
# reformats action to default format which very opinionated and based in following assumptions:
|
6
|
+
# * all attributes and fields are camel cased
|
7
|
+
# * all mutation actions accept one or two fields: id and input (input is everyting except 'id')
|
8
|
+
# * collection actions are paginated and accepts input attribute `filter`
|
9
|
+
#
|
10
|
+
# github graphql structure was used as inspiration
|
11
|
+
class BuildOrRelation
|
12
|
+
def self.call(*args)
|
13
|
+
new(*args).call
|
14
|
+
end
|
15
|
+
|
16
|
+
def initialize(left_scope, right_scope)
|
17
|
+
@left_scope = left_scope
|
18
|
+
@right_scope = right_scope
|
19
|
+
end
|
20
|
+
|
21
|
+
def call
|
22
|
+
shared_scope.where(or: or_attributes)
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
attr_reader :left_scope, :right_scope
|
28
|
+
|
29
|
+
def shared_scope
|
30
|
+
@shared_scope ||= clean_scope.where(shared_attributes)
|
31
|
+
end
|
32
|
+
|
33
|
+
def clean_scope
|
34
|
+
left_scope.where_attributes.keys.reduce(left_scope) { |final, key| final.unscope(where: key) }
|
35
|
+
end
|
36
|
+
|
37
|
+
def shared_attributes
|
38
|
+
@shared_attributes ||= begin
|
39
|
+
left_attributes = left_scope.where_attributes
|
40
|
+
right_attributes = right_scope.where_attributes
|
41
|
+
left_attributes.select { |key, value| right_attributes.key?(key) && right_attributes[key] == value }
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def or_attributes
|
46
|
+
left_unique_attributes.merge(right_unique_attributes) { |_key, old_value, new_value| [*old_value, new_value] }
|
47
|
+
end
|
48
|
+
|
49
|
+
def left_unique_attributes
|
50
|
+
left_or_attributes = left_scope.where_attributes[:or] || {}
|
51
|
+
|
52
|
+
unique_attributes = left_scope.where_attributes.except(:or).select do |key, value|
|
53
|
+
!shared_attributes.key?(key) || shared_attributes[key] != value
|
54
|
+
end
|
55
|
+
|
56
|
+
left_or_attributes.merge(unique_attributes)
|
57
|
+
end
|
58
|
+
|
59
|
+
def right_unique_attributes
|
60
|
+
right_scope.where_attributes.select do |key, value|
|
61
|
+
!shared_attributes.key?(key) || shared_attributes[key] != value
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveGraphql
|
4
|
+
module Model
|
5
|
+
# stores all information for how to handle graphql requets for model
|
6
|
+
class Configuration
|
7
|
+
class Attribute
|
8
|
+
attr_reader :name, :nesting, :decorate_with
|
9
|
+
|
10
|
+
def initialize(name, nesting: nil, decorate_with: nil)
|
11
|
+
@name = name.to_sym
|
12
|
+
@nesting = nesting
|
13
|
+
@decorate_with = decorate_with
|
14
|
+
end
|
15
|
+
|
16
|
+
def to_graphql_output
|
17
|
+
nesting ? { name => nesting } : name
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def initialize
|
22
|
+
@attributes = []
|
23
|
+
@primary_key = :id
|
24
|
+
end
|
25
|
+
|
26
|
+
def initialize_copy(other)
|
27
|
+
super
|
28
|
+
@attributes = other.attributes.dup
|
29
|
+
@graphql_client = other.graphql_client
|
30
|
+
@url = other.url.dup
|
31
|
+
@resource_name = other.resource_name.dup
|
32
|
+
@resource_plural_name = other.resource_plural_name.dup
|
33
|
+
end
|
34
|
+
|
35
|
+
def graphql_client(client = nil)
|
36
|
+
@graphql_client = client if client
|
37
|
+
@graphql_client ||= ActiveGraphql::Client.new(url: url)
|
38
|
+
end
|
39
|
+
|
40
|
+
def formatter(new_formatter = nil, &block)
|
41
|
+
@formatter = new_formatter || block if new_formatter || block
|
42
|
+
@formatter ||= Model::ActionFormatter
|
43
|
+
end
|
44
|
+
|
45
|
+
def attributes(*list, **detailed_attributes)
|
46
|
+
list.each { |name| attribute(name) }
|
47
|
+
detailed_attributes.each { |key, val| attribute(key, val) }
|
48
|
+
@attributes
|
49
|
+
end
|
50
|
+
|
51
|
+
def attributes_graphql_output
|
52
|
+
attributes.map(&:to_graphql_output)
|
53
|
+
end
|
54
|
+
|
55
|
+
def attribute(name, nesting = nil, decorate_with: nil)
|
56
|
+
@attributes << Attribute.new(name, nesting: nesting, decorate_with: decorate_with)
|
57
|
+
end
|
58
|
+
|
59
|
+
def url(value = nil)
|
60
|
+
update_or_return_config(:url, value)
|
61
|
+
end
|
62
|
+
|
63
|
+
def resource_name(value = nil)
|
64
|
+
update_or_return_config(:resource_name, value)
|
65
|
+
end
|
66
|
+
|
67
|
+
def resource_plural_name(value = nil)
|
68
|
+
update_or_return_config(:resource_plural_name, value)
|
69
|
+
end
|
70
|
+
|
71
|
+
def primary_key(value = nil)
|
72
|
+
update_or_return_config(:primary_key, value&.to_sym)
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def update_or_return_config(name, value)
|
78
|
+
instance_variable_set("@#{name}", value) if value
|
79
|
+
instance_variable_get("@#{name}")
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveGraphql
|
4
|
+
module Model
|
5
|
+
# fetches graphql paginated records in batches
|
6
|
+
class FindInBatches
|
7
|
+
def self.call(*args, &block)
|
8
|
+
new(*args).call(&block)
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize(relation, batch_size: 100, fetched_items_count: 0)
|
12
|
+
@relation = relation
|
13
|
+
@batch_size = batch_size
|
14
|
+
@fetched_items_count = fetched_items_count
|
15
|
+
end
|
16
|
+
|
17
|
+
def call(&block)
|
18
|
+
scope = relation.limit(batch_size).offset(offset_size)
|
19
|
+
|
20
|
+
items = scope.first_batch
|
21
|
+
return nil if items.empty?
|
22
|
+
|
23
|
+
yield(items)
|
24
|
+
fetch_next_batch(items_count: items.count, &block) if scope.next_page?
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
attr_reader :relation, :fetched_items_count
|
30
|
+
|
31
|
+
def fetch_next_batch(items_count:, &block)
|
32
|
+
self.class.call(
|
33
|
+
relation,
|
34
|
+
batch_size: batch_size,
|
35
|
+
fetched_items_count: fetched_items_count + items_count,
|
36
|
+
&block
|
37
|
+
)
|
38
|
+
end
|
39
|
+
|
40
|
+
def offset_size
|
41
|
+
relation.send(:offset_number).to_i + fetched_items_count
|
42
|
+
end
|
43
|
+
|
44
|
+
def batch_size
|
45
|
+
items_to_fetch = collection_limit_number - fetched_items_count if collection_limit_number
|
46
|
+
[@batch_size, collection_limit_number, items_to_fetch].compact.min
|
47
|
+
end
|
48
|
+
|
49
|
+
def collection_limit_number
|
50
|
+
relation.send(:limit_number)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|