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.
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