graphiti 2.0.0.beta.10 → 2.0.0.beta.12

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +6 -0
  3. data/.github/workflows/release.yml +2 -0
  4. data/.tool-versions +1 -1
  5. data/CHANGELOG.md +39 -0
  6. data/Rakefile +17 -12
  7. data/lib/generators/graphiti/generator_mixin.rb +5 -4
  8. data/lib/generators/graphiti/install_generator.rb +3 -0
  9. data/lib/generators/graphiti/locale_generator.rb +30 -0
  10. data/lib/generators/graphiti/templates/locale.yml.erb +22 -0
  11. data/lib/graphiti/adapters/abstract.rb +4 -3
  12. data/lib/graphiti/error_serializers/conflict_request.rb +1 -1
  13. data/lib/graphiti/error_serializers/deprecated_constants.rb +6 -16
  14. data/lib/graphiti/error_serializers/invalid_request.rb +6 -0
  15. data/lib/graphiti/errors.rb +4 -4
  16. data/lib/graphiti/query.rb +27 -0
  17. data/lib/graphiti/rails/exception_handlers.rb +17 -0
  18. data/lib/graphiti/rails/railtie.rb +10 -0
  19. data/lib/graphiti/rails/rake_helpers.rb +5 -0
  20. data/lib/graphiti/rails.rb +18 -0
  21. data/lib/graphiti/renderer.rb +4 -1
  22. data/lib/graphiti/request_validators/update_validator.rb +1 -5
  23. data/lib/graphiti/request_validators/validator.rb +6 -6
  24. data/lib/graphiti/resource/configuration.rb +11 -10
  25. data/lib/graphiti/resource/dsl.rb +5 -5
  26. data/lib/graphiti/resource/links.rb +3 -0
  27. data/lib/graphiti/schema/check.rb +69 -0
  28. data/lib/graphiti/schema.rb +8 -9
  29. data/lib/graphiti/scope.rb +51 -22
  30. data/lib/graphiti/scoping/filter.rb +2 -2
  31. data/lib/graphiti/serializer.rb +25 -9
  32. data/lib/graphiti/sideload/polymorphic_belongs_to.rb +9 -9
  33. data/lib/graphiti/sideload.rb +36 -25
  34. data/lib/graphiti/spec_helpers/matchers.rb +1 -1
  35. data/lib/graphiti/spec_helpers/rspec.rb +5 -13
  36. data/lib/graphiti/util/simple_errors.rb +26 -5
  37. data/lib/graphiti/version.rb +1 -1
  38. data/lib/graphiti.rb +1 -0
  39. data/lib/tasks/graphiti.rake +18 -0
  40. data/package.json +1 -1
  41. metadata +4 -1
@@ -0,0 +1,69 @@
1
+ module Graphiti
2
+ class Schema
3
+ class Check
4
+ attr_reader :path, :errors
5
+
6
+ def initialize(schema, path)
7
+ @path = path
8
+ @schema = schema
9
+ @generated = normalize(schema)
10
+ @committed = normalize(JSON.parse(File.read(path))) if File.exist?(path)
11
+ @errors = @committed ? SchemaDiff.new(@committed, @generated).compare : []
12
+ end
13
+
14
+ def missing?
15
+ @committed.nil?
16
+ end
17
+
18
+ def stale?
19
+ !missing? && @committed != @generated
20
+ end
21
+
22
+ def compatible?
23
+ errors.empty?
24
+ end
25
+
26
+ def ok?
27
+ !missing? && !stale? && compatible?
28
+ end
29
+
30
+ def write!
31
+ FileUtils.mkdir_p(File.dirname(path))
32
+ File.write(path, JSON.pretty_generate(@schema))
33
+ path
34
+ end
35
+
36
+ def message
37
+ return "Schema is up to date: #{path}" if ok?
38
+ return "#{missing_message}\n\n#{regenerate}" if missing?
39
+ return "#{incompatible_message}\n\n#{errors.join("\n")}" unless compatible?
40
+
41
+ "Schema file is outdated: #{path}\n\n#{regenerate}"
42
+ end
43
+
44
+ private
45
+
46
+ def normalize(schema)
47
+ JSON.parse(JSON.generate(schema))
48
+ end
49
+
50
+ def missing_message
51
+ "Schema file not found: #{path}"
52
+ end
53
+
54
+ def incompatible_message
55
+ <<~MSG.chomp
56
+ Found backwards-incompatibilities in schema: #{path}
57
+
58
+ Re-run with FORCE_SCHEMA=true to accept them and overwrite the file.
59
+
60
+ Incompatibilities:
61
+ MSG
62
+ end
63
+
64
+ def regenerate
65
+ "Run `rake graphiti:schema:generate` and commit the file."
66
+ end
67
+ end
68
+ end
69
+ end
@@ -11,19 +11,18 @@ module Graphiti
11
11
  new(resources).generate
