From c9e564b3b5aaf3462ce51eda7ed0cacb09202e9c Mon Sep 17 00:00:00 2001 From: Martin Voigt Date: Mon, 27 Jul 2026 17:18:06 +0200 Subject: [PATCH 1/5] Add rdf and dc serialization to events for oai-pmh serialization --- app/models/event.rb | 58 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/app/models/event.rb b/app/models/event.rb index 975db13f1..7f74f3c87 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -507,6 +507,64 @@ def self.from_varied_providers(events, count) events end + def to_rdf + jsonld_str = to_bioschemas[0].to_json + + graph = RDF::Graph.new + JSON::LD::Reader.new(jsonld_str) do |reader| + reader.each_statement { |stmt| graph << stmt } + end + + rdfxml_str = graph.dump(:rdfxml, prefixes: { sdo: 'http://schema.org/', dc: 'http://purl.org/dc/terms/' }) + rdfxml_str.sub(/\A<\?xml.*?\?>\s*/, '') # remove XML declaration because this is used inside OAI-PMH response + end + + def to_oai_dc + xml = ::Builder::XmlMarkup.new + xml.tag!('oai_dc:dc', + 'xmlns:oai_dc' => 'http://www.openarchives.org/OAI/2.0/oai_dc/', + 'xmlns:dc' => 'http://purl.org/dc/elements/1.1/', + 'xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance', + 'xsi:schemaLocation' => 'http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd') do + xml.tag!('dc:title', title) + xml.tag!('dc:description', description) + xml.tag!('dc:creator', organizer) if organizer.present? + instructors.each { |c| xml.tag!('dc:creator', c.display_name) } + contributors.each { |c| xml.tag!('dc:contributor', c.display_name) } + xml.tag!('dc:publisher', contact) if contact.present? + + + xml.tag!('dc:format', 'text/html') + xml.tag!('dc:language', language) if language.present? + + [start, self.end].compact.each do |d| + xml.tag!('dc:date', d.iso8601) + end + + xml.tag!('dc:identifier', url) + + (keywords + scientific_topics.map(&:uri) + operations.map(&:uri)).each do |s| + xml.tag!('dc:subject', s) + end + + xml.tag!('dc:type', 'http://purl.org/dc/dcmitype/Event') + xml.tag!('dc:type', 'https://schema.org/Event') + xml.tag!('dc:type', self.presence.to_s + ' event') + + xml.tag!('dc:relation', "#{TeSS::Config.base_url}#{Rails.application.routes.url_helpers.event_path(self)}") + xml.tag!('dc:relation', content_provider.url) if content_provider&.url + materials.each do |m| + if m.doi.present? + doi_iri = m.doi.start_with?('http://', 'https://') ? m.doi : "https://doi.org/#{m.doi}" + xml.tag!('dc:relation', doi_iri) + else + xml.tag!('dc:relation', m.url) + end + end + end + xml.target! + end + private def allowed_url From 5941e6a62c1536b59290799090a947e2093f4934 Mon Sep 17 00:00:00 2001 From: Martin Voigt Date: Tue, 28 Jul 2026 15:30:13 +0200 Subject: [PATCH 2/5] Initial implementation for merged Events and Materials in OAI-PMH endpoint --- app/controllers/oai_controller.rb | 2 +- config/initializers/oai_provider.rb | 87 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/app/controllers/oai_controller.rb b/app/controllers/oai_controller.rb index d9e6f8557..b0286e9cd 100644 --- a/app/controllers/oai_controller.rb +++ b/app/controllers/oai_controller.rb @@ -6,7 +6,7 @@ class OaiController < ApplicationController # GET /oai-pmh def index provider = TrainingProvider.new({ provider_context: :instance_based }) - provider.model = OAI::Provider::ActiveRecordWrapper.new(Material.in_current_space.where(visible: true)) + provider.model = MultiModel.new([Material.in_current_space.where(visible: true), Event.in_current_space.where(visible: true)]) response = provider.process_request(oai_params.to_h) # add XSLT prefix diff --git a/config/initializers/oai_provider.rb b/config/initializers/oai_provider.rb index 97e58b827..72833d952 100644 --- a/config/initializers/oai_provider.rb +++ b/config/initializers/oai_provider.rb @@ -3,6 +3,93 @@ require 'oai' require 'uri' +class MultiModel < OAI::Provider::Model + # represents multiple rails models in one OAI-PMH model. + # It assumes OAI-PMH identifiers are of the form: / (e.g. materials/142) + + attr_reader :model_scopes + + def initialize(model_scopes, limit = nil, timestamp_field = 'updated_at', identifier_field = 'id') + super(limit || 3, timestamp_field, identifier_field) + @model_scopes = model_scopes + end + + def earliest + model_scopes.filter_map { |scope| scope.minimum(timestamp_field) }.min || Time.at(0).utc + end + + def latest + model_scopes.filter_map { |scope| scope.maximum(timestamp_field) }.max || Time.at(0).utc + end + + def sets + model_scopes.map do |scope| + n = scope.model.model_name + OAI::Set.new({spec: n.route_key, name: n.plural.titleize, description: "Set of all training #{n.plural.humanize.downcase}"}) + end + end + + # selector can be a singular id, or the symbol :all + def find(selector, options={}) + return find_by_id(selector) unless selector == :all + + from_date = options[:from] + if options[:resumptionToken] + resumption_token = OAI::Provider::ResumptionToken.parse(options[:resumptionToken]) + options = resumption_token.to_conditions_hash + from_date = Date.parse(options[:resume_at]) if options[:resume_at] + end + + scopes = model_scopes + scopes = scopes.select { |scope| scope.model.model_name.route_key == options[:set] } if options[:set] + scopes = scopes.map { |scope| scope.where("#{timestamp_field} >= ?", from_date) } if from_date + scopes = scopes.map { |scope| scope.where("#{timestamp_field} <= ?", options[:until]) } if options[:until] + enumerators = scopes.map { |scope| scope.order(timestamp_field => :asc, id: :asc).limit(limit + 1).to_enum } + + results = [] + skipped_results = [] + skip_until = resumption_token.next_item if resumption_token&.next_item + while results.size < limit + 1 + enumerators = enumerators.filter { |enum| enum.peek rescue false } + break if enumerators.empty? + # min_by returns the first minimum resulting in deterministic order if different scopes have the same timestamp. + min_enum = enumerators.min_by { |enum| enum.peek.send(timestamp_field) } + result = min_enum.next + if skip_until && result.oai_identifier == skip_until + skip_until = nil + end + if skip_until && result.send(timestamp_field) > from_date + # don't miss results in case skip_until item is not found + skip_until = nil + results.concat(skipped_results) + end + if skip_until + skipped_results << result + else + results << result + end + end + return results if results.size <= limit + + next_item = results.pop + # Resumption token follows dual approach with coarse skip to resume_at and precise skip to next_item. + resumption_token = OAI::Provider::ResumptionToken.new(options.merge({ + next_item: next_item.oai_identifier, resume_at: next_item.send(timestamp_field).iso8601 + })) + return OAI::Provider::PartialResult.new(results, resumption_token) + end + + private + + def find_by_id(selector) + route_key, id = selector.to_s.split('/', 2) + scope = model_scopes.find { |s| s.model.model_name.route_key == route_key } + return nil unless scope + + scope.find_by(id: id) + end +end + class OAIRDF < OAI::Provider::Metadata::Format def initialize @prefix = 'rdf' From d2b587ff290f05fbcec78b1e0bfaaab225c920b7 Mon Sep 17 00:00:00 2001 From: Martin Voigt Date: Tue, 28 Jul 2026 18:11:03 +0200 Subject: [PATCH 3/5] Testing events in OAI-PMH endpoint --- config/initializers/oai_provider.rb | 35 ++++---- test/controllers/oai_controller_test.rb | 102 ++++++++++++++++++++++++ test/models/event_test.rb | 35 ++++++++ 3 files changed, 154 insertions(+), 18 deletions(-) diff --git a/config/initializers/oai_provider.rb b/config/initializers/oai_provider.rb index 72833d952..c10827bcf 100644 --- a/config/initializers/oai_provider.rb +++ b/config/initializers/oai_provider.rb @@ -10,7 +10,7 @@ class MultiModel < OAI::Provider::Model attr_reader :model_scopes def initialize(model_scopes, limit = nil, timestamp_field = 'updated_at', identifier_field = 'id') - super(limit || 3, timestamp_field, identifier_field) + super(limit || 100, timestamp_field, identifier_field) @model_scopes = model_scopes end @@ -33,22 +33,21 @@ def sets def find(selector, options={}) return find_by_id(selector) unless selector == :all - from_date = options[:from] - if options[:resumptionToken] - resumption_token = OAI::Provider::ResumptionToken.parse(options[:resumptionToken]) + skip_until = nil + if options[:resumption_token] + resumption_token = OAI::Provider::ResumptionToken.parse(options[:resumption_token]) options = resumption_token.to_conditions_hash - from_date = Date.parse(options[:resume_at]) if options[:resume_at] + skip_until = resumption_token.last_str end scopes = model_scopes scopes = scopes.select { |scope| scope.model.model_name.route_key == options[:set] } if options[:set] - scopes = scopes.map { |scope| scope.where("#{timestamp_field} >= ?", from_date) } if from_date - scopes = scopes.map { |scope| scope.where("#{timestamp_field} <= ?", options[:until]) } if options[:until] - enumerators = scopes.map { |scope| scope.order(timestamp_field => :asc, id: :asc).limit(limit + 1).to_enum } + scopes = scopes.map { |scope| scope.where("#{timestamp_field} >= ?", options[:from]) } if options[:from] + scopes = scopes.map { |scope| scope.where("#{timestamp_field} < ?", options[:until] + 1.second) } if options[:until] + enumerators = scopes.map { |scope| scope.order(timestamp_field => :asc, id: :asc).to_enum } results = [] skipped_results = [] - skip_until = resumption_token.next_item if resumption_token&.next_item while results.size < limit + 1 enumerators = enumerators.filter { |enum| enum.peek rescue false } break if enumerators.empty? @@ -57,25 +56,25 @@ def find(selector, options={}) result = min_enum.next if skip_until && result.oai_identifier == skip_until skip_until = nil + next end - if skip_until && result.send(timestamp_field) > from_date - # don't miss results in case skip_until item is not found + if skip_until && result.send(timestamp_field) > options[:from] + 1.days + # If the marker record is gone or changed, stop skipping so records are not lost. skip_until = nil results.concat(skipped_results) end if skip_until skipped_results << result - else - results << result + next end + + results << result end return results if results.size <= limit + results.pop - next_item = results.pop - # Resumption token follows dual approach with coarse skip to resume_at and precise skip to next_item. - resumption_token = OAI::Provider::ResumptionToken.new(options.merge({ - next_item: next_item.oai_identifier, resume_at: next_item.send(timestamp_field).iso8601 - })) + last_returned = results.last + resumption_token = OAI::Provider::ResumptionToken.new(options.merge(from: last_returned.send(timestamp_field), last: last_returned.oai_identifier)) return OAI::Provider::PartialResult.new(results, resumption_token) end diff --git a/test/controllers/oai_controller_test.rb b/test/controllers/oai_controller_test.rb index e3f73f299..0e6cf2297 100644 --- a/test/controllers/oai_controller_test.rb +++ b/test/controllers/oai_controller_test.rb @@ -3,6 +3,7 @@ class OaiControllerTest < ActionDispatch::IntegrationTest setup do @material = materials(:good_material) + @event = events(:two) @user = users(:regular_user) @material.user_id = @user.id @material.save! @@ -38,6 +39,17 @@ class OaiControllerTest < ActionDispatch::IntegrationTest assert_includes prefixes, 'rdf' end + test 'OAI ListSets exposes sets for materials and events' do + get '/oai-pmh', params: { verb: 'ListSets' } + assert_response :success + + parsed = Nokogiri::XML(@response.body) + set_specs = parsed.xpath('//oai:ListSets/oai:set/oai:setSpec', @ns).map(&:text) + + assert_includes set_specs, 'materials' + assert_includes set_specs, 'events' + end + test 'OAI ListRecords returns material in oai_dc format' do get '/oai-pmh', params: { verb: 'ListRecords', metadataPrefix: 'oai_dc' } assert_response :success @@ -53,6 +65,22 @@ class OaiControllerTest < ActionDispatch::IntegrationTest assert_includes identifiers, @material.doi end + test 'OAI ListRecords returns event in oai_dc format' do + get '/oai-pmh', params: { verb: 'ListRecords', metadataPrefix: 'oai_dc' } + assert_response :success + + parsed = Nokogiri::XML(@response.body) + event_records = parsed.xpath("//oai:record[contains(oai:header/oai:identifier, 'events/') ]", @ns) + + refute_empty event_records + event_titles = event_records.xpath('.//dc:title', @ns).map(&:text) + assert_includes event_titles, @event.title + + event_types = event_records.xpath('.//dc:type', @ns).map(&:text) + assert_includes event_types, 'http://purl.org/dc/dcmitype/Event' + assert_includes event_types, 'https://schema.org/Event' + end + test 'OAI-PMH endpoint respects current space' do get '/oai-pmh', params: { verb: 'ListRecords', metadataPrefix: 'oai_dc' } parsed = Nokogiri::XML(@response.body) @@ -83,6 +111,19 @@ class OaiControllerTest < ActionDispatch::IntegrationTest assert_includes keywords, 'good' end + test 'OAI ListRecords returns event in rdf format' do + get '/oai-pmh', params: { verb: 'ListRecords', metadataPrefix: 'rdf' } + assert_response :success + + parsed = Nokogiri::XML(@response.body) + + event_names = parsed.xpath('//sdo:Event/sdo:name', @ns).map(&:text) + assert_includes event_names, @event.title + + event_urls = parsed.xpath('//sdo:Event/sdo:url/@rdf:resource', @ns).map(&:value) + assert_includes event_urls, @event.url + end + test 'OAI ListRecords returns only visible materials' do get '/oai-pmh', params: { verb: 'ListRecords', metadataPrefix: 'rdf' } assert_response :success @@ -98,4 +139,65 @@ class OaiControllerTest < ActionDispatch::IntegrationTest parsed = Nokogiri::XML(@response.body) refute_includes parsed.xpath('//sdo:name', @ns).map(&:text), 'Training Material Example' end + + test 'OAI ListRecords resumes with resumption token' do + user = users(:regular_user) + base_count = Event.where(visible: true).count + Material.where(visible: true).count + records_needed = [0, 105 - base_count].max + + records_needed.times do |i| + Event.create!(title: "Paged event #{i}", url: "https://example.org/paged-events/#{i}", user: user) + end + + get '/oai-pmh', params: { verb: 'ListRecords', metadataPrefix: 'rdf' } + assert_response :success + + parsed = Nokogiri::XML(@response.body) + token = parsed.at_xpath('//oai:ListRecords/oai:resumptionToken', @ns)&.text + assert token.present?, 'Expected non-empty resumptionToken for paginated result set' + + get '/oai-pmh', params: { verb: 'ListRecords', resumptionToken: token } + assert_response :success + + parsed = Nokogiri::XML(@response.body) + refute parsed.at_xpath("//oai:error[@code='cannotDisseminateFormat']", @ns) + assert_operator parsed.xpath('//oai:ListRecords/oai:record', @ns).length, :>, 0 + end + + test 'OAI resumption token does not repeat the last returned record at a same-timestamp page boundary' do + user = users(:regular_user) + boundary_space = Space.create!(title: 'Boundary Space', host: 'boundary-space.example', user: user) + boundary_time = Time.utc(2026, 7, 28, 12, 0, 0) + + records = 101.times.map do |i| + material = Material.create!(title: "Boundary material #{i}", + description: "Boundary material #{i}", + url: "https://example.org/boundary-material/#{i}", + user: user, + visible: true, + space: boundary_space) + material.update_columns(updated_at: boundary_time, created_at: boundary_time) + material + end + + with_host(boundary_space.host) do + get '/oai-pmh', params: { verb: 'ListRecords', metadataPrefix: 'rdf' } + assert_response :success + + parsed = Nokogiri::XML(@response.body) + first_page_titles = parsed.xpath('//sdo:LearningResource/sdo:name', @ns).map(&:text) + assert_equal 100, first_page_titles.length + + token = parsed.at_xpath('//oai:ListRecords/oai:resumptionToken', @ns)&.text + assert token.present?, 'Expected non-empty resumptionToken for the first page' + + get '/oai-pmh', params: { verb: 'ListRecords', resumptionToken: token } + assert_response :success + + parsed = Nokogiri::XML(@response.body) + second_page_titles = parsed.xpath('//sdo:LearningResource/sdo:name', @ns).map(&:text) + refute_includes second_page_titles, first_page_titles.last + assert_includes second_page_titles, records.last.title + end + end end diff --git a/test/models/event_test.rb b/test/models/event_test.rb index d4f6140a0..2fde40d25 100644 --- a/test/models/event_test.rb +++ b/test/models/event_test.rb @@ -775,6 +775,41 @@ class EventTest < ActiveSupport::TestCase assert_equal 'http://mygoblet.org', bioschemas[:provider].first['url'] end + test 'serializes event to oai_dc xml' do + event = events(:one) + material = materials(:good_material) + event.materials << material unless event.materials.include?(material) + + parsed = Nokogiri::XML(event.to_oai_dc) + ns = { + 'dc' => 'http://purl.org/dc/elements/1.1/', + 'oai_dc' => 'http://www.openarchives.org/OAI/2.0/oai_dc/' + } + + assert_equal 'oai_dc', parsed.root.namespace.prefix + assert_equal event.title, parsed.at_xpath('//dc:title', ns).text + assert_equal event.url, parsed.at_xpath('//dc:identifier', ns).text + + dc_types = parsed.xpath('//dc:type', ns).map(&:text) + assert_includes dc_types, 'http://purl.org/dc/dcmitype/Event' + assert_includes dc_types, 'https://schema.org/Event' + + relations = parsed.xpath('//dc:relation', ns).map(&:text) + assert_includes relations, material.doi + end + + test 'serializes event to rdf xml' do + event = events(:two) + parsed = Nokogiri::XML(event.to_rdf) + ns = { + 'rdf' => 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', + 'sdo' => 'http://schema.org/' + } + + assert_includes parsed.xpath('//sdo:name', ns).map(&:text), event.title + assert_includes parsed.xpath('//sdo:url/@rdf:resource', ns).map(&:value), event.url + end + test 'does not destroy and recreate ontology term links' do e = events(:scraper_user_event) From 8b5747d95d9a837e6c9724e5f2e6ad2d12acd972 Mon Sep 17 00:00:00 2001 From: Martin Voigt Date: Wed, 29 Jul 2026 09:04:19 +0200 Subject: [PATCH 4/5] rubocop code style changes for events in OAI-PMH implementation --- app/models/event.rb | 16 ++++++++-------- config/initializers/oai_provider.rb | 18 ++++++++++++------ test/controllers/oai_controller_test.rb | 6 +++--- test/models/event_test.rb | 20 +++++++++----------- 4 files changed, 32 insertions(+), 28 deletions(-) diff --git a/app/models/event.rb b/app/models/event.rb index 7f74f3c87..888558acd 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -493,14 +493,15 @@ def self.from_varied_providers(events, count) events = [] events_left = true - while events_left && (events.length < count) do + while events_left && (events.length < count) events_left = false provider_events.each_value do |p_events| - if p_events.any? - events << p_events.shift - break if events.length == count - events_left ||= p_events.any? - end + next unless p_events.any? + + events << p_events.shift + break if events.length == count + + events_left ||= p_events.any? end end @@ -532,7 +533,6 @@ def to_oai_dc instructors.each { |c| xml.tag!('dc:creator', c.display_name) } contributors.each { |c| xml.tag!('dc:contributor', c.display_name) } xml.tag!('dc:publisher', contact) if contact.present? - xml.tag!('dc:format', 'text/html') xml.tag!('dc:language', language) if language.present? @@ -549,7 +549,7 @@ def to_oai_dc xml.tag!('dc:type', 'http://purl.org/dc/dcmitype/Event') xml.tag!('dc:type', 'https://schema.org/Event') - xml.tag!('dc:type', self.presence.to_s + ' event') + xml.tag!('dc:type', presence.to_s + ' event') xml.tag!('dc:relation', "#{TeSS::Config.base_url}#{Rails.application.routes.url_helpers.event_path(self)}") xml.tag!('dc:relation', content_provider.url) if content_provider&.url diff --git a/config/initializers/oai_provider.rb b/config/initializers/oai_provider.rb index c10827bcf..2aa80fdcf 100644 --- a/config/initializers/oai_provider.rb +++ b/config/initializers/oai_provider.rb @@ -4,7 +4,7 @@ require 'uri' class MultiModel < OAI::Provider::Model - # represents multiple rails models in one OAI-PMH model. + # Represents multiple rails models in one OAI-PMH model. # It assumes OAI-PMH identifiers are of the form: / (e.g. materials/142) attr_reader :model_scopes @@ -25,12 +25,12 @@ def latest def sets model_scopes.map do |scope| n = scope.model.model_name - OAI::Set.new({spec: n.route_key, name: n.plural.titleize, description: "Set of all training #{n.plural.humanize.downcase}"}) + OAI::Set.new({ spec: n.route_key, name: n.plural.titleize, description: "Set of all training #{n.plural.humanize.downcase}" }) end end # selector can be a singular id, or the symbol :all - def find(selector, options={}) + def find(selector, options = {}) return find_by_id(selector) unless selector == :all skip_until = nil @@ -49,8 +49,13 @@ def find(selector, options={}) results = [] skipped_results = [] while results.size < limit + 1 - enumerators = enumerators.filter { |enum| enum.peek rescue false } + enumerators = enumerators.filter do |enum| + enum.peek + rescue StopIteration + false + end break if enumerators.empty? + # min_by returns the first minimum resulting in deterministic order if different scopes have the same timestamp. min_enum = enumerators.min_by { |enum| enum.peek.send(timestamp_field) } result = min_enum.next @@ -71,11 +76,12 @@ def find(selector, options={}) results << result end return results if results.size <= limit + results.pop last_returned = results.last resumption_token = OAI::Provider::ResumptionToken.new(options.merge(from: last_returned.send(timestamp_field), last: last_returned.oai_identifier)) - return OAI::Provider::PartialResult.new(results, resumption_token) + OAI::Provider::PartialResult.new(results, resumption_token) end private @@ -85,7 +91,7 @@ def find_by_id(selector) scope = model_scopes.find { |s| s.model.model_name.route_key == route_key } return nil unless scope - scope.find_by(id: id) + scope.find_by(id:) end end diff --git a/test/controllers/oai_controller_test.rb b/test/controllers/oai_controller_test.rb index 0e6cf2297..a49f6af7a 100644 --- a/test/controllers/oai_controller_test.rb +++ b/test/controllers/oai_controller_test.rb @@ -146,7 +146,7 @@ class OaiControllerTest < ActionDispatch::IntegrationTest records_needed = [0, 105 - base_count].max records_needed.times do |i| - Event.create!(title: "Paged event #{i}", url: "https://example.org/paged-events/#{i}", user: user) + Event.create!(title: "Paged event #{i}", url: "https://example.org/paged-events/#{i}", user:) end get '/oai-pmh', params: { verb: 'ListRecords', metadataPrefix: 'rdf' } @@ -166,14 +166,14 @@ class OaiControllerTest < ActionDispatch::IntegrationTest test 'OAI resumption token does not repeat the last returned record at a same-timestamp page boundary' do user = users(:regular_user) - boundary_space = Space.create!(title: 'Boundary Space', host: 'boundary-space.example', user: user) + boundary_space = Space.create!(title: 'Boundary Space', host: 'boundary-space.example', user:) boundary_time = Time.utc(2026, 7, 28, 12, 0, 0) records = 101.times.map do |i| material = Material.create!(title: "Boundary material #{i}", description: "Boundary material #{i}", url: "https://example.org/boundary-material/#{i}", - user: user, + user:, visible: true, space: boundary_space) material.update_columns(updated_at: boundary_time, created_at: boundary_time) diff --git a/test/models/event_test.rb b/test/models/event_test.rb index 2fde40d25..55ae09150 100644 --- a/test/models/event_test.rb +++ b/test/models/event_test.rb @@ -713,15 +713,15 @@ class EventTest < ActiveSupport::TestCase provider_1 = content_providers(:goblet) provider_2 = content_providers(:iann) provider_3 = content_providers(:two) - e1a = provider_1.events.create!(title: 'Event1a', url: 'https://example.com/events/1a', user: user) - e1b = provider_1.events.create!(title: 'Event1b', url: 'https://example.com/events/1b', user: user) - e1c = provider_1.events.create!(title: 'Event1c', url: 'https://example.com/events/1c', user: user) - e2a = provider_2.events.create!(title: 'Event2a', url: 'https://example.com/events/2a', user: user) - e2b = provider_2.events.create!(title: 'Event2b', url: 'https://example.com/events/2b', user: user) - e2c = provider_2.events.create!(title: 'Event2c', url: 'https://example.com/events/2c', user: user) - e3a = provider_3.events.create!(title: 'Event3a', url: 'https://example.com/events/3a', user: user) - e3b = provider_3.events.create!(title: 'Event3b', url: 'https://example.com/events/3b', user: user) - e3c = provider_3.events.create!(title: 'Event3c', url: 'https://example.com/events/3c', user: user) + e1a = provider_1.events.create!(title: 'Event1a', url: 'https://example.com/events/1a', user:) + e1b = provider_1.events.create!(title: 'Event1b', url: 'https://example.com/events/1b', user:) + e1c = provider_1.events.create!(title: 'Event1c', url: 'https://example.com/events/1c', user:) + e2a = provider_2.events.create!(title: 'Event2a', url: 'https://example.com/events/2a', user:) + e2b = provider_2.events.create!(title: 'Event2b', url: 'https://example.com/events/2b', user:) + e2c = provider_2.events.create!(title: 'Event2c', url: 'https://example.com/events/2c', user:) + e3a = provider_3.events.create!(title: 'Event3a', url: 'https://example.com/events/3a', user:) + e3b = provider_3.events.create!(title: 'Event3b', url: 'https://example.com/events/3b', user:) + e3c = provider_3.events.create!(title: 'Event3c', url: 'https://example.com/events/3c', user:) even_single_mix = Event.from_varied_providers([e1a, e1b, e1c, e2a, e2b, e2c, e3a, e3b, e3c], 3) assert_equal 3, even_single_mix.length @@ -857,7 +857,6 @@ class EventTest < ActiveSupport::TestCase person1 = Person.new(resource: @event, name: 'Alice Wonder') person2 = Person.new(resource: @event, name: 'Bob Builder') - assert_not_includes @event.contributors.map(&:name), person1.name @event.contributors = [person1, person2] @@ -887,7 +886,6 @@ class EventTest < ActiveSupport::TestCase person1 = Person.new(resource: @event, name: 'Alice Wonder') person2 = Person.new(resource: @event, name: 'Bob Builder') - assert_not_includes @event.instructors.map(&:name), person1.name @event.instructors = [person1, person2] From d6f018ece804be5127f922b3e174000c397b8c63 Mon Sep 17 00:00:00 2001 From: Martin Voigt Date: Wed, 29 Jul 2026 14:45:22 +0200 Subject: [PATCH 5/5] Use identifier field instead of id field for secondary ordering of resources with same date in oai-pmh endpoint Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- config/initializers/oai_provider.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/oai_provider.rb b/config/initializers/oai_provider.rb index 2aa80fdcf..9cd3bb168 100644 --- a/config/initializers/oai_provider.rb +++ b/config/initializers/oai_provider.rb @@ -44,7 +44,7 @@ def find(selector, options = {}) scopes = scopes.select { |scope| scope.model.model_name.route_key == options[:set] } if options[:set] scopes = scopes.map { |scope| scope.where("#{timestamp_field} >= ?", options[:from]) } if options[:from] scopes = scopes.map { |scope| scope.where("#{timestamp_field} < ?", options[:until] + 1.second) } if options[:until] - enumerators = scopes.map { |scope| scope.order(timestamp_field => :asc, id: :asc).to_enum } + enumerators = scopes.map { |scope| scope.order(timestamp_field => :asc, identifier_field => :asc).to_enum } results = [] skipped_results = []