graphiti 2.0.0.beta.12 → 2.0.0.beta.13

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e73d0272cfabe57d3eb0cbc82ad7199dea624afead6856ca72c1107857fa144
4
- data.tar.gz: 795ddb1e77c8f61d2e74c66c333ccc4acd5c37ccb6aa3b4c7cb816f52dfe1b71
3
+ metadata.gz: 2963ad9851cf238b3885921c2b4858bf3b7aefe1fad5726a588c966da8cd0ada
4
+ data.tar.gz: 6486ac49fff4aa22faaa46cc2aef5439a015943748fd323288c8f90f596fbf9a
5
5
  SHA512:
6
- metadata.gz: f5ff1c0630702209372d0512afd16d29c87d6f561c551e08a5080bd25efa8b3bae465d33729129289a3ade41db781b779bffd753b55f6cf7cf02389662fa835e
7
- data.tar.gz: '0785ff7bd32570ef7bf588c7287b4704d7f9daf50bafb47f0739f02b054086a297cef4427364f818b5ab540a72935447bead5898957f597a400a40b435f4a81f'
6
+ metadata.gz: b25b468d3ddf0efc6aa513940ae06c2cc30150e7d4a743bd5ba1d4a56a768f3a7452780b515bbd68610ab51bdd979d577f7644d83a0fa724e975a45a547e1bef
7
+ data.tar.gz: 02a6b4f82e8e7c2d9582c8765d49ef07901e001ea942846d3b70f79ea25c7e83d4d032c5644404420c075e0565eb946cfb1d54356ed319aefb2841e644bb0944
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  graphiti changelog
2
2
 