12
12
  end
13
13
 
14
- def self.generate!(resources = nil)
15
- schema = generate(resources)
14
+ def self.generate!(resources = nil, path: Graphiti.config.schema_path, force: ENV["FORCE_SCHEMA"] == "true")
15
+ result = check(resources, path: path)
16
+ return result.errors if !force && !result.compatible?
16
17
 
17
- if ENV["FORCE_SCHEMA"] != "true" && File.exist?(Graphiti.config.schema_path)
18
- old = JSON.parse(File.read(Graphiti.config.schema_path))
19
- errors = Graphiti::SchemaDiff.new(old, schema).compare
20
- return errors if errors.any?
21
- end
22
- FileUtils.mkdir_p(Graphiti.config.schema_path.to_s.gsub("/schema.json", ""))
23
- File.write(Graphiti.config.schema_path, JSON.pretty_generate(schema))
18
+ result.write!
24
19
  []
25
20
  end
26
21
 
22
+ def self.check(resources = nil, path: Graphiti.config.schema_path)
23
+ Check.new(generate(resources), path)
24
+ end
25
+
27
26
  def initialize(resources)
28
27
  @resources = resources.sort_by(&:name)
29
28
  @remote_resources = @resources.select(&:remote?)
@@ -59,6 +59,9 @@ module Graphiti
59
59
  @query = query
60
60
  @opts = opts
61
61
 
62
+ @resolved_sideload_proxies = {}
63
+ @resolved_sideload_proxies_lock = Mutex.new
64
+
62
65
  @object = @resource.around_scoping(@object, @query.hash) { |scope|
63
66
  apply_scoping(scope, opts)
64
67
  }
@@ -67,7 +70,7 @@ module Graphiti
67
70
  def resolve(&blk)
68
71
  # The caller blocks on .value! either way, so concurrency only benefits parallel sideloads
69
72
  # See https://github.com/graphiti-api/graphiti/issues/505
70
- if self.class.resolve_synchronously? || !applicable_sideloads?
73
+ if self.class.resolve_synchronously? || !overlapping_sideloads?
71
74
  sync_resolve(&blk)
72
75
  else
73
76
  future_resolve(&blk).value!
@@ -140,9 +143,6 @@ module Graphiti
140
143
 
141
144
  private
142
145
 
143
- # Synchronous counterpart to #future_resolve, used when concurrency is off.
144
- # Resolves the resource and its sideloads inline without any promise
145
- # machinery. See #resolve.
146
146
  def sync_resolve(&blk)
147
147
  return [] if @query.zero_results?
148
148
 
@@ -151,14 +151,15 @@ module Graphiti
151
151
  resolved
152
152
  end
153
153
 
154
- # Synchronous counterpart to #future_resolve_sideloads, used when
155
- # concurrency is off. Resolves each sideload inline. See #resolve_sideloads.
156
154
  def sync_resolve_sideloads(results)
157
155
  return if results == []
158
156
 
159
- each_applicable_sideload do |sideload, sideload_query|
157
+ reset_captured_sideload_proxies
158
+ each_applicable_sideload do |name, sideload, sideload_query|
160
159
  Graphiti.config.before_sideload&.call(Graphiti.context)
