active_graphql 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +15 -0
  3. data/.hound.yml +4 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +48 -0
  6. data/.ruby-version +1 -0
  7. data/.travis.yml +7 -0
  8. data/CHANGELOG.md +25 -0
  9. data/CODE_OF_CONDUCT.md +74 -0
  10. data/Gemfile +15 -0
  11. data/Gemfile.lock +134 -0
  12. data/LICENSE.txt +21 -0
  13. data/Rakefile +6 -0
  14. data/active_graphql.gemspec +49 -0
  15. data/bin/console +14 -0
  16. data/bin/setup +8 -0
  17. data/docs/.nojekyll +0 -0
  18. data/docs/README.md +95 -0
  19. data/docs/_sidebar.md +4 -0
  20. data/docs/client.md +69 -0
  21. data/docs/index.html +70 -0
  22. data/docs/model.md +464 -0
  23. data/lib/active_graphql.rb +10 -0
  24. data/lib/active_graphql/client.rb +38 -0
  25. data/lib/active_graphql/client/actions.rb +15 -0
  26. data/lib/active_graphql/client/actions/action.rb +116 -0
  27. data/lib/active_graphql/client/actions/action/format_inputs.rb +80 -0
  28. data/lib/active_graphql/client/actions/action/format_outputs.rb +40 -0
  29. data/lib/active_graphql/client/actions/mutation_action.rb +29 -0
  30. data/lib/active_graphql/client/actions/query_action.rb +23 -0
  31. data/lib/active_graphql/client/adapters.rb +10 -0
  32. data/lib/active_graphql/client/adapters/graphlient_adapter.rb +32 -0
  33. data/lib/active_graphql/client/response.rb +47 -0
  34. data/lib/active_graphql/errors.rb +11 -0
  35. data/lib/active_graphql/model.rb +174 -0
  36. data/lib/active_graphql/model/action_formatter.rb +96 -0
  37. data/lib/active_graphql/model/build_or_relation.rb +66 -0
  38. data/lib/active_graphql/model/configuration.rb +83 -0
  39. data/lib/active_graphql/model/find_in_batches.rb +54 -0
  40. data/lib/active_graphql/model/relation_proxy.rb +321 -0
  41. data/lib/active_graphql/version.rb +5 -0
  42. 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