3
+ # [2.0.0-beta.13](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.12...v2.0.0-beta.13) (2026-08-31)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * compare the endpoint id to the payload id as strings ([62051d7](https://github.com/graphiti-api/graphiti/commit/62051d7531432357db7bc464cce77ccc55168d07))
9
+ * evaluate writable guards under the action being performed ([d56b86a](https://github.com/graphiti-api/graphiti/commit/d56b86a5ffaac4106aaea1e2ee5400069a4205fe))
10
+ * **generators:** stop injecting a routes host into config/application.rb ([db92d06](https://github.com/graphiti-api/graphiti/commit/db92d0649ca0246662a86b1172229eaa2a817d2b))
11
+ * keep a belongs_to linkage when another include shares its name ([2c9c7c7](https://github.com/graphiti-api/graphiti/commit/2c9c7c7ac06cd319ac95e1445eb55a0322841de2))
12
+ * keep deep filters narrow when an include path repeats ([833a10b](https://github.com/graphiti-api/graphiti/commit/833a10be120a4f2cd615ad05f750c7f246d62801))
13
+ * keep request state in fiber storage so it survives into child fibers ([6569935](https://github.com/graphiti-api/graphiti/commit/6569935c2140d4dd91cd6d4a7f9ec7c1bd05c888))
14
+ * only register the jsonapi mime type when it is missing ([88401ab](https://github.com/graphiti-api/graphiti/commit/88401abc8c77f1e859b4b8da3f9574c75834eb43))
15
+ * with_options accepts the predicate backed relationship options ([71ab441](https://github.com/graphiti-api/graphiti/commit/71ab4418bccc4cff7e6cc10151c1728641d25d34))
16
+
17
+
18
+ ### Features
19
+
20
+ * write every error code Graphiti renders into the generated locale file ([15f19c1](https://github.com/graphiti-api/graphiti/commit/15f19c1dbe56559d9d041fa50e2cc5ea7b2dcf4b))
21
+
22
+
23
+ ### Performance Improvements
24
+
25
+ * record latest performance stats ([7380ef9](https://github.com/graphiti-api/graphiti/commit/7380ef912e9f3b99618bf47ed04e06d1efad4e6c))
26
+
3
27
  # [2.0.0-beta.12](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.11...v2.0.0-beta.12) (2026-08-30)
4
28
 
5
29
 
data/Gemfile CHANGED
@@ -3,6 +3,9 @@ source "https://rubygems.org"
3
3
  # Specify your gem's dependencies in graphiti.gemspec
4
4
  gemspec
5
5
 
6
+ # Until rescue_registry 1.1 ships with the fiber storage fix.
7
+ gem "rescue_registry", github: "wagenet/rescue_registry", branch: "develop"
8
+
6
9
  group :test do
7
10
  gem "database_cleaner"
8
11
  gem "pry"
@@ -28,23 +28,8 @@ module Graphiti
28
28
  app_controller_code
29
29
  end
30
30
 
31
- inject_into_file "config/application.rb", after: "Rails::Application\n" do
32
- <<-'RUBY'
33
- # In order for Graphiti to generate links, you need to set the routes host.
34
- # When not explicitly set, via the HOST env var, this will fall back to
35
- # the rails server settings.
36
- # Rails::Server is not defined in console or rake tasks, so this will only
37
- # use those defaults when they are available.
38
- routes.default_url_options[:host] = ENV.fetch('HOST') do
39
- if defined?(Rails::Server)
40
- argv_options = Rails::Server::Options.new.parse!(ARGV)
41
- "http://#{argv_options[:Host]}:#{argv_options[:Port]}"
42
- end
43
- end
44
- RUBY
45
- end
46
-
47
- if defined?(RSpec)
31
+ # Thor aborts the whole generator when this file is missing.
32
+ if defined?(RSpec) && File.exist?(File.join(destination_root, "spec/rails_helper.rb"))
48
33
  inject_into_file "spec/rails_helper.rb", after: /RSpec.configure.+^end$/m do
49
34
  <<~RUBY
50
35
 
@@ -60,20 +45,7 @@ module Graphiti
60
45
  end
61
46
 
62
47
  insert_into_file "config/routes.rb", after: "Rails.application.routes.draw do\n" do
63
- if defined?(VandalUi)
64
- <<-STR
65
- scope path: ApplicationResource.endpoint_namespace, defaults: { format: :jsonapi } do
66
- mount VandalUi::Engine, at: '/vandal'
67
- # your routes go here
68
- end
69
- STR
70
- else
71
- <<-STR
72
- scope path: ApplicationResource.endpoint_namespace, defaults: { format: :jsonapi } do
73
- # your routes go here
74
- end
75
- STR
76
- end
48
+ namespace_scope
77
49
  end
78
50
  end
79
51
 
@@ -83,6 +55,15 @@ module Graphiti
83
55
  @options["omit-comments"]
84
56
  end
85
57
 
58
+ # The resource generator re-finds this scope by its format default.
59
+ def namespace_scope
60
+ lines = [" scope path: \"#{api_namespace}\", defaults: {format: :jsonapi} do\n"]
61
+ lines << " mount VandalUi::Engine, at: '/vandal'\n" if defined?(VandalUi)
62
+ lines << " # your routes go here\n"
63
+ lines << " end\n"
64
+ lines.join
65
+ end
66
+
86
67
  def app_controller_code
87
68
  str = +" include Graphiti::Rails::Controller\n"
88
69
  if defined?(::Responders)
@@ -8,6 +8,28 @@ module Graphiti
8
8
  aliases: ["-c"],
9
9
  desc: "Generate without documentation comments"
10
10
 
11
+ # Every code Graphiti renders a title for, and the title it renders today.
12
+ ERROR_TITLES = {
13
+ bad_request: "Request Error",
14
+ not_found: "Not Found",
15
+ conflict: "Conflict Error",
16
+ unprocessable_entity: "Validation Error",
17
+ internal_server_error: "Internal Server Error"
18
+ }.freeze
19
+
20
+ # The two codes no registration produces.
21
+ ERROR_SOURCES = {
22
+ unprocessable_entity: ["a write that failed model validations"],
23
+ internal_server_error: ["any exception Graphiti did not register"]
24
+ }.freeze
25
+
26
+ # Written into the app's locale file, not defaulted in Graphiti.
27
+ ERROR_DETAILS = {
28
+ internal_server_error: "We've probably received an error report already, but please contact us if the issue persists."
29
+ }.freeze
30
+
31
+ COMMENT_WIDTH = 68
32
+
11
33
  desc "Writes the error text Graphiti renders, ready to edit"
12
34
  def locale
13
35
  template("locale.yml.erb", File.join("config/locales", "graphiti.en.yml"))
@@ -19,6 +41,40 @@ module Graphiti
19
41
  @options["omit-comments"]
20
42
  end
21
43
 
44
+ def error_titles
45
+ ERROR_TITLES
46
+ end
47
+
48
+ def error_detail(code)
49
+ ERROR_DETAILS[code]
50
+ end
51
+
52
+ # What raises this code, so the file says where its text is used.
53
+ def error_source_lines(code)
54
+ ERROR_SOURCES[code] || wrap(exception_names(code))
55
+ end
56
+
57
+ def exception_names(code)
58
+ Graphiti::Rails::CLIENT_ERROR_STATUSES
59
+ .select { |_exception, status| status == code }
60
+ .keys.map { |exception| exception.delete_prefix("Graphiti::Errors::") }
61
+ end
62
+
63
+ def wrap(names)
64
+ items = names.map.with_index do |name, index|
65
+ (index == names.length - 1) ? name : "#{name},"
66
+ end
67
+
68
+ items.each_with_object([]) do |item, lines|
69
+ joined = "#{lines.last} #{item}"
70
+ if lines.empty? || joined.length > COMMENT_WIDTH
71
+ lines << item
72
+ else
73
+ lines[-1] = joined
74
+ end
75
+ end
76
+ end
77
+
22
78
  def default_messages
23
79
  Graphiti::Util::SimpleErrors::DEFAULT_MESSAGES
24
80
  end
@@ -171,7 +171,8 @@ module Graphiti
171
171
  code << %(, controller: "#{controller_path}") if options[:controller]
172
172
  code << %(, only: [#{actions.map { |a| ":#{a}" }.join(", ")}]) if actions.length < 5
173
173
  code << "\n"
174
- inject_into_file "config/routes.rb", after: /ApplicationResource.*$\n/ do
174
+ # Matches the install generator's scope, including its pre-2.0 form.
175
+ inject_into_file "config/routes.rb", after: /format: :jsonapi.*$\n/ do
175
176
  indent(code, 4)
176
177
  end
177
178
  end
@@ -1,21 +1,18 @@
1
1
  <%- unless omit_comments? -%>
2
- # ApplicationResource is similar to ApplicationRecord - a base class that
3
- # holds configuration/methods for subclasses.
4
2
  # All Resources should inherit from ApplicationResource.
5
3
  <%- end -%>
6
4
  class ApplicationResource < Graphiti::Resource
7
- <%- unless omit_comments? -%>
8
- # Use the ActiveRecord Adapter for all subclasses.
9
- # Subclasses can still override this default.
10
- <%- end -%>
11
5
  self.abstract_class = true
12
6
  self.adapter = Graphiti::Adapters::ActiveRecord
13
- self.base_url = Rails.application.routes.default_url_options[:host]
7
+ <%- unless omit_comments? -%>
8
+ # Or follow config/environments:
9
+ # self.base_url = ActionDispatch::Http::URL.url_for(Rails.application.routes.default_url_options)
10
+ <%- end -%>
11
+ self.base_url = ENV.fetch('BASE_URL', 'http://localhost:3000')
14
12
  self.endpoint_namespace = '<%= api_namespace %>'
15
13
  <%- unless omit_comments? -%>
16
14
 
17
- # Defaults every Resource inherits. Uncomment to change them API-wide, or set
18
- # them on individual Resources. See https://graphiti.dev/concepts/resources
15
+ # Defaults every Resource inherits. See https://graphiti.dev/concepts/resources
19
16
  <%- resource_setting_groups.each_pair do |group, lines| -%>
20
17
 
21
18
  # <%= group.to_s.humanize %>
@@ -56,7 +56,7 @@ class <%= controller_class_name %> < ApplicationController
56
56
  def update
57
57
  <%= file_name %> = <%= resource_klass %>.find(params)
58
58
 
59
- if <%= file_name %>.update_attributes
59
+ if <%= file_name %>.update
60
60
  render jsonapi: <%= file_name %>
61
61
  else
62
62
  render jsonapi_errors: <%= file_name %>
@@ -1,19 +1,27 @@
1
1
  <%- unless omit_comments? -%>
2
- # Everything Graphiti puts in an errors payload.
3
2
  # For another language, add a block below or a graphiti.<locale>.yml file.
4
3
  <%- end -%>
5
4
  en:
6
5
  graphiti:
7
6
  errors:
8
7
  <%- unless omit_comments? -%>
9
- # Titles and details are keyed by error code, and Graphiti ships none.
10
- # Without a key you get the HTTP status name and no detail.
11
- #
12
- # internal_server_error:
13
- # title: "Something went wrong"
14
- # detail: "We've probably received an error report already, but please contact us if the issue persists."
15
- #
16
- # format joins them: "data.attributes.title is an unknown attribute".
8
+ # Keyed by error code. Edit any value to reword it. Comments name what
9
+ # raises each code, relative to Graphiti::Errors.
10
+ <%- end -%>
11
+ <%- error_titles.each_pair do |code, title| -%>
12
+ <%= code %>:
13
+ <%- unless omit_comments? -%>
14
+ <%- error_source_lines(code).each do |line| -%>
15
+ # <%= line %>
16
+ <%- end -%>
17
+ <%- end -%>
18
+ title: <%= title.inspect %>
19
+ <%- if error_detail(code) -%>
20
+ detail: <%= error_detail(code).inspect %>
21
+ <%- end -%>
22
+ <%- end -%>
23
+ <%- unless omit_comments? -%>
24
+ # format joins the two below: "data.attributes.title is an unknown attribute".
17
25
  <%- end -%>
18
26
  format: <%= default_format.inspect %>
19
27
  messages:
@@ -45,7 +45,7 @@ RSpec.describe <%= resource_class %>, type: :resource do
45
45
 
46
46
  xit 'works (add some attributes and enable this spec)' do
47
47
  expect {
48
- expect(instance.update_attributes).to eq(true)
48
+ expect(instance.update).to eq(true)
49
49
  }.to change { <%= var %>.reload.updated_at }
50
50
  # .and change { <%= var %>.foo }.to('bar') <- example
51
51
  end
@@ -246,7 +246,7 @@ module Graphiti
246
246
 
247
247
  children.each do |child|
248
248
  if association_type == :many_to_many &&
249
- [:create, :update].include?(Graphiti.context[:namespace]) &&
249
+ [:create, :update].include?(Graphiti.context[:action]) &&
250
250
  !parent.send(association_name).exists?(child.id) &&
251
251
  child.errors.blank?
252
252
  parent.send(association_name) << child
@@ -0,0 +1,21 @@
1
+ module Graphiti
2
+ # graphiti-api/graphiti#134 recommended reading Graphiti.context[:namespace] from base_scope before current_action existed, so the old key keeps working for one major.
3
+ class ContextHash < Hash
4
+ def [](key)
5
+ super(deprecate_namespace(key))
6
+ end
7
+
8
+ def []=(key, value)
9
+ super(deprecate_namespace(key), value)
10
+ end
11
+
12
+ private
13
+
14
+ def deprecate_namespace(key)
15
+ return key unless key == :namespace
16
+
17
+ Graphiti::DEPRECATOR.deprecation_warning(:"context[:namespace]", "Use #current_action instead", caller_locations(2))
18
+ :action
19
+ end
20
+ end
21
+ end
@@ -11,13 +11,13 @@ module Graphiti
11
11
  private_constant :CHUNKS
12
12
 
13
13
  class << self
14
- # Pool threads inherit this through the thread-local copy Scope#future_with_context makes, so their chunks reach the right request.
14
+ # Pool threads inherit this through the fiber storage copy Scope#future_with_context makes, so their chunks reach the right request.
15
15
  def chunks
16
- Thread.current[CHUNKS] ||= Concurrent::Array.new
16
+ Fiber[CHUNKS] ||= Concurrent::Array.new
17
17
  end
18
18
 
19
19
  def chunks=(value)
20
- Thread.current[CHUNKS] = value
20
+ Fiber[CHUNKS] = value
21
21
  end
22
22
 
23
23
  def on_data(name, start, stop, id, payload)
@@ -1,6 +1,8 @@
1
1
  module Graphiti
2
2
  module ErrorSerializers
3
3
  class InvalidRequest
4
+ include TranslatedTitle
5
+
4
6
  STATUS = 400
5
7
 
6
8
  attr_reader :source
@@ -48,12 +50,6 @@ module Graphiti
48
50
  "bad_request"
49
51
  end
50
52
 
51
- def title
52
- return default_title unless defined?(::I18n)
53
-
54
- ::I18n.t :title, scope: [:graphiti, :errors, code], default: default_title
55
- end
56
-
57
53
  def default_title
58
54
  "Request Error"
59
55
  end
@@ -0,0 +1,13 @@
1
+ module Graphiti
2
+ module ErrorSerializers
3
+ module TranslatedTitle
4
+ private
5
+
6
+ def title
7
+ return default_title unless defined?(::I18n)
8
+
9
+ ::I18n.t :title, scope: [:graphiti, :errors, code], default: default_title
10
+ end
11
+ end
12
+ end
13
+ end
@@ -3,6 +3,8 @@ module Graphiti
3
3
  # Walks into related objects so nested writes report against the record
4
4
  # that actually failed.
5
5
  class Validation
6
+ include TranslatedTitle
7
+
6
8
  attr_reader :object
7
9
 
8
10
  def initialize(object, relationship_payloads = {}, relationship_meta = {})
@@ -13,14 +15,14 @@ module Graphiti
13
15
 
14
16
  def attribute_errors
15
17
  [].tap do |errors|
16
- each_error do |attribute, message, code|
18
+ each_error do |attribute, message, validation_code|
17
19
  errors << {
18
- code: "unprocessable_entity",
20
+ code: code,
19
21
  status: "422",
20
- title: "Validation Error",
22
+ title: title,
21
23
  detail: detail_for(attribute, message),
22
24
  source: {pointer: pointer_for(object, attribute)},
23
- meta: meta_for(attribute, message, code, @relationship_meta)
25
+ meta: meta_for(attribute, message, validation_code, @relationship_meta)
24
26
  }
25
27
  end
26
28
  end
@@ -34,6 +36,14 @@ module Graphiti
34
36
 
35
37
  private
36
38
 
39
+ def code
40
+ "unprocessable_entity"
41
+ end
42
+
43
+ def default_title
44
+ "Validation Error"
45
+ end
46
+
37
47
  def each_error
38
48
  object.errors.messages.each_pair do |attribute, messages|
39
49
  details = object.errors.details.find { |k, _| k == attribute }[1]
@@ -26,6 +26,7 @@ module Graphiti
26
26
  @parents = parents
27
27
  @action = parse_action(action)
28
28
  @entity_map = Concurrent::Map.new if parents.empty?
29
+ @association_owners = Concurrent::Map.new if parents.empty?
29
30
  end
30
31
 
31
32
  def association?
@@ -39,6 +40,12 @@ module Graphiti
39
40
  @entity_map
40
41
  end
41
42
 
43
+ def association_owners
44
+ return root.association_owners unless root == self
45
+
46
+ @association_owners
47
+ end
48
+
42
49
  # Deduplication exists for a row two include paths both resolve, so a
43
50
  # resource class sitting at one node has nothing to collide with.
44
51
  def repeated_resource_classes
@@ -421,7 +428,7 @@ module Graphiti
421
428
  end
422
429
 
423
430
  def parse_action(action)
424
- action ||= @params.fetch(:action, Graphiti.context[:namespace]).try(:to_sym)
431
+ action ||= @params.fetch(:action, Graphiti.context[:action]).try(:to_sym)
425
432
  case action
426
433
  when :index
427
434
  :all
@@ -77,7 +77,7 @@ module Graphiti
77
77
  end
78
78
 
79
79
  def register_mime_type
80
- Mime::Type.register(MEDIA_TYPE, :jsonapi)
80
+ Mime::Type.register(MEDIA_TYPE, :jsonapi) if Mime[:jsonapi].nil?
81
81
  end
82
82
 
83
83
  def register_parameter_parser
@@ -28,7 +28,7 @@ module Graphiti
28
28
  end
29
29
 
30
30
  def payload_matches_endpoint?
31
- unless @params.dig(:data, :id) == @params.dig(:filter, :id)
31
+ unless @params.dig(:data, :id).to_s == @params.dig(:filter, :id).to_s
32
32
  attribute_mismatch([:data, :id])
33
33
  end
34
34
 
@@ -37,8 +37,8 @@ module Graphiti
37
37
  end
38
38
  end
39
39
 
40
- def with_context(object, namespace = nil)
41
- Graphiti.with_context(object, namespace) do
40
+ def with_context(object, action = nil)
41
+ Graphiti.with_context(object, action) do
42
42
  yield
43
43
  end
44
44
  end
@@ -55,7 +55,7 @@ module Graphiti
55
55
  # is called, not a fixed list. Persistence overrides it with :create/:update
56
56
  # while saving, and :show while resolving sideloads afterwards.
57
57
  def self.current_action
58
- Graphiti.context[:namespace]
58
+ Graphiti.context[:action]
59
59
  end
60
60
 
61
61
  def current_action
@@ -159,7 +159,7 @@ module Graphiti
159
159
  params = normalized_params_copy(params)
160
160
  add_endpoint_filter(params, action)
161
161
  validator = ::Graphiti::RequestValidator.new(@resource, params, action)
162
- validator.validate!
162
+ with_context_action(action) { validator.validate! }
163
163
 
164
164
  if @assigned_model && same_write_payload?(validator.deserialized_payload)
165
165
  return @assigned_model
@@ -174,32 +174,36 @@ module Graphiti
174
174
  )
175
175
  end
176
176
 
177
+ # TODO: remove this. Only used for persisting many-to-many with AR
178
+ # (see activerecord adapter)
179
+ def with_context_action(action)
180
+ original = Graphiti.context[:action]
181
+ Graphiti.context[:action] = action
182
+ yield
183
+ ensure
184
+ Graphiti.context[:action] = original
185
+ end
186
+
177
187
  def save(action: :create)
178
- # TODO: remove this. Only used for persisting many-to-many with AR
179
- # (see activerecord adapter)
180
- original = Graphiti.context[:namespace]
181
- begin
182
- Graphiti.context[:namespace] = action
188
+ validator = with_context_action(action) do
183
189
  # An assigned model can only come from #assign_attributes, which
184
190
  # validated the payload it stored - re-validating here would run the
185
191
  # writable guards (and their guard_model lookups) a redundant time.
186
192
  unless @assigned_model
187
193
  ::Graphiti::RequestValidator.new(@resource, @payload.params, action).validate!
188
194
  end
189
- validator = persist {
195
+ persist {
190
196
  @resource.persist_with_relationships \
191
197
  @payload.meta(action: action),
192
198
  @payload.attributes,
193
199
  @payload.relationships,
194
200
  assigned_model: @assigned_model
195
201
  }
196
- ensure
197
- Graphiti.context[:namespace] = original
198
202
  end
199
203
  @data, success = validator.to_a
200
204
 
201
205
  if success
202
- # If the context namespace is `update` or `create`, certain
206
+ # If the context action is `update` or `create`, certain
203
207
  # adapters will cause N+1 validation calls, so lets explicitly
204
208
  # switch to a lookup context.
205
209
  Graphiti.with_context(Graphiti.context[:object], :show) do
@@ -39,18 +39,17 @@ module Graphiti
39
39
  !Graphiti.config.concurrency || on_pool_thread?
40
40
  end
41
41
 
42
- # TODO: move to Fiber[] once the floor is Ruby 3.2
43
42
  def self.on_pool_thread?
44
- Thread.current[POOL_THREAD] == true
43
+ Fiber[POOL_THREAD] == true
45
44
  end
46
45
 
47
46
  # Restores rather than clears because :caller_runs may have run the task on a request thread.
48
47
  def self.marking_pool_thread
49
- previous = Thread.current[POOL_THREAD]
50
- Thread.current[POOL_THREAD] = true
48
+ previous = Fiber[POOL_THREAD]
49
+ Fiber[POOL_THREAD] = true
51
50
  yield
52
51
  ensure
53
- Thread.current[POOL_THREAD] = previous
52
+ Fiber[POOL_THREAD] = previous
54
53
  end
55
54
 
56
55
  def initialize(object, resource, query, opts = {})
@@ -61,6 +61,27 @@ module Graphiti
61
61
  self.scope_proc = blk
62
62
  end
63
63
 
64
+ ASSIGNING_QUERY = :__graphiti_assigning_query
65
+ private_constant :ASSIGNING_QUERY
66
+
67
+ def self.assigning_node(query)
68
+ return yield if query.nil?
69
+
70
+ previous = Fiber[ASSIGNING_QUERY]
71
+ Fiber[ASSIGNING_QUERY] = query
72
+ yield
73
+ ensure
74
+ Fiber[ASSIGNING_QUERY] = previous unless query.nil?
75
+ end
76
+
77
+ def self.current_assigning_query
78
+ Fiber[ASSIGNING_QUERY]
79
+ end
80
+
81
+ def self.assigned?(query, record, association_name)
82
+ query.association_owners.key?([record.object_id, association_name])
83
+ end
84
+
64
85
  def self.assign(&blk)
65
86
  self.assign_proc = blk
66
87
  end
@@ -365,7 +386,7 @@ module Graphiti
365
386
 
366
387
  if self.class.scope_proc
367
388
  build_sideload_scope(parents, query, graph_parent).resolve do |sideload_results|
368
- fire_assign(parents, sideload_results)
389
+ fire_assign(parents, sideload_results, query)
369
390
  end
370
391
  else
371
392
  sync_load(parents, query, graph_parent, &proxy_block)
@@ -378,7 +399,7 @@ module Graphiti
378
399
 
379
400
  if self.class.scope_proc
380
401
  build_sideload_scope(parents, query, graph_parent).future_resolve do |sideload_results|
381
- fire_assign(parents, sideload_results)
402
+ fire_assign(parents, sideload_results, query)
382
403
  end
383
404
  else
384
405
  future_load(parents, query, graph_parent, &proxy_block)
@@ -413,10 +434,14 @@ module Graphiti
413
434
  end
414
435
 
415
436
  def associate_all(parent, children)
437
+ return unless claim_association(parent)
438
+
416
439
  parent_resource.associate_all(parent, children, association_name, type)
417
440
  end
418
441
 
419
442
  def associate(parent, child)
443
+ return unless claim_association(parent)
444
+
420
445
  parent_resource.associate(parent, child, association_name, type)
421
446
  end
422
447
 
@@ -506,7 +531,7 @@ module Graphiti
506
531
  opts[:sideload_parent_length] = parents.length
507
532
  opts[:query] = query
508
533
  opts[:after_resolve] = ->(results) {
509
- fire_assign(parents, results)
534
+ fire_assign(parents, results, query)
510
535
  }
511
536
  end
512
537
  end
@@ -519,16 +544,28 @@ module Graphiti
519
544
  end
520
545
  end
521
546
 
522
- def fire_assign(parents, children)
523
- with_error_handling Errors::SideloadAssignError do
524
- if self.class.assign_proc
525
- instance_exec(parents, children, &self.class.assign_proc)
526
- else
527
- assign(parents, children)
547
+ def fire_assign(parents, children, query = nil)
548
+ self.class.assigning_node(query) do
549
+ with_error_handling Errors::SideloadAssignError do
550
+ if self.class.assign_proc
551
+ instance_exec(parents, children, &self.class.assign_proc)
552
+ else
553
+ assign(parents, children)
554
+ end
528
555
  end
529
556
  end
530
557
  end
531
558
 
559
+ # A record can be shared by two nodes of the include tree, and a node that narrows
560
+ # differently returns different rows, so the first node to populate an association owns it.
561
+ def claim_association(parent)
562
+ query = self.class.current_assigning_query
563
+ return true if query.nil?
564
+
565
+ key = [parent.object_id, association_name]
566
+ query.association_owners.compute_if_absent(key) { query.hash } == query.hash
567
+ end
568
+
532
569
  def with_error_handling(error_class)
533
570
  begin
534
571
  result = yield
@@ -59,6 +59,7 @@ module Graphiti
59
59
 
60
60
  class RelationMatcher < BaseMatcher
61
61
  GRAPHITI_OPTS = %i[primary_key foreign_key resource readable writable link single].freeze
62
+ SIDELOAD_METHODS = {resource: :resource_class, readable: :readable?, writable: :writable?, single: :single?}.freeze
62
63
  GRAPHITI_CONFIG_KEY = :sideloads
63
64
  EXPECTED_ACTION = ""
64
65
 
@@ -73,7 +74,7 @@ module Graphiti
73
74
  attr_reader :target, :opts, :resource
74
75
 
75
76
  def assert_opt(opt)
76
- asserted_opt = (opt == :resource) ? :resource_class : opt
77
+ asserted_opt = SIDELOAD_METHODS.fetch(opt, opt)
77
78
  return true if config.send(asserted_opt) == opts[opt]
78
79
 
79
80
  @opt_failures << opt_failure_message(opt, opts[opt], config.send(asserted_opt))
@@ -71,7 +71,7 @@ module Graphiti
71
71
  # not predict, so the loaded records win. Only the un-included case
72
72
  # is worth short-circuiting.
73
73
  if sideload_ref.resource_ids_from_foreign_key? &&
74
- !self_ref.send(:included_anywhere?, @proxy.query.include_hash, sideload_ref.name)
74
+ !::Graphiti::Sideload.assigned?(@proxy.query, @object, sideload_ref.association_name)
75
75
  linkage always: sideload_ref.render_resource_ids? do
76
76
  foreign_key = begin
77
77
  @object.public_send(sideload_ref.foreign_key)
@@ -104,15 +104,6 @@ module Graphiti
104
104
  end
105
105
  end
106
106
 
107
- # A relationship nested under another one is still loaded and can still
108
- # be narrowed by a deep filter, so the foreign key is not a safe
109
- # stand-in for what the request actually returns.
110
- def included_anywhere?(include_hash, name)
111
- include_hash.any? do |key, nested|
112
- key == name || included_anywhere?(nested, name)
113
- end
114
- end
115
-
116
107
  def data_proc
117
108
  sideload_ref = @sideload
118
109
  resource_class_ref = @resource_class
@@ -58,11 +58,11 @@ module Graphiti
58
58
  private
59
59
 
60
60
  def _hooks
61
- Thread.current[:_graphiti_hooks]
61
+ Fiber[:_graphiti_hooks]
62
62
  end
63
63
 
64
64
  def reset_hooks
65
- Thread.current[:_graphiti_hooks] = {
65
+ Fiber[:_graphiti_hooks] = {
66
66
  after_graph_persist: [],
67
67
  before_commit: [],
68
68
  after_commit: []
@@ -1,3 +1,3 @@
1
1
  module Graphiti
2
- VERSION = "2.0.0.beta.12"
2
+ VERSION = "2.0.0.beta.13"
3
3
  end
data/lib/graphiti.rb CHANGED
@@ -51,18 +51,20 @@ module Graphiti
51
51
 
52
52
  # @api private
53
53
  def self.context
54
- Thread.current[:context] ||= {}
54
+ Fiber[:context] ||= ContextHash.new
55
55
  end
56
56
 
57
57
  # @api private
58
58
  def self.context=(val)
59
- Thread.current[:context] = val
59
+ Fiber[:context] = ContextHash.new.tap do |hash|
60
+ val.each { |key, value| hash[key] = value }
61
+ end
60
62
  end
61
63
 
62
64
  # @api private
63
- def self.with_context(obj, namespace = nil)
65
+ def self.with_context(obj, action = nil)
64
66
  prior = context
65
- self.context = {object: obj, namespace: namespace}
67
+ self.context = {object: obj, action: action}
66
68
  yield
67
69
  ensure
68
70
  self.context = prior
@@ -169,6 +171,7 @@ require "graphiti/version"
169
171
  require "graphiti/jsonapi_serializable_ext"
170
172
  require "graphiti/configuration"
171
173
  require "graphiti/context"
174
+ require "graphiti/context_hash"
172
175
  require "graphiti/errors"
173
176
  require "graphiti/types"
174
177
  require "graphiti/audit"
@@ -239,6 +242,7 @@ require "graphiti/query"
239
242
  require "graphiti/debugger"
240
243
  require "graphiti/util/cache_debug"
241
244
  require "graphiti/util/uri_decoder"
245
+ require "graphiti/error_serializers/translated_title"
242
246
  require "graphiti/error_serializers/validation"
243
247
  require "graphiti/error_serializers/invalid_request"
244
248
  require "graphiti/error_serializers/conflict_request"
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.12
4
+ version: 2.0.0.beta.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lee Richmond
@@ -318,12 +318,14 @@ files:
318
318
  - lib/graphiti/cli.rb
319
319
  - lib/graphiti/configuration.rb
320
320
  - lib/graphiti/context.rb
321
+ - lib/graphiti/context_hash.rb
321
322
  - lib/graphiti/debugger.rb
322
323
  - lib/graphiti/delegates/pagination.rb
323
324
  - lib/graphiti/deserializer.rb
324
325
  - lib/graphiti/error_serializers/conflict_request.rb
325
326
  - lib/graphiti/error_serializers/deprecated_constants.rb
326
327
  - lib/graphiti/error_serializers/invalid_request.rb
328
+ - lib/graphiti/error_serializers/translated_title.rb
327
329
  - lib/graphiti/error_serializers/validation.rb
328
330
  - lib/graphiti/errors.rb
329
331
  - lib/graphiti/extensions/boolean_attribute.rb