161
- sideload.resolve(results, sideload_query, @resource)
160
+ sideload.sync_resolve(results, sideload_query, @resource) do |proxy|
161
+ capture_sideload_proxy(name, proxy)
162
+ end
162
163
  end
163
164
  end
164
165
 
@@ -176,7 +177,7 @@ module Graphiti
176
177
  assign_serializer(resolved)
177
178
  yield resolved if block_given?
178
179
  @opts[:after_resolve]&.call(resolved)
179
- resolved
180
+ @resolved_records = resolved
180
181
  end
181
182
 
182
183
  # Must run before sideloads assign, so every include path populates the
@@ -198,6 +199,8 @@ module Graphiti
198
199
 
199
200
  # A customized sideload can load a record another path would not, so it keeps its own instances.
200
201
  def deduplicable?
202
+ return false unless @query.repeated_resource_classes.include?(@resource.class)
203
+
201
204
  sideload = @opts[:sideload]
202
205
  return true unless sideload
203
206
 
@@ -207,9 +210,15 @@ module Graphiti
207
210
  sideload.primary_key == :id
208
211
  end
209
212
 
210
- # Nothing to post to the pool means the future would wrap work already done.
211
- def applicable_sideloads?
212
- each_applicable_sideload { return true }
213
+ # One sideload has nothing to run beside it, so the pool would hand the work
214
+ # to another thread and wait for it. A chain is one at every level. A
215
+ # polymorphic sideload counts as its children, which do run beside each other.
216
+ def overlapping_sideloads?
217
+ found = 0
218
+ each_applicable_sideload do |_, sideload, _|
219
+ found += sideload.respond_to?(:children) ? sideload.children.size : 1
220
+ return true if found > 1
221
+ end
213
222
  false
214
223
  end
215
224
 
@@ -218,18 +227,34 @@ module Graphiti
218
227
  sideload = @resource.class.sideload(name)
219
228
  next if sideload.nil? || sideload.shared_remote?
220
229
 
221
- yield sideload, sideload_query
230
+ yield name, sideload, sideload_query
231
+ end
232
+ end
233
+
234
+ # The write path resolves sideloads twice on one scope, which would double every proxy in the cache key.
235
+ def reset_captured_sideload_proxies
236
+ @resolved_sideload_proxies_lock.synchronize { @resolved_sideload_proxies.clear }
237
+ end
238
+
239
+ # A proxy built while resolving is already resolved all the way down.
240
+ def capture_sideload_proxy(name, proxy)
241
+ @resolved_sideload_proxies_lock.synchronize do
242
+ captured = (@resolved_sideload_proxies[name] ||= [])
243
+ captured << proxy unless proxy.nil? || proxy == []
222
244
  end
223
245
  end
224
246
 
225
247
  def future_resolve_sideloads(results)
226
248
  return Concurrent::Promises.fulfilled_future(nil, self.class.global_thread_pool_executor) if results == []
227
249
 
250
+ reset_captured_sideload_proxies
228
251
  sideload_promises = []
229
- each_applicable_sideload do |sideload, sideload_query|
252
+ each_applicable_sideload do |name, sideload, sideload_query|
230
253
  promise = future_with_context(results, sideload_query, @resource) do |parent_results, future_query, parent_resource|
231
254
  Graphiti.config.before_sideload&.call(Graphiti.context)
232
- sideload.future_resolve(parent_results, future_query, parent_resource)
255
+ sideload.future_resolve(parent_results, future_query, parent_resource) do |proxy|
256
+ capture_sideload_proxy(name, proxy)
257
+ end
233
258
  end
234
259
  sideload_promises << promise.flat
235
260
  end
@@ -361,15 +386,19 @@ module Graphiti
361
386
 
362
387
  def sideload_resource_proxies
363
388
  @sideload_resource_proxies ||= begin
