graphiti 1.0.alpha.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (94) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +11 -0
  3. data/.rspec +3 -0
  4. data/.ruby-version +1 -0
  5. data/.travis.yml +20 -0
  6. data/.yardopts +2 -0
  7. data/Appraisals +11 -0
  8. data/CODE_OF_CONDUCT.md +49 -0
  9. data/Gemfile +12 -0
  10. data/Guardfile +32 -0
  11. data/LICENSE.txt +21 -0
  12. data/README.md +75 -0
  13. data/Rakefile +15 -0
  14. data/bin/appraisal +17 -0
  15. data/bin/console +14 -0
  16. data/bin/rspec +17 -0
  17. data/bin/setup +8 -0
  18. data/gemfiles/rails_4.gemfile +17 -0
  19. data/gemfiles/rails_5.gemfile +17 -0
  20. data/graphiti.gemspec +34 -0
  21. data/lib/generators/jsonapi/resource_generator.rb +169 -0
  22. data/lib/generators/jsonapi/templates/application_resource.rb.erb +15 -0
  23. data/lib/generators/jsonapi/templates/controller.rb.erb +61 -0
  24. data/lib/generators/jsonapi/templates/create_request_spec.rb.erb +30 -0
  25. data/lib/generators/jsonapi/templates/destroy_request_spec.rb.erb +20 -0
  26. data/lib/generators/jsonapi/templates/index_request_spec.rb.erb +22 -0
  27. data/lib/generators/jsonapi/templates/resource.rb.erb +11 -0
  28. data/lib/generators/jsonapi/templates/resource_reads_spec.rb.erb +62 -0
  29. data/lib/generators/jsonapi/templates/resource_writes_spec.rb.erb +63 -0
  30. data/lib/generators/jsonapi/templates/show_request_spec.rb.erb +21 -0
  31. data/lib/generators/jsonapi/templates/update_request_spec.rb.erb +34 -0
  32. data/lib/graphiti.rb +121 -0
  33. data/lib/graphiti/adapters/abstract.rb +516 -0
  34. data/lib/graphiti/adapters/active_record.rb +6 -0
  35. data/lib/graphiti/adapters/active_record/base.rb +249 -0
  36. data/lib/graphiti/adapters/active_record/belongs_to_sideload.rb +17 -0
  37. data/lib/graphiti/adapters/active_record/has_many_sideload.rb +17 -0
  38. data/lib/graphiti/adapters/active_record/has_one_sideload.rb +17 -0
  39. data/lib/graphiti/adapters/active_record/inferrence.rb +12 -0
  40. data/lib/graphiti/adapters/active_record/many_to_many_sideload.rb +30 -0
  41. data/lib/graphiti/adapters/null.rb +236 -0
  42. data/lib/graphiti/base.rb +70 -0
  43. data/lib/graphiti/configuration.rb +21 -0
  44. data/lib/graphiti/context.rb +16 -0
  45. data/lib/graphiti/deserializer.rb +208 -0
  46. data/lib/graphiti/errors.rb +309 -0
  47. data/lib/graphiti/extensions/boolean_attribute.rb +33 -0
  48. data/lib/graphiti/extensions/extra_attribute.rb +70 -0
  49. data/lib/graphiti/extensions/temp_id.rb +26 -0
  50. data/lib/graphiti/filter_operators.rb +25 -0
  51. data/lib/graphiti/hash_renderer.rb +57 -0
  52. data/lib/graphiti/jsonapi_serializable_ext.rb +50 -0
  53. data/lib/graphiti/query.rb +251 -0
  54. data/lib/graphiti/rails.rb +28 -0
  55. data/lib/graphiti/railtie.rb +74 -0
  56. data/lib/graphiti/renderer.rb +60 -0
  57. data/lib/graphiti/resource.rb +110 -0
  58. data/lib/graphiti/resource/configuration.rb +239 -0
  59. data/lib/graphiti/resource/dsl.rb +138 -0
  60. data/lib/graphiti/resource/interface.rb +32 -0
  61. data/lib/graphiti/resource/polymorphism.rb +68 -0
  62. data/lib/graphiti/resource/sideloading.rb +102 -0
  63. data/lib/graphiti/resource_proxy.rb +127 -0
  64. data/lib/graphiti/responders.rb +19 -0
  65. data/lib/graphiti/runner.rb +25 -0
  66. data/lib/graphiti/scope.rb +98 -0
  67. data/lib/graphiti/scoping/base.rb +99 -0
  68. data/lib/graphiti/scoping/default_filter.rb +58 -0
  69. data/lib/graphiti/scoping/extra_attributes.rb +29 -0
  70. data/lib/graphiti/scoping/filter.rb +93 -0
  71. data/lib/graphiti/scoping/filterable.rb +36 -0
  72. data/lib/graphiti/scoping/paginate.rb +87 -0
  73. data/lib/graphiti/scoping/sort.rb +64 -0
  74. data/lib/graphiti/sideload.rb +281 -0
  75. data/lib/graphiti/sideload/belongs_to.rb +34 -0
  76. data/lib/graphiti/sideload/has_many.rb +16 -0
  77. data/lib/graphiti/sideload/has_one.rb +9 -0
  78. data/lib/graphiti/sideload/many_to_many.rb +24 -0
  79. data/lib/graphiti/sideload/polymorphic_belongs_to.rb +108 -0
  80. data/lib/graphiti/stats/dsl.rb +89 -0
  81. data/lib/graphiti/stats/payload.rb +49 -0
  82. data/lib/graphiti/types.rb +172 -0
  83. data/lib/graphiti/util/attribute_check.rb +88 -0
  84. data/lib/graphiti/util/field_params.rb +16 -0
  85. data/lib/graphiti/util/hash.rb +51 -0
  86. data/lib/graphiti/util/hooks.rb +33 -0
  87. data/lib/graphiti/util/include_params.rb +39 -0
  88. data/lib/graphiti/util/persistence.rb +219 -0
  89. data/lib/graphiti/util/relationship_payload.rb +64 -0
  90. data/lib/graphiti/util/serializer_attributes.rb +97 -0
  91. data/lib/graphiti/util/sideload.rb +33 -0
  92. data/lib/graphiti/util/validation_response.rb +78 -0
  93. data/lib/graphiti/version.rb +3 -0
  94. metadata +316 -0
