active_graphql 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|