364
- @object = @resource.before_resolve(@object, @query)
365
- results = @resource.resolve(@object)
389
+ # Reached after the response resolved, where resolving again re-applies before_resolve to a mutated scope.
390
+ results = @resolved_records
391
+ if results.nil?
392
+ @object = @resource.before_resolve(@object, @query)
393
+ results = @resource.resolve(@object)
394
+ end
366
395
 
367
396
  [].tap do |proxies|
368
- unless @query.sideloads.empty?
369
- @query.sideloads.each_pair do |name, q|
370
- sideload = @resource.class.sideload(name)
371
- next if sideload.nil? || sideload.shared_remote?
372
-
397
+ each_applicable_sideload do |name, sideload, q|
398
+ captured = @resolved_sideload_proxies[name]
399
+ if captured
400
+ proxies.concat(captured)
401
+ else
373
402
  proxies << sideload.build_resource_proxy(results, q, parent_resource)
374
403
  end
375
404
  end
@@ -209,7 +209,7 @@ module Graphiti
209
209
  end
210
210
 
211
211
  def parse_string_null(filter, value)
212
- return value unless filter[:blanks] == :as_nil
212
+ return value unless filter[:blanks] == :null
213
213
  return value.map { |item| (item == "null") ? nil : item } if value.is_a?(Array)
214
214
  return if value == "null"
215
215
 
@@ -217,7 +217,7 @@ module Graphiti
217
217
  end
218
218
 
219
219
  def check_blank_filters!(resource, filter, value)
220
- return unless filter.values[0][:blanks] == :reject
220
+ return unless filter.values[0][:blanks] == :rejected
221
221
 
222
222
  if value.nil? || value.empty? || value == "null"
223
223
  raise Errors::InvalidFilterValue.new(resource, filter, "(empty)")
@@ -1,7 +1,5 @@
1
1
  module Graphiti
2
2
  class Serializer < JSONAPI::Serializable::Resource
3
- UNREQUESTED_LINKS = [false, nil, "false"].freeze
4
-
5
3
  include Graphiti::Extensions::BooleanAttribute
6
4
  include Graphiti::Extensions::ExtraAttribute
7
5
  include Graphiti::SerializableHash
@@ -26,6 +24,16 @@ module Graphiti
26
24
  self.relationship_condition_blocks ||= {}
27
25
  self.relationship_sideloads ||= {}
28
26
 
27
+ # Keyed on the sideloads hash itself, which is reassigned whenever a
28
+ # relationship is applied, so the answer is recomputed exactly then.
29
+ def self.on_demand_links?
30
+ sideloads = relationship_sideloads
31
+ return @on_demand_links.last if @on_demand_links&.first.equal?(sideloads)
32
+
33
+ @on_demand_links = [sideloads, sideloads.each_value.any? { |sideload| sideload.link_mode == :on_demand }]
34
+ @on_demand_links.last
35
+ end
36
+
29
37
  def self.inherited(klass)
30
38
  super
31
39
  klass.class_eval do
@@ -76,7 +84,7 @@ module Graphiti
76
84
 
77
85
  def as_jsonapi(kwargs = {})
78
86
  super(**kwargs).tap do |hash|
79
- strip_relationships!(hash) if strip_relationships?
87
+ strip_relationships!(hash)
80
88
  add_links!(hash)
81
89
  end
82
90
  end
@@ -102,21 +110,29 @@ module Graphiti
102
110
  hash[:links] = @resource.links(@object) if @resource.links?
103
111
  end
104
112
 
105
- # A relationship whose only content would be an unrequested on-demand link
106
- # serializes as an empty stub, which JSON:API forbids.
113
+ # The meta: {included: false} stub is jsonapi-serializable's filler, not JSON:API.
107
114
  def strip_relationships!(hash)
115
+ placeholders = relationship_placeholders?
116
+ return if placeholders && !strip_on_demand_relationships?
117
+
108
118
  hash[:relationships]&.reject! do |name, payload|
109
119
  next false if payload.key?(:data) || payload.key?(:links)
120
+ next true unless placeholders
110
121
 