@@ -0,0 +1,16 @@
1
+ class Graphiti::Sideload::HasMany < Graphiti::Sideload
2
+ def type
3
+ :has_many
4
+ end
5
+
6
+ def load_params(parents, query)
7
+ query.to_hash.tap do |hash|
8
+ hash[:filter] ||= {}
9
+ hash[:filter][foreign_key] = ids_for_parents(parents)
10
+ end
11
+ end
12
+
13
+ def assign_each(parent, children)
14
+ children.select { |c| c.send(foreign_key) == parent.send(primary_key) }
15
+ end
16
+ end
@@ -0,0 +1,9 @@
1
+ class Graphiti::Sideload::HasOne < Graphiti::Sideload::HasMany
2
+ def type
3
+ :has_one
4
+ end
5
+
6
+ def assign_each(parent, children)
7
+ children.find { |c| c.send(foreign_key) == parent.send(primary_key) }
8
+ end
9
+ end
@@ -0,0 +1,24 @@
1
+ class Graphiti::Sideload::ManyToMany < Graphiti::Sideload
2
+ def type
3
+ :many_to_many
4
+ end
5
+
6
+ def through
7
+ foreign_key.keys.first
8
+ end
9
+
10
+ def true_foreign_key
11
+ foreign_key.values.first
12
+ end
13
+
14
+ def infer_foreign_key
15
+ raise 'You must explicitly pass :foreign_key for many-to-many relaitonships, or override in subclass to return a hash.'
16
+ end
17
+
18
+ def assign_each(parent, children)
19
+ children.select do |c|
20
+ match = ->(ct) { ct.send(true_foreign_key) == parent.send(primary_key) }
21
+ c.send(through).any?(&match)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,108 @@
1
+ class Graphiti::Sideload::PolymorphicBelongsTo < Graphiti::Sideload::BelongsTo
2
+ class Group
3
+ attr_reader :name, :calls
4
+
5
+ def initialize(name)
6
+ @name = name
7
+ @calls = []
8
+ end
9
+
10
+ def method_missing(name, *args, &blk)
11
+ @calls << [name, args, blk]
12
+ end
13
+ end
14
+
15
+ class Grouper
16
+ attr_reader :field_name
17
+
18
+ def initialize(field_name)
19
+ @field_name = field_name
20
+ @groups = []
21
+ end
22
+
23
+ def on(name, &blk)
24
+ group = Group.new(name)
25
+ @groups << group
26
+ group.belongs_to(name.to_s.underscore.to_sym)
27
+ group
28
+ end
29
+
30
+ def apply(sideload, resource_class)
31
+ @groups.each do |group|
32
+ group.calls.each do |call|
33
+ args = call[1]
34
+ opts = args.extract_options!
35
+ opts.merge! as: sideload.name,
36
+ parent: sideload,
37
+ group_name: group.name,
38
+ polymorphic_child: true
39
+ if !sideload.resource.class.abstract_class?
40
+ opts[:foreign_key] ||= sideload.foreign_key
41
+ opts[:primary_key] ||= sideload.primary_key
42
+ end
43
+ args << opts
44
+ resource_class.send(call[0], *args, &call[2])
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ class_attribute :grouper
51
+ attr_accessor :children
52
+ self.grouper = Grouper.new(:default)
53
+
54
+ def type
55
+ :polymorphic_belongs_to
56
+ end
57
+
58
+ def infer_foreign_key
59
+ :"#{name}_id"
60
+ end
61
+
62
+ def self.group_by(name, &blk)
63
+ self.grouper = Grouper.new(name)
64
+ self.grouper.instance_eval(&blk)
65
+ end
66
+
67
+ def initialize(name, opts)
68
+ super
69
+ self.children = {}
70
+ grouper.apply(self, parent_resource_class)
71
+ end
72
+
73
+ def child_for_type(type)
74
+ children.values.find do |sideload|
75
+ sideload.resource.type == type
76
+ end
77
+ end
78
+
79
+ def resolve(parents, query)
80
+ parents.group_by(&grouper.field_name).each_pair do |group_name, group|
81
+ next if group_name.nil?
82
+
83
+ match = ->(name, sl) { sl.group_name == group_name.to_sym }
84
+ if child = children.find(&match)
85
+ sideload = child[1]
86
+ query = remove_invalid_sideloads(sideload.resource, query)
87
+ sideload.resolve(group, query)
88
+ else
89
+ err = ::Graphiti::Errors::PolymorphicChildNotFound
90
+ raise err.new(self, group_name)
91
+ end
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ # We may be requesting a relationship that some subclasses support,
98
+ # but not others. Remove anything we don't support.
99
+ def remove_invalid_sideloads(resource, query)
100
+ query = query.dup
101
+ query.sideloads.each_pair do |key, value|
102
+ unless resource.class.sideload(key)
103
+ query.sideloads.delete(key)
104
+ end
105
+ end
106
+ query
107
+ end
108
+ end
@@ -0,0 +1,89 @@
1
+ module Graphiti
2
+ module Stats
3
+ # Provides an easier interface to stats scoping.
4
+ #
5
+ # Used within Resource DSL:
6
+ #
7
+ # allow_stat total: [:count] do
8
+ # # ... eval'd in Stats::DSL context! ...
9
+ # end
10
+ #
11
+ # This allows us to define arbitrary stats:
12
+ #
13
+ # allow_stat total: [:count] do
14
+ # standard_deviation { |scope, attr| ... }
15
+ # end
16
+ #
17
+ # And use convenience methods:
18
+ #
19
+ # allow_stat :rating do
20
+ # count!
21
+ # average!
22
+ # end
23
+ #
24
+ # @see Resource.allow_stat
25
+ # @attr_reader [Symbol] name the stat, e.g. :total
26
+ # @attr_reader [Hash] calculations procs for various metrics
27
+ class DSL
28
+ attr_reader :name, :calculations
29
+
30
+ # @param [Adapters::Abstract] adapter the Resource adapter
31
+ # @param [Symbol, Hash] config example: +:total+ or +{ total: [:count] }+
32
+ def initialize(adapter, config)
33
+ config = { config => [] } if config.is_a?(Symbol)
34
+
35
+ @adapter = adapter
36
+ @calculations = {}
37
+ @name = config.keys.first
38
+ Array(config.values.first).each { |c| send(:"#{c}!") }
39
+ end
40
+
41
+ # Used for defining arbitrary stats within the DSL:
42
+ #
43
+ # allow_stat :total do
44
+ # standard_deviation { |scope, attr| ... }
45
+ # end
46
+ #
47
+ # ...will hit +method_missing+ and store the proc for future reference.
48
+ # @api private
49
+ def method_missing(meth, *args, &blk)
50
+ @calculations[meth] = blk
51
+ end
52
+
53
+ # Grab a calculation proc. Raises error if no corresponding stat
54
+ # has been configured.
55
+ #
56
+ # @param [String, Symbol] name the name of the calculation, e.g. +:total+
57
+ # @return [Proc] the proc to run the calculation
58
+ def calculation(name)
59
+ callable = @calculations[name] || @calculations[name.to_sym]
60
+ callable || raise(Errors::StatNotFound.new(@name, name))
61
+ end
62
+
63
+ # Convenience method for default :count proc
64
+ def count!
65
+ @calculations[:count] = @adapter.method(:count)
66
+ end
67
+
68
+ # Convenience method for default :sum proc
69
+ def sum!
70
+ @calculations[:sum] = @adapter.method(:sum)
71
+ end
72
+
73
+ # Convenience method for default :average proc
74
+ def average!
75
+ @calculations[:average] = @adapter.method(:average)
76
+ end
77
+
78
+ # Convenience method for default :maximum proc
79
+ def maximum!
80
+ @calculations[:maximum] = @adapter.method(:maximum)
81
+ end
82
+
83
+ # Convenience method for default :minimum proc
84
+ def minimum!
85
+ @calculations[:minimum] = @adapter.method(:minimum)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,49 @@
1
+ module Graphiti
2
+ module Stats
3
+ # Generate the stats payload so we can return it in the response.
4
+ #
5
+ # {
6
+ # data: [...],
7
+ # meta: { stats: the_generated_payload }
8
+ # }
9
+ #
10
+ # For example:
11
+ #
12
+ # {
13
+ # data: [...],
14
+ # meta: { stats: { total: { count: 100 } } }
15
+ # }
16
+ class Payload
17
+ def initialize(resource, query, scope)
18
+ @resource = resource
19
+ @query = query
20
+ @scope = scope
21
+ end
22
+
23
+ # Generate the payload for +{ meta: { stats: { ... } } }+
24
+ # Loops over all calculations, computes then, and gives back
25
+ # a hash of stats and their results.
26
+ # @return [Hash] the generated payload
27
+ def generate
28
+ {}.tap do |stats|
29
+ @query.stats.each_pair do |name, calculation|
30
+ stats[name] = {}
31
+
32
+ each_calculation(name, calculation) do |calc, function|
33
+ stats[name][calc] = function.call(@scope, name)
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def each_calculation(name, calculations)
42
+ calculations.each do |calc|
43
+ function = @resource.stat(name, calc)
44
+ yield calc, function
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,172 @@
1
+ module Graphiti
2
+ class Types
3
+ def self.create(primitive, &blk)
4
+ definition = Dry::Types::Definition.new(primitive)
5
+ definition.constructor(&blk)
6
+ end
7
+
8
+ WriteDateTime = create(::DateTime) do |input|
9
+ if input.is_a?(::Date) || input.is_a?(::Time)
10
+ input = ::DateTime.parse(input.to_s)
11
+ end
12
+ input = Dry::Types['json.date_time'][input]
13
+ Dry::Types['strict.date_time'][input] if input
14
+ end
15
+
16
+ ReadDateTime = create(::DateTime) do |input|
17
+ if input.is_a?(::Date) || input.is_a?(::Time)
18
+ input = ::DateTime.parse(input.to_s)
19
+ end
20
+ input = Dry::Types['json.date_time'][input]
21
+ Dry::Types['strict.date_time'][input].iso8601 if input
22
+ end
23
+
24
+ PresentParamsDateTime = create(::DateTime) do |input|
25
+ input = Dry::Types['params.date_time'][input]
26
+ Dry::Types['strict.date_time'][input]
27
+ end
28
+
29
+ Date = create(::Date) do |input|
30
+ input = ::Date.parse(input.to_s) if input.is_a?(::Time)
31
+ input = Dry::Types['json.date'][input]
32
+ Dry::Types['strict.date'][input] if input
33
+ end
34
+
35
+ PresentDate = create(::Date) do |input|
36
+ input = ::Date.parse(input.to_s) if input.is_a?(::Time)
37
+ input = Dry::Types['json.date'][input]
38
+ Dry::Types['strict.date'][input]
39
+ end
40
+
41
+ Bool = create(nil) do |input|
42
+ input = Dry::Types['params.bool'][input]
43
+ Dry::Types['strict.bool'][input] if input
44
+ end
45
+
46
+ PresentBool = create(nil) do |input|
47
+ input = Dry::Types['params.bool'][input]
48
+ Dry::Types['strict.bool'][input]
49
+ end
50
+
51
+ Integer = create(::Integer) do |input|
52
+ Dry::Types['coercible.integer'][input] if input
53
+ end
54
+
55
+ # The Float() check here is to ensure we have a number
56
+ # Otherwise BigDecimal('foo') *will return a decimal*
57
+ ParamDecimal = create(::BigDecimal) do |input|
58
+ Float(input)
59
+ input = Dry::Types['coercible.decimal'][input]
60
+ Dry::Types['strict.decimal'][input]
61
+ end
62
+
63
+ PresentInteger = create(::Integer) do |input|
64
+ Dry::Types['coercible.integer'][input]
65
+ end
66
+
67
+ Float = create(::Float) do |input|
68
+ Dry::Types['coercible.float'][input] if input
69
+ end
70
+
71
+ PresentParamsHash = create(::Hash) do |input|
72
+ Dry::Types['params.hash'][input].deep_symbolize_keys
73
+ end
74
+
75
+ def self.map
76
+ @map ||= begin
77
+ hash = {
78
+ integer_id: {
79
+ canonical_name: :integer,
80
+ params: Dry::Types['coercible.integer'],
81
+ read: Dry::Types['coercible.string'],
82
+ write: Dry::Types['coercible.integer']
83
+ },
84
+ string: {
85
+ params: Dry::Types['coercible.string'],
86
+ read: Dry::Types['coercible.string'],
87
+ write: Dry::Types['coercible.string']
88
+ },
89
+ integer: {
90
+ params: PresentInteger,
91
+ read: Integer,
92
+ write: Integer
93
+ },
94
+ big_decimal: {
95
+ params: ParamDecimal,
96
+ read: Dry::Types['json.decimal'],
97
+ write: Dry::Types['json.decimal']
98
+ },
99
+ float: {
100
+ params: Dry::Types['coercible.float'],
101
+ read: Float,
102
+ write: Float
103
+ },
104
+ boolean: {
105
+ params: PresentBool,
106
+ read: Bool,
107
+ write: Bool
108
+ },
109
+ date: {
110
+ params: PresentDate,
111
+ read: Date,
112
+ write: Date
113
+ },
114
+ datetime: {
115
+ params: PresentParamsDateTime,
116
+ read: ReadDateTime,
117
+ write: WriteDateTime
118
+ },
119
+ hash: {
120
+ params: PresentParamsHash,
121
+ read: Dry::Types['strict.hash'],
122
+ write: Dry::Types['strict.hash']
123
+ },
124
+ array: {
125
+ params: Dry::Types['strict.array'],
126
+ read: Dry::Types['strict.array'],
127
+ write: Dry::Types['strict.array']
128
+ }
129
+ }
130
+
131
+ hash.each_pair do |k, v|
132
+ hash[k][:canonical_name] ||= k
133
+ end
134
+
135
+ arrays = {}
136
+ hash.each_pair do |name, map|
137
+ arrays[:"array_of_#{name.to_s.pluralize}"] = {
138
+ canonical_name: name,
139
+ params: Dry::Types['strict.array'].of(map[:params]),
140
+ read: Dry::Types['strict.array'].of(map[:read]),
141
+ test: Dry::Types['strict.array'].of(map[:test]),
142
+ write: Dry::Types['strict.array'].of(map[:write])
143
+ }
144
+ end
145
+ hash.merge!(arrays)
146
+
147
+ hash
148
+ end
149
+ end
150
+
151
+ def self.[](key)
152
+ map[key.to_sym]
153
+ end
154
+
155
+ def self.[]=(key, value)
156
+ unless value.is_a?(Hash)
157
+ value = {
158
+ read: value,
159
+ params: value,
160
+ test: value
161
+ }
162
+ end
163
+ map[key.to_sym] = value
164
+ end
165
+
166
+ def self.name_for(key)
167
+ key = key.to_sym
168
+ type = map[key]
169
+ type[:canonical_name]
170
+ end
171
+ end
172
+ end