diff --git a/lib/valkyrie/persistence/fedora/permissive_schema.rb b/lib/valkyrie/persistence/fedora/permissive_schema.rb index 74241a598..0660e84d5 100644 --- a/lib/valkyrie/persistence/fedora/permissive_schema.rb +++ b/lib/valkyrie/persistence/fedora/permissive_schema.rb @@ -22,11 +22,19 @@ def self.id uri_for(:id) end + def self.alternate_ids + uri_for(:alternate_ids) + end + # @return [RDF::URI] def self.member_ids uri_for(:member_ids) end + def self.references + uri_for(:references) + end + # @return [RDF::URI] def self.valkyrie_bool uri_for(:valkyrie_bool) diff --git a/lib/valkyrie/persistence/fedora/persister.rb b/lib/valkyrie/persistence/fedora/persister.rb index 869a4f919..bdba9216c 100644 --- a/lib/valkyrie/persistence/fedora/persister.rb +++ b/lib/valkyrie/persistence/fedora/persister.rb @@ -3,6 +3,7 @@ module Valkyrie::Persistence::Fedora # Persister for Fedora MetadataAdapter. class Persister require 'valkyrie/persistence/fedora/persister/resource_factory' + require 'valkyrie/persistence/fedora/persister/alternate_identifier' attr_reader :adapter delegate :connection, :base_path, :resource_factory, to: :adapter def initialize(adapter:) @@ -16,14 +17,17 @@ def save(resource:) resource.updated_at ||= Time.current ensure_multiple_values!(resource) orm = resource_factory.from_resource(resource: resource) + alternate_resources = find_or_create_alternate_ids(resource) + if !orm.new? || resource.id - orm.update do |req| - req.headers["Prefer"] = "handling=lenient; received=\"minimal\"" - end + cleanup_alternate_resources(resource) if alternate_resources + orm.update { |req| req.headers["Prefer"] = "handling=lenient; received=\"minimal\"" } else orm.create end - resource_factory.to_resource(object: orm) + persisted_resource = resource_factory.to_resource(object: orm) + + alternate_resources ? save_reference_to_resource(persisted_resource, alternate_resources) : persisted_resource end # (see Valkyrie::Persistence::Memory::Persister#save_all) @@ -35,8 +39,15 @@ def save_all(resources:) # (see Valkyrie::Persistence::Memory::Persister#delete) def delete(resource:) + if resource.try(:alternate_ids) + resource.alternate_ids.each do |alternate_identifier| + adapter.persister.delete(resource: adapter.query_service.find_by(id: alternate_identifier)) + end + end + orm = resource_factory.from_resource(resource: resource) orm.delete + resource end @@ -60,10 +71,41 @@ def initialize_repository private def ensure_multiple_values!(resource) - bad_keys = resource.attributes.except(:internal_resource, :created_at, :updated_at, :new_record, :id).select do |_k, v| + bad_keys = resource.attributes.except(:internal_resource, :created_at, :updated_at, :new_record, :id, :references).select do |_k, v| !v.nil? && !v.is_a?(Array) end raise ::Valkyrie::Persistence::UnsupportedDatatype, "#{resource}: #{bad_keys.keys} have non-array values, which can not be persisted by Valkyrie. Cast to arrays." unless bad_keys.keys.empty? end + + def find_or_create_alternate_ids(resource) + return nil unless resource.try(:alternate_ids) + + resource.alternate_ids.map do |alternate_identifier| + begin + adapter.query_service.find_by(id: alternate_identifier) + rescue ::Valkyrie::Persistence::ObjectNotFoundError + alternate_resource = ::Valkyrie::Persistence::Fedora::AlternateIdentifier.new(id: alternate_identifier) + adapter.persister.save(resource: alternate_resource) + end + end + end + + def cleanup_alternate_resources(updated_resource) + persisted_resource = adapter.query_service.find_by(id: updated_resource.id) + removed_identifiers = persisted_resource.alternate_ids - updated_resource.alternate_ids + + removed_identifiers.each do |removed_id| + adapter.persister.delete(resource: adapter.query_service.find_by(id: removed_id)) + end + end + + def save_reference_to_resource(resource, alternate_resources) + alternate_resources.each do |alternate_resource| + alternate_resource.references = resource.id + adapter.persister.save(resource: alternate_resource) + end + + resource + end end end diff --git a/lib/valkyrie/persistence/fedora/persister/alternate_identifier.rb b/lib/valkyrie/persistence/fedora/persister/alternate_identifier.rb new file mode 100644 index 000000000..e9003a7cc --- /dev/null +++ b/lib/valkyrie/persistence/fedora/persister/alternate_identifier.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +require 'valkyrie/resource' +require 'valkyrie/types' + +module Valkyrie::Persistence::Fedora + class AlternateIdentifier < ::Valkyrie::Resource + attribute :id, ::Valkyrie::Types::ID.optional + attribute :references, ::Valkyrie::Types::ID.optional + end +end diff --git a/lib/valkyrie/persistence/fedora/query_service.rb b/lib/valkyrie/persistence/fedora/query_service.rb index 346e33669..cb72ce0e9 100644 --- a/lib/valkyrie/persistence/fedora/query_service.rb +++ b/lib/valkyrie/persistence/fedora/query_service.rb @@ -10,15 +10,19 @@ def initialize(adapter:) # (see Valkyrie::Persistence::Memory::QueryService#find_by) def find_by(id:) - id = Valkyrie::ID.new(id.to_s) if id.is_a?(String) validate_id(id) uri = adapter.id_to_uri(id) - begin - resource = Ldp::Resource.for(connection, uri, connection.get(uri)) - resource_factory.to_resource(object: resource) - rescue ::Ldp::Gone, ::Ldp::NotFound - raise ::Valkyrie::Persistence::ObjectNotFoundError - end + + resource_from_uri(uri) + end + + # (see Valkyrie::Persistence::Memory::QueryService#find_by_alternate_identifier) + def find_by_alternate_identifier(alternate_identifier:) + validate_id(alternate_identifier) + uri = adapter.id_to_uri(alternate_identifier) + alternate_id = resource_from_uri(uri).references + + find_by(id: alternate_id) end # (see Valkyrie::Persistence::Memory::QueryService#find_many_by_ids) @@ -113,9 +117,17 @@ def custom_queries private def validate_id(id) + id = Valkyrie::ID.new(id.to_s) if id.is_a?(String) raise ArgumentError, 'id must be a Valkyrie::ID' unless id.is_a? Valkyrie::ID end + def resource_from_uri(uri) + resource = Ldp::Resource.for(connection, uri, connection.get(uri)) + resource_factory.to_resource(object: resource) + rescue ::Ldp::Gone, ::Ldp::NotFound + raise ::Valkyrie::Persistence::ObjectNotFoundError + end + def ensure_persisted(resource) raise ArgumentError, 'resource is not saved' unless resource.persisted? end diff --git a/lib/valkyrie/persistence/memory/query_service.rb b/lib/valkyrie/persistence/memory/query_service.rb index 22eca03d7..b7e53c833 100644 --- a/lib/valkyrie/persistence/memory/query_service.rb +++ b/lib/valkyrie/persistence/memory/query_service.rb @@ -24,6 +24,17 @@ def find_by(id:) cache[id] || raise(::Valkyrie::Persistence::ObjectNotFoundError) end + # @param alternate_identifier [Valkyrie::ID] The alternate identifier to query for. + # @raise [Valkyrie::Persistence::ObjectNotFoundError] Raised when the alternate identifier + # isn't in the persistence backend. + # @raise [ArgumentError] Raised when alternate identifier is not a String or a Valkyrie::ID + # @return [Valkyrie::Resource] The object being searched for. + def find_by_alternate_identifier(alternate_identifier:) + alternate_identifier = Valkyrie::ID.new(alternate_identifier.to_s) if alternate_identifier.is_a?(String) + validate_id(alternate_identifier) + cache.select { |_key, resource| resource['alternate_ids'].include?(alternate_identifier) }.values.first || raise(::Valkyrie::Persistence::ObjectNotFoundError) + end + # @param ids [Array] The IDs to query for. # @raise [ArgumentError] Raised when any ID is not a String or a Valkyrie::ID # @return [Array] All requested objects that were found diff --git a/lib/valkyrie/persistence/postgres/query_service.rb b/lib/valkyrie/persistence/postgres/query_service.rb index 194580500..ecb7c1259 100644 --- a/lib/valkyrie/persistence/postgres/query_service.rb +++ b/lib/valkyrie/persistence/postgres/query_service.rb @@ -37,6 +37,14 @@ def find_by(id:) raise Valkyrie::Persistence::ObjectNotFoundError end + # (see Valkyrie::Persistence::Memory::QueryService#find_by_alternate_identifier) + def find_by_alternate_identifier(alternate_identifier:) + alternate_identifier = Valkyrie::ID.new(alternate_identifier.to_s) if alternate_identifier.is_a?(String) + validate_id(alternate_identifier) + internal_array = "{\"alternate_ids\": [{\"id\": \"#{alternate_identifier}\"}]}" + run_query(find_inverse_references_query, internal_array).first || raise(Valkyrie::Persistence::ObjectNotFoundError) + end + # (see Valkyrie::Persistence::Memory::QueryService#find_many_by_ids) def find_many_by_ids(ids:) ids.map! do |id| diff --git a/lib/valkyrie/persistence/solr/queries.rb b/lib/valkyrie/persistence/solr/queries.rb index 1d301d70e..5419b9077 100644 --- a/lib/valkyrie/persistence/solr/queries.rb +++ b/lib/valkyrie/persistence/solr/queries.rb @@ -6,6 +6,7 @@ module Queries require 'valkyrie/persistence/solr/queries/default_paginator' require 'valkyrie/persistence/solr/queries/find_all_query' require 'valkyrie/persistence/solr/queries/find_by_id_query' + require 'valkyrie/persistence/solr/queries/find_by_alternate_identifier_query' require 'valkyrie/persistence/solr/queries/find_many_by_ids_query' require 'valkyrie/persistence/solr/queries/find_inverse_references_query' require 'valkyrie/persistence/solr/queries/find_members_query' diff --git a/lib/valkyrie/persistence/solr/queries/find_by_alternate_identifier_query.rb b/lib/valkyrie/persistence/solr/queries/find_by_alternate_identifier_query.rb new file mode 100644 index 000000000..e177eca2a --- /dev/null +++ b/lib/valkyrie/persistence/solr/queries/find_by_alternate_identifier_query.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +module Valkyrie::Persistence::Solr::Queries + # Responsible for returning a single resource identified by an ID. + class FindByAlternateIdentifierQuery + attr_reader :connection, :resource_factory + attr_writer :alternate_identifier + def initialize(alternate_identifier, connection:, resource_factory:) + @alternate_identifier = alternate_identifier + @connection = connection + @resource_factory = resource_factory + end + + def run + raise ::Valkyrie::Persistence::ObjectNotFoundError unless resource + resource_factory.to_resource(object: resource) + end + + def alternate_identifier + @alternate_identifier.to_s + end + + def resource + connection.get("select", params: { q: "alternate_ids_ssim:\"id-#{alternate_identifier}\"", fl: "*", rows: 1 })["response"]["docs"].first + end + end +end diff --git a/lib/valkyrie/persistence/solr/query_service.rb b/lib/valkyrie/persistence/solr/query_service.rb index 95ec9e472..adb018c0e 100644 --- a/lib/valkyrie/persistence/solr/query_service.rb +++ b/lib/valkyrie/persistence/solr/query_service.rb @@ -18,6 +18,13 @@ def find_by(id:) Valkyrie::Persistence::Solr::Queries::FindByIdQuery.new(id, connection: connection, resource_factory: resource_factory).run end + # (see Valkyrie::Persistence::Memory::QueryService#find_by_alternate_identifier) + def find_by_alternate_identifier(alternate_identifier:) + alternate_identifier = Valkyrie::ID.new(alternate_identifier.to_s) if alternate_identifier.is_a?(String) + validate_id(alternate_identifier) + Valkyrie::Persistence::Solr::Queries::FindByAlternateIdentifierQuery.new(alternate_identifier, connection: connection, resource_factory: resource_factory).run + end + # (see Valkyrie::Persistence::Memory::QueryService#find_many_by_ids) def find_many_by_ids(ids:) ids.map! do |id| diff --git a/lib/valkyrie/persistence/solr/repository.rb b/lib/valkyrie/persistence/solr/repository.rb index 22628d47c..488e873b4 100644 --- a/lib/valkyrie/persistence/solr/repository.rb +++ b/lib/valkyrie/persistence/solr/repository.rb @@ -39,7 +39,7 @@ def generate_id(resource) end def ensure_multiple_values!(resource) - bad_keys = resource.attributes.except(:internal_resource, :created_at, :updated_at, :new_record, :id).select do |_k, v| + bad_keys = resource.attributes.except(:internal_resource, :alternate_ids, :created_at, :updated_at, :new_record, :id).select do |_k, v| !v.nil? && !v.is_a?(Array) end raise ::Valkyrie::Persistence::UnsupportedDatatype, "#{resource}: #{bad_keys.keys} have non-array values, which can not be persisted by Valkyrie. Cast to arrays." unless bad_keys.keys.empty? diff --git a/lib/valkyrie/specs/shared_specs/queries.rb b/lib/valkyrie/specs/shared_specs/queries.rb index 53df4f23c..ac07d5e63 100644 --- a/lib/valkyrie/specs/shared_specs/queries.rb +++ b/lib/valkyrie/specs/shared_specs/queries.rb @@ -5,6 +5,7 @@ defined? adapter class CustomResource < Valkyrie::Resource attribute :id, Valkyrie::Types::ID.optional + attribute :alternate_ids, Valkyrie::Types::Array attribute :title attribute :member_ids, Valkyrie::Types::Array attribute :a_member_of @@ -25,6 +26,7 @@ class SecondResource < Valkyrie::Resource it { is_expected.to respond_to(:find_all).with(0).arguments } it { is_expected.to respond_to(:find_all_of_model).with_keywords(:model) } it { is_expected.to respond_to(:find_by).with_keywords(:id) } + it { is_expected.to respond_to(:find_by_alternate_identifier).with_keywords(:alternate_identifier) } it { is_expected.to respond_to(:find_many_by_ids).with_keywords(:ids) } it { is_expected.to respond_to(:find_members).with_keywords(:resource, :model) } it { is_expected.to respond_to(:find_references_by).with_keywords(:resource, :property) } @@ -74,6 +76,44 @@ class SecondResource < Valkyrie::Resource end end + describe ".find_by_alternate_identifier" do + it "returns a resource by alternate identifier or string representation of an alternate identifier" do + resource = resource_class.new + resource.alternate_ids = [Valkyrie::ID.new('p9s0xfj')] + resource = persister.save(resource: resource) + + found = query_service.find_by_alternate_identifier(alternate_identifier: resource.alternate_ids.first) + expect(found.id).to eq resource.id + expect(found).to be_persisted + + found = query_service.find_by_alternate_identifier(alternate_identifier: resource.alternate_ids.first.to_s) + expect(found.id).to eq resource.id + expect(found).to be_persisted + end + + it "returns a Valkyrie::Persistence::ObjectNotFoundError for a non-found alternate identifier" do + expect { query_service.find_by_alternate_identifier(alternate_identifier: Valkyrie::ID.new("123123123")) }.to raise_error ::Valkyrie::Persistence::ObjectNotFoundError + end + + it 'raises an error if the alternate identifier is not a Valkyrie::ID or a string' do + expect { query_service.find_by_alternate_identifier(alternate_identifier: 123) }.to raise_error ArgumentError + end + + it 'can have multiple alternate identifiers' do + resource = resource_class.new + resource.alternate_ids = [Valkyrie::ID.new('p9s0xfj'), Valkyrie::ID.new('jks0xfj')] + resource = persister.save(resource: resource) + + found = query_service.find_by_alternate_identifier(alternate_identifier: resource.alternate_ids.first) + expect(found.id).to eq resource.id + expect(found).to be_persisted + + found = query_service.find_by_alternate_identifier(alternate_identifier: resource.alternate_ids.last) + expect(found.id).to eq resource.id + expect(found).to be_persisted + end + end + describe ".find_many_by_ids" do let!(:resource) { persister.save(resource: resource_class.new) } let!(:resource2) { persister.save(resource: resource_class.new) } diff --git a/spec/valkyrie/persistence/fedora/persister_spec.rb b/spec/valkyrie/persistence/fedora/persister_spec.rb index 64b4a6e2a..e0978c22d 100644 --- a/spec/valkyrie/persistence/fedora/persister_spec.rb +++ b/spec/valkyrie/persistence/fedora/persister_spec.rb @@ -40,4 +40,86 @@ class CustomResource < Valkyrie::Resource expect(reloaded).to be_persisted end end + + context "when given an alternate identifier" do + before do + raise 'persister must be set with `let(:persister)`' unless defined? persister + class CustomResource < Valkyrie::Resource + include Valkyrie::Resource::AccessControls + attribute :id, Valkyrie::Types::ID.optional + attribute :alternate_ids, Valkyrie::Types::Array + attribute :title + attribute :author + attribute :member_ids + attribute :nested_resource + end + end + after do + Object.send(:remove_const, :CustomResource) + end + let(:resource_class) { CustomResource } + + it "creates an alternate identifier resource" do + alternate_identifier = Valkyrie::ID.new("alternative") + resource = resource_class.new + resource.alternate_ids = [alternate_identifier] + persister.save(resource: resource) + + alternate = query_service.find_by(id: alternate_identifier) + expect(alternate.id).to eq alternate_identifier + expect(alternate).to be_persisted + end + + it "updates an alternate identifier resource" do + alternate_identifier = Valkyrie::ID.new("alternative") + resource = resource_class.new + resource.alternate_ids = [alternate_identifier] + reloaded = persister.save(resource: resource) + + alternate = query_service.find_by(id: alternate_identifier) + expect(alternate.id).to eq alternate_identifier + expect(alternate).to be_persisted + + alternate_identifier = Valkyrie::ID.new("alternate") + reloaded.alternate_ids = [alternate_identifier] + persister.save(resource: reloaded) + expect(query_service.find_by_alternate_identifier(alternate_identifier: alternate_identifier).id).to eq reloaded.id + end + + it "deletes the alternate identifier with the resource" do + alternate_identifier = Valkyrie::ID.new("alternative") + resource = resource_class.new + resource.alternate_ids = [alternate_identifier] + reloaded = persister.save(resource: resource) + + alternate = query_service.find_by(id: alternate_identifier) + expect(alternate.id).to eq alternate_identifier + expect(alternate).to be_persisted + + persister.delete(resource: reloaded) + expect { query_service.find_by(id: alternate_identifier) }.to raise_error(Valkyrie::Persistence::ObjectNotFoundError) + end + + it "deletes removed alternate identifiers" do + alternate_identifier = Valkyrie::ID.new("altfirst") + second_alternate_identifier = Valkyrie::ID.new("altsecond") + resource = resource_class.new + resource.alternate_ids = [alternate_identifier, second_alternate_identifier] + reloaded = persister.save(resource: resource) + + alternate = query_service.find_by(id: second_alternate_identifier) + expect(alternate.id).to eq second_alternate_identifier + expect(alternate).to be_persisted + + reload = query_service.find_by(id: reloaded.id) + reload.alternate_ids = [alternate_identifier] + persister.save(resource: reload) + + alternate = query_service.find_by(id: alternate_identifier) + expect(alternate.id).to eq alternate_identifier + expect(alternate).to be_persisted + + expect { query_service.find_by(id: second_alternate_identifier) }.to raise_error(Valkyrie::Persistence::ObjectNotFoundError) + end + end end