111
122
  self.class.relationship_sideloads[name]&.link_mode == :on_demand
112
123
  end
113
124
  end
114
125
 
115
- def strip_relationships?
116
- context = Graphiti.context[:object]
117
- params = context.params if context.respond_to?(:params)
126
+ # A remote resource exposes a stand-in object rather than a Resource.
127
+ def relationship_placeholders?
128
+ resource_class = @resource.class
129
+ return Resource.relationship_placeholders unless resource_class.respond_to?(:relationship_placeholders)
130
+
131
+ resource_class.relationship_placeholders
132
+ end
118
133
 
119
- UNREQUESTED_LINKS.include?(params && params[:links])
134
+ def strip_on_demand_relationships?
135
+ self.class.on_demand_links? && !@proxy&.query&.render_link?(:on_demand)
120
136
  end
121
137
  end
122
138
  end
@@ -107,18 +107,18 @@ class Graphiti::Sideload::PolymorphicBelongsTo < Graphiti::Sideload::BelongsTo
107
107
  end
108
108
  end
109
109
 
110
- def resolve(parents, query, graph_parent)
110
+ def resolve(parents, query, graph_parent, &proxy_block)
111
111
  if ::Graphiti::Scope.resolve_synchronously?
112
- sync_resolve(parents, query, graph_parent)
112
+ sync_resolve(parents, query, graph_parent, &proxy_block)
113
113
  else
114
- future_resolve(parents, query, graph_parent).value!
114
+ future_resolve(parents, query, graph_parent, &proxy_block).value!
115
115
  end
116
116
  end
117
117
 
118
- def future_resolve(parents, query, graph_parent)
118
+ def future_resolve(parents, query, graph_parent, &proxy_block)
119
119
  promises = []
120
120
  each_resolvable_group(parents, query) do |child, group, child_query|
121
- promises << child.future_resolve(group, child_query, graph_parent)
121
+ promises << child.future_resolve(group, child_query, graph_parent, &proxy_block)
122
122
  end
123
123
  return promises.first if promises.one?
124
124
 
@@ -130,14 +130,14 @@ class Graphiti::Sideload::PolymorphicBelongsTo < Graphiti::Sideload::BelongsTo
130
130
  end
131
131
  end
132
132
 
133
- private
134
-
135
- def sync_resolve(parents, query, graph_parent)
133
+ def sync_resolve(parents, query, graph_parent, &proxy_block)
136
134
  each_resolvable_group(parents, query) do |child, group, child_query|
137
- child.resolve(group, child_query, graph_parent)
135
+ child.sync_resolve(group, child_query, graph_parent, &proxy_block)
138
136
  end
139
137
  end
140
138
 
139
+ private
140
+
141
141
  # Group parents by their polymorphic type and yield each group's child
142
142
  # sideload alongside a query pruned to the sideloads that child supports.
143
143
  def each_resolvable_group(parents, query)
@@ -152,8 +152,12 @@ module Graphiti
152
152
  !!@polymorphic_as
153
153
  end
154
154
 
155
+ # Every check behind the blocker is static sideload configuration, and this
156
+ # is asked once per rendered record per relationship.
155
157
  def resource_ids_from_foreign_key?
156
- resource_ids_blocker.nil?
158
+ return @resource_ids_from_foreign_key unless @resource_ids_from_foreign_key.nil?
159
+
160
+ @resource_ids_from_foreign_key = resource_ids_blocker.nil?
157
161
  end
158
162
 
159
163
  def resource_ids_blocker
@@ -285,14 +289,20 @@ module Graphiti
285
289
  proxy
286
290
  end
287
291
 
288
- def load(parents, query, graph_parent)
292
+ def load(parents, query, graph_parent, &proxy_block)
289
293
  if Scope.resolve_synchronously?
290
- build_resource_proxy(parents, query, graph_parent).to_a
294
+ sync_load(parents, query, graph_parent, &proxy_block)
291
295
  else
292
- future_load(parents, query, graph_parent).value!
296
+ future_load(parents, query, graph_parent, &proxy_block).value!
293
297
  end
