Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/matrix.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"api": [null],
"additional_engine_cart_rails_options": [""],
"additional_name": [""],
"repository": [null],
"include": [
{
"ruby": "3.4",
Expand All @@ -25,6 +26,13 @@
"view_component_version": "~> 3.0",
"additional_name": "ViewComponent 3"
},
{
"ruby": "3.4",
"rails_version": "8.1.1",
"additional_engine_cart_rails_options": "--css=bootstrap",
"repository": "elasticsearch",
"additional_name": "| Elasticsearch"
},
{
"ruby": "3.3",
"rails_version": "8.0.3",
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
ref:
required: false
type: string
default: ''
default: ""
description: The branch or reference to run the workflow against
jobs:
set_matrix:
Expand Down Expand Up @@ -35,6 +35,7 @@ jobs:
VIEW_COMPONENT_VERSION: ${{ matrix.view_component_version }}
BOOTSTRAP_VERSION: ${{ matrix.bootstrap_version }}
BLACKLIGHT_API_TEST: ${{ matrix.api }}
BLACKLIGHT_REPOSITORY: ${{ matrix.repository }}
ENGINE_CART_RAILS_OPTIONS: "--skip-git --skip-listen --skip-spring --skip-keeps --skip-kamal --skip-solid --skip-coffee --skip-test ${{ matrix.additional_engine_cart_rails_options }}"
steps:
- uses: actions/checkout@v4
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ def button_component
end

def render?
# debugger
body.present?
end
end
Expand Down
4 changes: 2 additions & 2 deletions app/components/blacklight/search_header_component.html.erb
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
<%= render 'did_you_mean' %>
<%= render 'sort_and_per_page' %>
<%= render 'did_you_mean' if did_you_mean? %>
<%= render 'sort_and_per_page' %>
5 changes: 5 additions & 0 deletions app/components/blacklight/search_header_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,10 @@

module Blacklight
class SearchHeaderComponent < Blacklight::Component
# Should we draw the did_you_mean component?
# Currently not supported with elasticsearch
def did_you_mean?
Blacklight.solr?
end
end
end
3 changes: 1 addition & 2 deletions app/services/blacklight/bookmarks_search_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,11 @@ class BookmarksSearchBuilder < ::SearchBuilder
#
# @return [void]
def bookmarked(solr_parameters)
solr_parameters[:fq] ||= []
bookmarks = @scope.context.fetch(:bookmarks)
return unless bookmarks

document_ids = bookmarks.collect { |b| b.document_id.to_s }
solr_parameters[:fq] += ["{!terms f=id}#{document_ids.join(',')}"]
limit_to_specific_records(solr_parameters, document_ids)
end
self.default_processor_chain += [:bookmarked]
end
Expand Down
28 changes: 4 additions & 24 deletions app/services/blacklight/search_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ def search_results

builder = yield(builder) if block_given?
response = repository.search(params: builder)

if response.grouped? && grouped_key_for_results
response.group(grouped_key_for_results)
elsif response.grouped? && response.grouped.length == 1
Expand Down Expand Up @@ -74,7 +73,9 @@ def facet_suggest_response(facet_field, facet_suggestion_query, extra_controller
def previous_and_next_documents_for_search(index, request_params, extra_controller_params = {})
p = previous_and_next_document_params(index)
new_state = request_params.is_a?(Blacklight::SearchState) ? request_params : Blacklight::SearchState.new(request_params, blacklight_config)
query = search_builder.with(new_state).start(p.delete(:start)).rows(p.delete(:rows)).merge(extra_controller_params).merge(p)
builder = search_builder.with(new_state)
builder.processor_chain.delete(:add_aggregation)
query = builder.start(p.delete(:start)).rows(p.delete(:rows)).merge(extra_controller_params).merge(p)
response = repository.search(params: query)
document_list = response.documents

Expand Down Expand Up @@ -120,35 +121,14 @@ def solr_opensearch_params(field)
solr_params
end

##
# Pagination parameters for selecting the previous and next documents
# out of a result set.
def previous_and_next_document_params(index, window = 1)
solr_params = blacklight_config.document_pagination_params.dup

if solr_params.empty?
solr_params[:fl] = blacklight_config.document_model.unique_key
end

if index > 0
solr_params[:start] = index - window # get one before
solr_params[:rows] = (2 * window) + 1 # and one after
else
solr_params[:start] = 0 # there is no previous doc
solr_params[:rows] = 2 * window # but there should be one after
end

solr_params[:facet] = false
solr_params
end
delegate :previous_and_next_document_params, to: :search_builder

##
# Retrieve a set of documents by id
# @param [Array] ids
# @param [HashWithIndifferentAccess] extra_controller_params
def fetch_many(ids, extra_controller_params)
extra_controller_params ||= {}

query = search_builder
.with(search_state)
.where(blacklight_config.document_model.unique_key => ids)
Expand Down
1 change: 1 addition & 0 deletions blacklight.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Gem::Specification.new do |s|
s.add_dependency "zeitwerk"

s.add_development_dependency "rsolr", ">= 1.0.6", "< 3" # Library for interacting with rSolr.
s.add_development_dependency "elasticsearch"
s.add_development_dependency "rspec-rails", "~> 7.0"
s.add_development_dependency "rspec-collection_matchers", ">= 1.0"
s.add_development_dependency 'axe-core-rspec'
Expand Down
12 changes: 12 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,15 @@ services:
- /opt/solr/conf
- "-Xms256m"
- "-Xmx512m"

elasticsearch:
environment:
- discovery.type=single-node
- xpack.security.enabled=false
image: elasticsearch:8.17.3
ports:
- 9200:9200
volumes:
- es_data:/usr/share/elasticsearch/data
volumes:
es_data:
9 changes: 8 additions & 1 deletion lib/blacklight.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,15 @@ def self.default_index=(repository)
Blacklight::RuntimeRegistry.connection = repository
end

# @return [Bool] Are we configured to use solr?
def self.solr?
repository_class == Blacklight::Solr::Repository
end

##
# The configured repository class. By convention, this is
# the class Blacklight::(name of the adapter)::Repository, e.g.
# elastic_search => Blacklight::ElasticSearch::Repository
# elasticsearch => Blacklight::ElasticSearch::Repository
def self.repository_class
if connection_config && !connection_config.key?(:adapter)
raise "The value for :adapter was not found in the blacklight.yml config"
Expand All @@ -36,6 +41,8 @@ def self.repository_class
case connection_config&.fetch(:adapter) || 'solr'
when 'solr'
Blacklight::Solr::Repository
when 'elasticsearch'
Blacklight::Elasticsearch::Repository
when /::/
connection_config[:adapter].constantize
else
Expand Down
8 changes: 6 additions & 2 deletions lib/blacklight/configuration/facet_field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,12 @@ def normalize! blacklight_config = nil
super

if single && tag.blank? && ex.blank?
self.tag = "#{key}_single"
self.ex = "#{key}_single"
if Blacklight.solr?
self.tag = "#{key}_single"
self.ex = "#{key}_single"
else
Blacklight.logger.warn "the `single' property on the facet configuration (for #{key}) is not yet supported for elasticsearch"
end
end

self
Expand Down
105 changes: 105 additions & 0 deletions lib/blacklight/elasticsearch/repository.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# frozen_string_literal: true

module Blacklight::Elasticsearch
class Repository < Blacklight::AbstractRepository
##
# Find a single solr document result (by id) using the document configuration
# @param [String] id document's unique key value
# @param [Hash] params additional query parameters
def find id, params = {}
api_response = connection.get(index:, id:)
blacklight_config.response_model.new(api_response, params, document_model: blacklight_config.document_model, blacklight_config: blacklight_config)
rescue Elastic::Transport::Transport::Errors::NotFound
raise Blacklight::Exceptions::RecordNotFound
end

# Find multiple documents by their ids
# @param [SearchBuilder] search_builder the search builder
def find_many(search_builder)
# TODO: This is hacky, but SearchBuilder#where is currently very coupled to Solr
ids = search_builder.search_state.params['q']['id']
docs = ids.map { |id| { _index: index, _id: id } }

api_response = connection.mget(body: { docs: })
blacklight_config.response_model.new(api_response, search_builder, document_model: blacklight_config.document_model, blacklight_config: blacklight_config)
end

##
# Execute a search query against solr
# @param [SearchBuilder] params the search builder
def search params: nil, **kwargs
request_params = params.reverse_merge(kwargs)
api_response = connection.search(index:, body: request_params)
blacklight_config.response_model.new(api_response, params, document_model: blacklight_config.document_model, blacklight_config: blacklight_config)
end

def seed_index docs
begin
connection.indices.delete(index:)
rescue StandardError
Elastic::Transport::Transport::Errors::NotFound
end

# TODO: move this to a yaml file
connection.indices.create index:, body: {
settings: {
analysis: {
analyzer: {
foo: {
type: "custom",
tokenizer: "standard",
filter: %w[
lowercase
asciifolding
]
}
}
}
},
mappings: {
properties: {
format: { type: 'keyword' },
pub_date_ssim: { type: 'integer' },
subject_ssim: { type: 'keyword' },
language_ssim: { type: 'keyword' },
lc_1letter_ssim: { type: 'keyword' },
subject_geo_ssim: { type: 'keyword' },
subject_era_ssim: { type: 'keyword' },
all_text_timv: { type: 'text', analyzer: "foo", search_analyzer: "foo" }
}
}
}

# Since Elasticsearch doesn't have copy fields, we need to create all_text_timv manually
body = docs.map do |fixture_data|
data = fixture_data.except('id')
data['all_text_timv'] = data.select { |k, _| /_(si|ssim|tsim)\z/.match?(k) }.values.flatten
{ index: { _index: index, _id: fixture_data['id'], data: data } }
end
connection.bulk(body:)
connection.indices.refresh(index:)
end

# @param [Hash] request_params
# @return [Blacklight::Suggest::Response]
def suggestions(request_params)
suggest_results = {} # TODO: implement
Blacklight.logger.warn("suggestions has not yet been implemented for elasticsearch")
Blacklight::Suggest::Response.new suggest_results, request_params, '', ''
end

private

def index
@index ||= connection_config.fetch(:index)
end

# See https://www.elastic.co/guide/en/elasticsearch/client/ruby-api/current/connecting.html
def build_connection
::Elasticsearch::Client.new(url: connection_config[:url])
# cloud_id: '<CloudID>',
# user: '<Username>',
# password: '<Password>'
end
end
end
72 changes: 72 additions & 0 deletions lib/blacklight/elasticsearch/request.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# frozen_string_literal: true

# class Blacklight::Elasticsearch::InvalidParameter < ArgumentError; end

class Blacklight::Elasticsearch::Request < ActiveSupport::HashWithIndifferentAccess
# This is similar to qf in Solr
cattr_accessor :query_fields, default: %w[all_text_timv]
def initialize(constructor = {})
if constructor.is_a?(Hash)
super()
update(constructor)
else
super
end
end

def ids=(ids)
if ids.empty?
match_none
else
append_filter_query({ ids: { values: ids } })
end
end

def match_none
must.delete('match_all')
must['match_none'] = {}
end

# See https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-all-query.html
def match_all
must.delete 'combined_fields'
must['match_all'] = {}
end

# https://www.elastic.co/guide/en/elasticsearch/reference/current/query-filter-context.html
def append_filter_query(filter_query)
bool['filter'] ||= {}
bool['filter'].merge! filter_query
end

# See https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-combined-fields-query.html
def append_query(query)
return if query.nil?

# TODO: Perhaps we could use this alternative:
# "_all": "your search text"
must['combined_fields'] = {
'query' => query,
'fields' => query_fields,
'operator' => 'or'
}
end

def append_facet_fields(value)
self['aggs'] ||= {}
self['aggs']["bl-#{value}"] = { terms: { field: value } }
end

private

def bool
self['query'] ||= {}
self['query']['bool'] ||= {}
self['query']['bool']
end

def must
bool['must'] ||= {}
bool['must']
end
end
Loading
Loading