diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index cc8fdc0d1..6703d5ed5 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,3 +1,7 @@ Metrics/ClassLength: Exclude: - 'lib/valkyrie/persistence/postgres/query_service.rb' + +Metrics/MethodLength: + Exclude: + - 'lib/valkyrie/persistence/fedora/persister.rb' diff --git a/lib/valkyrie/persistence/fedora/permissive_schema.rb b/lib/valkyrie/persistence/fedora/permissive_schema.rb index f959fab0e..1044c2fea 100644 --- a/lib/valkyrie/persistence/fedora/permissive_schema.rb +++ b/lib/valkyrie/persistence/fedora/permissive_schema.rb @@ -63,6 +63,11 @@ def self.valkyrie_time uri_for(:valkyrie_time) end + # @return [RDF::URI] + def self.optimistic_lock_token + uri_for(:optimistic_lock_token) + end + # Cast the property to a URI in the namespace # @param property [Symbol] # @return [RDF::URI] diff --git a/lib/valkyrie/persistence/fedora/persister.rb b/lib/valkyrie/persistence/fedora/persister.rb index 0bc8e2a4c..6eab7179d 100644 --- a/lib/valkyrie/persistence/fedora/persister.rb +++ b/lib/valkyrie/persistence/fedora/persister.rb @@ -13,13 +13,16 @@ def initialize(adapter:) # (see Valkyrie::Persistence::Memory::Persister#save) def save(resource:) initialize_repository - resource.created_at ||= Time.current - resource.updated_at ||= Time.current - orm = resource_factory.from_resource(resource: resource) - alternate_resources = find_or_create_alternate_ids(resource) - - if !orm.new? || resource.id - cleanup_alternate_resources(resource) if alternate_resources + internal_resource = resource.dup + internal_resource.created_at ||= Time.current + internal_resource.updated_at ||= Time.current + validate_lock_token(internal_resource) + generate_lock_token(internal_resource) + orm = resource_factory.from_resource(resource: internal_resource) + alternate_resources = find_or_create_alternate_ids(internal_resource) + + if !orm.new? || internal_resource.id + cleanup_alternate_resources(internal_resource) if alternate_resources orm.update { |req| req.headers["Prefer"] = "handling=lenient; received=\"minimal\"" } else orm.create @@ -34,6 +37,9 @@ def save_all(resources:) resources.map do |resource| save(resource: resource) end + rescue Valkyrie::Persistence::StaleObjectError + # blank out the message / id + raise Valkyrie::Persistence::StaleObjectError end # (see Valkyrie::Persistence::Memory::Persister#delete) @@ -99,5 +105,29 @@ def save_reference_to_resource(resource, alternate_resources) resource end + + # @note Fedora's last modified response is not granular enough to produce an effective lock token + # therefore, we use the same implementation as the memory adapter. This could fail to lock a + # resource if Fedora updated this resource between the time it was saved and Valkyrie created + # the token. + def generate_lock_token(resource) + return unless resource.optimistic_locking_enabled? + token = Valkyrie::Persistence::OptimisticLockToken.new(adapter_id: adapter.id, token: Time.now.to_r) + resource.send("#{Valkyrie::Persistence::Attributes::OPTIMISTIC_LOCK}=", token) + end + + def validate_lock_token(resource) + return unless resource.optimistic_locking_enabled? + return if resource.id.blank? + + current_lock_token = resource[Valkyrie::Persistence::Attributes::OPTIMISTIC_LOCK].find { |lock_token| lock_token.adapter_id == adapter.id } + return if current_lock_token.blank? + + retrieved_lock_tokens = adapter.query_service.find_by(id: resource.id)[Valkyrie::Persistence::Attributes::OPTIMISTIC_LOCK] + retrieved_lock_token = retrieved_lock_tokens.find { |lock_token| lock_token.adapter_id == adapter.id } + return if retrieved_lock_token.blank? + + raise Valkyrie::Persistence::StaleObjectError, resource.id.to_s unless current_lock_token.serialize == retrieved_lock_token.serialize + end end end diff --git a/lib/valkyrie/persistence/fedora/persister/orm_converter.rb b/lib/valkyrie/persistence/fedora/persister/orm_converter.rb index 0747c08c6..a235262c5 100644 --- a/lib/valkyrie/persistence/fedora/persister/orm_converter.rb +++ b/lib/valkyrie/persistence/fedora/persister/orm_converter.rb @@ -237,6 +237,18 @@ def result end end + class ValkyrieOptimisticLockToken < ::Valkyrie::ValueMapper + FedoraValue.register(self) + def self.handles?(value) + value.statement.object.is_a?(RDF::Literal) && value.statement.object.datatype == PermissiveSchema.optimistic_lock_token + end + + def result + value.statement.object = Valkyrie::Persistence::OptimisticLockToken.new(adapter_id: value.adapter.id, token: value.statement.object.to_s) + calling_mapper.for(Property.new(statement: value.statement, scope: value.scope, adapter: value.adapter)).result + end + end + class InternalURI < ::Valkyrie::ValueMapper FedoraValue.register(self) def self.handles?(value) diff --git a/spec/valkyrie/persistence/fedora/permissive_schema_spec.rb b/spec/valkyrie/persistence/fedora/permissive_schema_spec.rb index 7f9ec679c..327846b2d 100644 --- a/spec/valkyrie/persistence/fedora/permissive_schema_spec.rb +++ b/spec/valkyrie/persistence/fedora/permissive_schema_spec.rb @@ -13,4 +13,10 @@ expect(described_class.alternate_ids).to eq RDF::URI("http://example.com/predicate/alternate_ids") end end + + describe ".optimistic_lock_token" do + it "returns the expected temporary URI" do + expect(described_class.optimistic_lock_token).to eq RDF::URI("http://example.com/predicate/optimistic_lock_token") + end + end end diff --git a/spec/valkyrie/persistence/fedora/persister_spec.rb b/spec/valkyrie/persistence/fedora/persister_spec.rb index beafc5a07..acac848b0 100644 --- a/spec/valkyrie/persistence/fedora/persister_spec.rb +++ b/spec/valkyrie/persistence/fedora/persister_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'spec_helper' require 'valkyrie/specs/shared_specs' +require 'valkyrie/specs/shared_specs/locking_persister' RSpec.describe Valkyrie::Persistence::Fedora::Persister do let(:adapter) do @@ -13,6 +14,7 @@ let(:persister) { adapter.persister } let(:query_service) { adapter.query_service } it_behaves_like "a Valkyrie::Persister" + it_behaves_like "a Valkyrie locking persister" context "when given an id containing a slash" do before do