294
298
  end
295
299
 
300
+ def sync_load(parents, query, graph_parent, &proxy_block)
301
+ proxy = build_resource_proxy(parents, query, graph_parent)
302
+ proxy_block&.call(proxy)
303
+ proxy.to_a
304
+ end
305
+
296
306
  # Override in subclass
297
307
  def infer_foreign_key
298
308
  model = parent_resource_class.model
@@ -340,15 +350,30 @@ module Graphiti
340
350
  children.replace(associated) if track_associated
341
351
  end
342
352
 
343
- def resolve(parents, query, graph_parent)
353
+ def resolve(parents, query, graph_parent, &proxy_block)
344
354
  if Scope.resolve_synchronously?
345
- sync_resolve(parents, query, graph_parent)
355
+ sync_resolve(parents, query, graph_parent, &proxy_block)
356
+ else
357
+ future_resolve(parents, query, graph_parent, &proxy_block).value!
358
+ end
359
+ end
360
+
361
+ # Called by a scope that already decided to stay inline, so it must not consult the pool again.
362
+ # A scope_proc builds a Scope rather than a proxy, and that nested scope decides for itself.
363
+ def sync_resolve(parents, query, graph_parent, &proxy_block)
364
+ assert_singular!(parents)
365
+
366
+ if self.class.scope_proc
367
+ build_sideload_scope(parents, query, graph_parent).resolve do |sideload_results|
368
+ fire_assign(parents, sideload_results)
369
+ end
346
370
  else
347
- future_resolve(parents, query, graph_parent).value!
371
+ sync_load(parents, query, graph_parent, &proxy_block)
348
372
  end
349
373
  end
350
374
 
351
- def future_resolve(parents, query, graph_parent)
375
+ # A scope_proc builds a Scope rather than a proxy, and a Scope's cache key omits what a proxy's carries.
376
+ def future_resolve(parents, query, graph_parent, &proxy_block)
352
377
  assert_singular!(parents)
353
378
 
354
379
  if self.class.scope_proc
@@ -356,7 +381,7 @@ module Graphiti
356
381
  fire_assign(parents, sideload_results)
357
382
  end
358
383
  else
359
- future_load(parents, query, graph_parent)
384
+ future_load(parents, query, graph_parent, &proxy_block)
360
385
  end
361
386
  end
362
387
 
@@ -420,21 +445,6 @@ module Graphiti
420
445
 
421
446
  private
422
447
 
423
- # Synchronous counterpart to #future_resolve, used when concurrency is off.
424
- # Resolves inline via the synchronous Scope#resolve / #load paths (no
425
- # promises). See Scope#sync_resolve_sideloads.
426
- def sync_resolve(parents, query, graph_parent)
427
- assert_singular!(parents)
428
-
429
- if self.class.scope_proc
430
- build_sideload_scope(parents, query, graph_parent).resolve do |sideload_results|
431
- fire_assign(parents, sideload_results)
432
- end
433
- else
434
- load(parents, query, graph_parent)
435
- end
436
- end
437
-
438
448
  def assert_singular!(parents)
439
449
  if single? && parents.length > 1
440
450
  raise Errors::SingularSideload.new(self, parents.length)
@@ -451,8 +461,9 @@ module Graphiti
451
461
  default_paginate: false
452
462
  end
453
463
 
454
- def future_load(parents, query, graph_parent)
464
+ def future_load(parents, query, graph_parent, &proxy_block)
455
465
  proxy = build_resource_proxy(parents, query, graph_parent)
466
+ proxy_block&.call(proxy)
456
467
  proxy.respond_to?(:future_resolve_data) ? proxy.future_resolve_data : Concurrent::Promises.fulfilled_future(proxy)
457
468
  end
458
469
 
@@ -186,7 +186,7 @@ module Graphiti
186
186
  # @param [Symbol] type
187
187
  #
188
188
  # @example expect(subject).to filter_attribute(:name, :string)
189
- # @example expect(subject).to filter_attribute(:name, :string).with_options(blanks: :as_nil)
189
+ # @example expect(subject).to filter_attribute(:name, :string).with_options(blanks: :null)
190
190
  # @example expect(subject).not_to filter_attribute(:name, :string)
191
191
  def filter_attribute(attribute, type)
192
192
  FilterAttributeMatcher.new(attribute, type)
@@ -127,22 +127,14 @@ module Graphiti
127
127
  end
128
128
  end
129
129
 
130
- def self.schema!(resources = nil)
130
+ def self.schema!(resources = nil, path: nil)
131
131
  ::RSpec.describe "Graphiti Schema" do
132
132
  it "generates a backwards-compatible schema" do
133
- message = <<~MSG
134
- Found backwards-incompatibilities in schema! Run with FORCE_SCHEMA=true to ignore.
133
+ check = Graphiti::Schema.check(resources, path: path || Graphiti.config.schema_path)
134
+ forced = ENV["FORCE_SCHEMA"] == "true"
135
+ check.write! if check.compatible? || forced
135
136
 
136
- Incompatibilities:
137
-
138
- MSG
139
-
140
- errors = Graphiti::Schema.generate!(resources)
141
- errors.each do |e|
142
- message << "#{e}\n"
143
- end
144
-
145
- expect(errors.empty?).to eq(true), message
137
+ expect(check.compatible? || forced).to eq(true), check.message
146
138
  end
147
139
  end
148
140
  end
@@ -6,6 +6,21 @@ module Graphiti
6
6
  class SimpleErrors
7
7
  include Enumerable
8
8
 
9
+ # Overridable under graphiti.errors.messages.
10
+ DEFAULT_MESSAGES = {
11
+ missing: "is missing",
12
+ invalid: "must be an object",
13
+ invalid_relationship: "is not a valid relationship",
14
+ unwritable_relationship: "cannot be written",
15
+ unknown_attribute: "is an unknown attribute",
16
+ unwritable_attribute: "cannot be written",
17
+ type_error: "should be type %{type}",
18
+ attribute_mismatch: "does not match the server endpoint"
19
+ }.freeze
20
+
21
+ # Joins an attribute to its message, like Rails' own errors.format.
22
+ DEFAULT_FORMAT = "%{attribute} %{message}"
23
+
9
24
  attr_reader :messages, :details
10
25
 
11
26
  def initialize(validation_target)
@@ -50,11 +65,9 @@ module Graphiti
50
65
  end
51
66
  alias_method :blank?, :empty?
52
67
 
53
- def add(attribute, code, message: nil)
54
- message ||= "is #{code.to_s.humanize.downcase}"
55
-
68
+ def add(attribute, code, message: nil, **interpolations)
56
69
  details[attribute.to_sym] << {error: code}
57
- messages[attribute.to_sym] << message
70
+ messages[attribute.to_sym] << translate(code, message, **interpolations, attribute: attribute)
58
71
  end
59
72
 
60
73
  def added?(attribute, code)
@@ -73,11 +86,19 @@ module Graphiti
73
86
 
74
87
  def full_message(attribute, message)
75
88
  return message if attribute == :base
76
- "#{attribute} #{message}"
89
+
90
+ translate(:format, DEFAULT_FORMAT, [:graphiti, :errors], attribute: attribute, message: message)
77
91
  end
78
92
 
79
93
  private
80
94
 
95
+ def translate(key, fallback, scope = [:graphiti, :errors, :messages], **interpolations)
96
+ fallback ||= DEFAULT_MESSAGES.fetch(key) { "is #{key.to_s.humanize.downcase}" }
97
+ return fallback % interpolations unless defined?(::I18n)
98
+
99
+ ::I18n.t(key, scope: scope, default: fallback, **interpolations)
100
+ end
101
+
81
102
  def apply_default_array(hash)
82
103
  hash.default_proc = proc { |h, key| h[key] = [] }
83
104
  hash
@@ -1,3 +1,3 @@
1
1
  module Graphiti
2
- VERSION = "2.0.0.beta.10"
2
+ VERSION = "2.0.0.beta.12"
3
3
  end
data/lib/graphiti.rb CHANGED
@@ -175,6 +175,7 @@ require "graphiti/audit"
175
175
  require "graphiti/audit/report"
176
176
  require "graphiti/schema"
177
177
  require "graphiti/schema_diff"
178
+ require "graphiti/schema/check"
178
179
  require "graphiti/adapters/abstract"
179
180
  require "graphiti/resource/sideloading"
180
181
  require "graphiti/resource/links"
@@ -16,6 +16,24 @@ namespace :graphiti do
16
16
  Graphiti::Debugger.flush if debug
17
17
  end
18
18
 
19
+ namespace :schema do
20
+ desc "Write the schema file. Refuses backwards-incompatible changes unless FORCE_SCHEMA=true. Takes an optional path, defaulting to Graphiti.config.schema_path."
21
+ task :generate, [:path] => [:environment] do |_, args|
22
+ check = Graphiti::Schema.check(path: helpers.schema_path(args[:path]))
23
+ abort check.message unless check.compatible? || ENV["FORCE_SCHEMA"] == "true"
24
+
25
+ puts "Schema written: #{check.write!}"
26
+ end
27
+
28
+ desc "Fail unless the committed schema file exists, is up to date, and is backwards-compatible. Takes an optional path, defaulting to Graphiti.config.schema_path."
29
+ task :check, [:path] => [:environment] do |_, args|
30
+ check = Graphiti::Schema.check(path: helpers.schema_path(args[:path]))
31
+ abort check.message unless check.ok?
32
+
33
+ puts check.message
34
+ end
35
+ end
36
+
19
37
  desc "Audit every relationship: what will raise, what loads to render ids, which render no ids, and which checks passed."
20
38
  task audit: [:environment] do
21
39
  helpers.setup_rails!
data/package.json CHANGED
@@ -142,7 +142,7 @@
142
142
  [
143
143
  "@semantic-release/exec",
144
144
  {
145
- "prepareCmd": "bundle exec ruby spec/performance/measure_releases.rb --pending v${nextRelease.version}"
145
+ "prepareCmd": "bundle exec ruby spec/performance/measure_releases.rb --promote v${nextRelease.version}"
146
146
  }
147
147
  ],
148
148
  [
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphiti
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0.beta.10
4
+ version: 2.0.0.beta.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lee Richmond
@@ -287,6 +287,7 @@ files:
287
287
  - lib/generators/graphiti/api_test_generator.rb
288
288
  - lib/generators/graphiti/generator_mixin.rb
289
289
  - lib/generators/graphiti/install_generator.rb
290
+ - lib/generators/graphiti/locale_generator.rb
290
291
  - lib/generators/graphiti/resource_generator.rb
291
292
  - lib/generators/graphiti/resource_test_generator.rb
292
293
  - lib/generators/graphiti/templates/application_resource.rb.erb
@@ -294,6 +295,7 @@ files:
294
295
  - lib/generators/graphiti/templates/create_request_spec.rb.erb
295
296
  - lib/generators/graphiti/templates/destroy_request_spec.rb.erb
296
297
  - lib/generators/graphiti/templates/index_request_spec.rb.erb
298
+ - lib/generators/graphiti/templates/locale.yml.erb
297
299
  - lib/generators/graphiti/templates/resource.rb.erb
298
300
  - lib/generators/graphiti/templates/resource_reads_spec.rb.erb
299
301
  - lib/generators/graphiti/templates/resource_writes_spec.rb.erb
@@ -359,6 +361,7 @@ files:
359
361
  - lib/graphiti/responders.rb
360
362
  - lib/graphiti/runner.rb
361
363
  - lib/graphiti/schema.rb
364
+ - lib/graphiti/schema/check.rb
362
365
  - lib/graphiti/schema_diff.rb
363
366
  - lib/graphiti/scope.rb
364
367
  - lib/graphiti/scoping/base.rb