├── ansr_blacklight ├── spec │ ├── fixtures │ │ └── config.yml │ ├── spec_helper.rb │ └── lib │ │ ├── request_builders_spec.rb │ │ ├── queryable_relation_spec.rb │ │ ├── relation │ │ ├── grouping_spec.rb │ │ └── faceting_spec.rb │ │ └── loaded_relation_spec.rb ├── Gemfile ├── ansr_blacklight-0.0.2.gem ├── ansr_blacklight-0.0.4.gem ├── lib │ ├── ansr_blacklight │ │ ├── solr.rb │ │ ├── arel.rb │ │ ├── arel │ │ │ ├── visitors.rb │ │ │ ├── visitors │ │ │ │ ├── to_no_sql.rb │ │ │ │ └── query_builder.rb │ │ │ └── big_table.rb │ │ ├── solr │ │ │ ├── response │ │ │ │ ├── more_like_this.rb │ │ │ │ ├── group.rb │ │ │ │ ├── pagination_methods.rb │ │ │ │ ├── group_response.rb │ │ │ │ └── spelling.rb │ │ │ ├── request.rb │ │ │ └── response.rb │ │ ├── base.rb │ │ ├── model │ │ │ └── querying.rb │ │ ├── relation │ │ │ └── solr_projection_methods.rb │ │ ├── connection_adapters │ │ │ └── no_sql_adapter.rb │ │ ├── relation.rb │ │ └── request_builders.rb │ └── ansr_blacklight.rb ├── README.md └── ansr_blacklight.gemspec ├── Gemfile ├── ansr_dpla ├── app │ └── models │ │ ├── item.rb │ │ └── collection.rb ├── fixtures │ ├── dpla.yml │ ├── empty.jsonld │ ├── collection.json │ ├── collection.jsonld │ ├── collections.json │ ├── collections.jsonld │ ├── item.json │ └── item.jsonld ├── Gemfile ├── lib │ ├── ansr_dpla │ │ ├── request.rb │ │ ├── arel.rb │ │ ├── arel │ │ │ ├── visitors.rb │ │ │ ├── visitors │ │ │ │ ├── to_no_sql.rb │ │ │ │ └── query_builder.rb │ │ │ └── big_table.rb │ │ ├── model.rb │ │ ├── model │ │ │ ├── pseudo_associate.rb │ │ │ ├── base.rb │ │ │ └── querying.rb │ │ ├── relation.rb │ │ ├── connection_adapters │ │ │ └── no_sql_adapter.rb │ │ └── api.rb │ └── ansr_dpla.rb ├── spec │ ├── adpla_test_api.rb │ ├── spec_helper.rb │ └── lib │ │ ├── item_spec.rb │ │ ├── relation │ │ ├── select_spec.rb │ │ ├── where_spec.rb │ │ └── facet_spec.rb │ │ ├── api_spec.rb │ │ └── relation_spec.rb ├── test │ ├── debug.rb │ └── system.rb ├── ansr_dpla.gemspec └── README.md ├── .gitignore ├── lib ├── ansr │ ├── .DS_Store │ ├── version.rb │ ├── connection_adapters.rb │ ├── arel │ │ ├── visitors.rb │ │ ├── visitors │ │ │ ├── context.rb │ │ │ ├── to_no_sql.rb │ │ │ └── query_builder.rb │ │ ├── configured_field.rb │ │ ├── nodes.rb │ │ └── big_table.rb │ ├── arel.rb │ ├── configurable.rb │ ├── relation │ │ ├── arel_methods.rb │ │ ├── group.rb │ │ ├── predicate_builder.rb │ │ └── query_methods.rb │ ├── model │ │ └── connection_handler.rb │ ├── sanitization.rb │ ├── model.rb │ ├── base.rb │ ├── utils.rb │ ├── connection_adapters │ │ └── no_sql_adapter.rb │ ├── facets.rb │ ├── relation.rb │ └── dummy_associations.rb └── ansr.rb ├── ansr.gemspec └── README.md /ansr_blacklight/spec/fixtures/config.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gemspec -------------------------------------------------------------------------------- /ansr_dpla/app/models/item.rb: -------------------------------------------------------------------------------- 1 | class Item < Ansr::Dpla::Model::Base 2 | end -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config/dpla.yml 2 | ansr_dpla/config/dpla.yml 3 | Gemfile.lock 4 | *.gem -------------------------------------------------------------------------------- /ansr_dpla/app/models/collection.rb: -------------------------------------------------------------------------------- 1 | class Collection < Ansr::Dpla::Model::Base 2 | end -------------------------------------------------------------------------------- /ansr_dpla/fixtures/dpla.yml: -------------------------------------------------------------------------------- 1 | :api_key: "fake_api_key" 2 | :url: "http://fake.dp.la/v0/" -------------------------------------------------------------------------------- /ansr_dpla/fixtures/empty.jsonld: -------------------------------------------------------------------------------- 1 | {"count":0,"start":0,"limit":10,"docs":[],"facets":[]} -------------------------------------------------------------------------------- /lib/ansr/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mistydemeo/ansr/master/lib/ansr/.DS_Store -------------------------------------------------------------------------------- /lib/ansr/version.rb: -------------------------------------------------------------------------------- 1 | module Ansr 2 | VERSION = '0.0.5' 3 | def self.version 4 | VERSION 5 | end 6 | end -------------------------------------------------------------------------------- /ansr_dpla/Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gemspec 4 | gem "ansr", :path => '..', :group => :development -------------------------------------------------------------------------------- /ansr_blacklight/Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gemspec 4 | gem "ansr", :path => '..', :group => :development -------------------------------------------------------------------------------- /ansr_blacklight/ansr_blacklight-0.0.2.gem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mistydemeo/ansr/master/ansr_blacklight/ansr_blacklight-0.0.2.gem -------------------------------------------------------------------------------- /ansr_blacklight/ansr_blacklight-0.0.4.gem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mistydemeo/ansr/master/ansr_blacklight/ansr_blacklight-0.0.4.gem -------------------------------------------------------------------------------- /ansr_dpla/lib/ansr_dpla/request.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Dpla 2 | class Request < ::Ansr::OpenStructWithHashAccess 3 | attr_accessor :path 4 | end 5 | end -------------------------------------------------------------------------------- /lib/ansr/connection_adapters.rb: -------------------------------------------------------------------------------- 1 | module Ansr 2 | module ConnectionAdapters 3 | require 'ansr/connection_adapters/no_sql_adapter' 4 | end 5 | end -------------------------------------------------------------------------------- /ansr_blacklight/lib/ansr_blacklight/solr.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Blacklight::Solr 2 | require 'ansr_blacklight/solr/request' 3 | require 'ansr_blacklight/solr/response' 4 | end -------------------------------------------------------------------------------- /ansr_dpla/lib/ansr_dpla/arel.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | module Ansr::Dpla 3 | module Arel 4 | require 'ansr_dpla/arel/big_table' 5 | require 'ansr_dpla/arel/visitors' 6 | end 7 | end -------------------------------------------------------------------------------- /ansr_blacklight/lib/ansr_blacklight/arel.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Blacklight 2 | module Arel 3 | require 'ansr_blacklight/arel/visitors' 4 | require 'ansr_blacklight/arel/big_table' 5 | end 6 | end -------------------------------------------------------------------------------- /ansr_dpla/spec/adpla_test_api.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Dpla 2 | class TestApi 3 | include Ansr::Configurable 4 | def items(opts={}) 5 | end 6 | def collections(opts={}) 7 | end 8 | end 9 | end -------------------------------------------------------------------------------- /ansr_dpla/lib/ansr_dpla/arel/visitors.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Dpla::Arel 2 | module Visitors 3 | require 'ansr_dpla/arel/visitors/query_builder' 4 | require 'ansr_dpla/arel/visitors/to_no_sql' 5 | end 6 | end -------------------------------------------------------------------------------- /lib/ansr/arel/visitors.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Arel 2 | module Visitors 3 | require 'ansr/arel/visitors/context' 4 | require 'ansr/arel/visitors/query_builder' 5 | require 'ansr/arel/visitors/to_no_sql' 6 | end 7 | end -------------------------------------------------------------------------------- /ansr_dpla/lib/ansr_dpla/model.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Dpla 2 | module Model 3 | autoload :PseudoAssociate, 'ansr_dpla/model/pseudo_associate' 4 | require 'ansr_dpla/model/querying' 5 | require 'ansr_dpla/model/base' 6 | end 7 | end -------------------------------------------------------------------------------- /lib/ansr/arel.rb: -------------------------------------------------------------------------------- 1 | module Ansr 2 | module Arel 3 | autoload :ConfiguredField, 'ansr/arel/configured_field' 4 | require 'ansr/arel/big_table' 5 | require 'ansr/arel/nodes' 6 | require 'ansr/arel/visitors' 7 | end 8 | end -------------------------------------------------------------------------------- /ansr_blacklight/lib/ansr_blacklight/arel/visitors.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Blacklight::Arel 2 | module Visitors 3 | require 'ansr_blacklight/arel/visitors/query_builder' 4 | require 'ansr_blacklight/arel/visitors/to_no_sql' 5 | end 6 | end -------------------------------------------------------------------------------- /ansr_dpla/lib/ansr_dpla/arel/visitors/to_no_sql.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Dpla::Arel::Visitors 2 | class ToNoSql < Ansr::Arel::Visitors::ToNoSql 3 | 4 | def query_builder(opts = nil) 5 | Ansr::Dpla::Arel::Visitors::QueryBuilder.new(table, opts) 6 | end 7 | 8 | end 9 | end -------------------------------------------------------------------------------- /lib/ansr/configurable.rb: -------------------------------------------------------------------------------- 1 | module Ansr 2 | module Configurable 3 | def config 4 | @config ||= {} 5 | if block_given? 6 | yield @config 7 | end 8 | @config 9 | end 10 | 11 | alias_method :configure, :config 12 | 13 | end 14 | end -------------------------------------------------------------------------------- /ansr_dpla/lib/ansr_dpla.rb: -------------------------------------------------------------------------------- 1 | require 'ansr' 2 | module Ansr::Dpla 3 | require 'ansr_dpla/api' 4 | require 'ansr_dpla/request' 5 | require 'ansr_dpla/connection_adapters/no_sql_adapter' 6 | require 'ansr_dpla/arel' 7 | require 'ansr_dpla/relation' 8 | require 'ansr_dpla/model' 9 | end -------------------------------------------------------------------------------- /ansr_dpla/fixtures/collection.json: -------------------------------------------------------------------------------- 1 | {"docs":[{"_id":"bpl--commonwealth:2j62s484w","title":"Leslie Jones Collection","ingestDate":"2014-02-15T17:00:26.122359","ingestionSequence":5,"ingestType":"collection","@id":"http://dp.la/api/collections/460c76299e1b0a46afea352b1ab8f556","id":"460c76299e1b0a46afea352b1ab8f556"}],"count":1} -------------------------------------------------------------------------------- /ansr_dpla/test/debug.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'app/models')) 2 | require 'rails' 3 | require 'adpla' 4 | require 'item' 5 | class Logger 6 | def info(msg) 7 | puts msg 8 | end 9 | alias :warn :info 10 | alias :error :info 11 | alias :debug :info 12 | end 13 | 14 | puts Item.table.inspect -------------------------------------------------------------------------------- /lib/ansr/relation/arel_methods.rb: -------------------------------------------------------------------------------- 1 | module Ansr 2 | module ArelMethods 3 | # Returns the Arel object associated with the relation. 4 | # duplicated to respect access control 5 | def arel # :nodoc: 6 | @arel ||= build_arel 7 | end 8 | 9 | def arel_table 10 | model().table 11 | end 12 | 13 | end 14 | end -------------------------------------------------------------------------------- /ansr_blacklight/lib/ansr_blacklight/arel/visitors/to_no_sql.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Blacklight::Arel::Visitors 2 | class ToNoSql < Ansr::Arel::Visitors::ToNoSql 3 | 4 | def initialize(table, http_method=:get) 5 | super(table) 6 | end 7 | 8 | def query_builder() 9 | Ansr::Blacklight::Arel::Visitors::QueryBuilder.new(table) 10 | end 11 | 12 | end 13 | 14 | end -------------------------------------------------------------------------------- /lib/ansr/arel/visitors/context.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Arel::Visitors 2 | class Context 3 | attr_reader :attribute 4 | def initialize(attribute) 5 | @attribute = attribute 6 | end 7 | end 8 | 9 | # create some thin subclasses in this module 10 | %W(Facet Filter From Order ProjectionTraits).each do |name| 11 | const_set(name, Class.new(Context)) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /ansr_blacklight/lib/ansr_blacklight/solr/response/more_like_this.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Blacklight::Solr::Response::MoreLikeThis 2 | def more_like document 3 | mlt = more_like_this[document.id] 4 | return [] unless mlt and mlt['docs'] 5 | 6 | mlt['docs'] 7 | end 8 | 9 | def more_like_this 10 | return {} unless self[:moreLikeThis] 11 | 12 | self[:moreLikeThis] 13 | end 14 | end -------------------------------------------------------------------------------- /ansr_dpla/lib/ansr_dpla/model/pseudo_associate.rb: -------------------------------------------------------------------------------- 1 | # a class to pretend the unfindable "associations" are real models 2 | module Ansr::Dpla 3 | module Model 4 | class PseudoAssociate 5 | def initialize(doc = {}) 6 | @doc = doc.with_indifferent_access 7 | end 8 | 9 | def method_missing(name, *args) 10 | @doc[name] or super 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /ansr_dpla/fixtures/collection.jsonld: -------------------------------------------------------------------------------- 1 | { 2 | "docs": [ 3 | { 4 | "_id": "bpl--commonwealth:2j62s484w", 5 | "title": "Leslie Jones Collection", 6 | "ingestDate": "2014-02-15T17:00:26.122359", 7 | "ingestionSequence": 5, 8 | "ingestType": "collection", 9 | "@id": "http://dp.la/api/collections/460c76299e1b0a46afea352b1ab8f556", 10 | "id": "460c76299e1b0a46afea352b1ab8f556" 11 | } 12 | ], 13 | "count": 1 14 | } -------------------------------------------------------------------------------- /lib/ansr/arel/configured_field.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Arel 2 | class ConfiguredField < ::Arel::Attributes::Attribute 3 | attr_reader :config 4 | def initialize(relation, name, config={}) 5 | super(relation, name) 6 | @config = {}.merge(config) 7 | end 8 | def query 9 | @config[:query] 10 | end 11 | def local 12 | @config[:local] 13 | end 14 | def method_missing(method, *args) 15 | @config[method] = args if args.first 16 | @config[method] 17 | end 18 | end 19 | end -------------------------------------------------------------------------------- /ansr_blacklight/lib/ansr_blacklight/base.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Blacklight 2 | class Base < Ansr::Base 3 | include Ansr::Blacklight::Model::Querying 4 | 5 | self.abstract_class = true 6 | 7 | def self.solr_search_params_logic 8 | @solr_search_params_logic || [] 9 | end 10 | 11 | def self.solr_search_params_logic=(vals) 12 | @solr_search_params_logic=vals 13 | end 14 | 15 | def self.build_default_scope 16 | rel = super 17 | solr_search_params_logic.each {|method| rel = self.send(method, rel)} 18 | rel 19 | end 20 | end 21 | end -------------------------------------------------------------------------------- /lib/ansr/model/connection_handler.rb: -------------------------------------------------------------------------------- 1 | module Ansr 2 | module Model 3 | class ConnectionHandler 4 | def initialize(connection_class) 5 | @connection_class = connection_class 6 | end 7 | 8 | def adapter 9 | @connection_class 10 | end 11 | 12 | # retrieve a datasource adapter 13 | def retrieve_connection(klass) 14 | @connection_class.new(klass) 15 | end 16 | 17 | def retrieve_connection_pool(klass) 18 | retrieve_connection(klass) 19 | end 20 | 21 | def connected?(klass) 22 | true 23 | end 24 | end 25 | end 26 | end -------------------------------------------------------------------------------- /lib/ansr.rb: -------------------------------------------------------------------------------- 1 | require 'active_support' 2 | require 'active_record' 3 | module Ansr 4 | extend ActiveSupport::Autoload 5 | eager_autoload do 6 | autoload :Configurable 7 | autoload :ConnectionAdapters 8 | autoload :DummyAssociations 9 | autoload :Arel 10 | autoload :Facets 11 | autoload :Base 12 | autoload :Model 13 | autoload :Sanitization 14 | autoload :Relation 15 | autoload :OpenStructWithHashAccess, 'ansr/utils' 16 | autoload_under 'relation' do 17 | autoload :Group 18 | autoload :PredicateBuilder 19 | autoload :ArelMethods 20 | autoload :QueryMethods 21 | end 22 | end 23 | end -------------------------------------------------------------------------------- /ansr_dpla/lib/ansr_dpla/model/base.rb: -------------------------------------------------------------------------------- 1 | require 'ansr' 2 | module Ansr::Dpla 3 | module Model 4 | class Base < Ansr::Base 5 | self.abstract_class = true 6 | 7 | include Querying 8 | 9 | def self.inherited(subclass) 10 | super(subclass) 11 | subclass.configure do |config| 12 | config[:table_class] = Ansr::Dpla::Arel::BigTable 13 | end 14 | end 15 | 16 | def assign_nested_parameter_attributes(pairs) 17 | pairs.each do |k, v| 18 | v = PseudoAssociate.new(v) if Hash === v 19 | _assign_attribute(k, v) 20 | end 21 | end 22 | end 23 | end 24 | end -------------------------------------------------------------------------------- /lib/ansr/sanitization.rb: -------------------------------------------------------------------------------- 1 | module Ansr 2 | module Sanitization 3 | extend ActiveSupport::Concern 4 | module ClassMethods 5 | def expand_hash_conditions_for_sql_aggregates(conditions) 6 | conditions.reduce({}) {|memo, k, v| } 7 | conditions 8 | end 9 | 10 | def sanitize_sql_for_conditions(condition, table_name = table_name()) 11 | condition 12 | end 13 | 14 | def sanitize_sql_hash_for_conditions(attrs, default_table_name = self.table_name) 15 | attrs 16 | end 17 | 18 | def sanitize_sql(condition, table_name = table_name()) 19 | sanitize_sql_for_conditions(condition, table_name) 20 | end 21 | end 22 | end 23 | end -------------------------------------------------------------------------------- /ansr_blacklight/lib/ansr_blacklight/solr/response/group.rb: -------------------------------------------------------------------------------- 1 | class Ansr::Blacklight::Solr::Response::Group < Ansr::Group 2 | 3 | include Ansr::Blacklight::Solr::Response::PaginationMethods 4 | 5 | attr_reader :response 6 | 7 | def initialize group_key, model, group, response 8 | super(group_key, model, group) 9 | @response = response 10 | end 11 | 12 | def doclist 13 | group[:doclist] 14 | end 15 | 16 | # short cut to response['numFound'] 17 | def total 18 | doclist[:numFound].to_s.to_i 19 | end 20 | 21 | def start 22 | doclist[:start].to_s.to_i 23 | end 24 | 25 | def docs 26 | doclist[:docs].map {|doc| model.new(doc)} #TODO do we need to have the solrResponse in the item? 27 | end 28 | 29 | def field 30 | response.group_field 31 | end 32 | end -------------------------------------------------------------------------------- /lib/ansr/relation/group.rb: -------------------------------------------------------------------------------- 1 | # encapsulate a set of a response documents grouped on a field 2 | module Ansr 3 | class Group 4 | attr_reader :field, :key, :group, :model 5 | def initialize(group_key, model, group) 6 | @field, @key = group_key.first 7 | @model = model 8 | @group = group 9 | end 10 | 11 | # size of the group 12 | def total 13 | raise "Group#total must be implemented by subclass" 14 | end 15 | 16 | # offset in the response 17 | def start 18 | raise "Group#start must be implemented by subclass" 19 | end 20 | 21 | # model instances belonging to this group 22 | def records 23 | raise "Group#records must be implemented by subclass" 24 | end 25 | 26 | # the field from which the key value was selected 27 | def field 28 | raise "Group#field must be implemented by subclass" 29 | end 30 | end 31 | end -------------------------------------------------------------------------------- /ansr_blacklight/lib/ansr_blacklight/model/querying.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Blacklight::Model 2 | module Querying 3 | extend ActiveSupport::Concern 4 | 5 | module ClassMethods 6 | 7 | def solr 8 | Ansr::Blacklight.solr 9 | end 10 | 11 | def build_default_scope 12 | rel = Ansr::Blacklight::Relation.new(model(), table()) 13 | rel 14 | end 15 | 16 | def unique_key 17 | table().unique_key 18 | end 19 | 20 | def default_connection_handler 21 | @default_connection_handler ||= Ansr::Model::ConnectionHandler.new(Ansr::Blacklight::ConnectionAdapters::NoSqlAdapter) 22 | end 23 | 24 | def references 25 | [] 26 | end 27 | def ansr_query(*args) 28 | ansr_query = super(*args) 29 | ansr_query.http_method = args[2] if args[2] 30 | ansr_query 31 | end 32 | end 33 | end 34 | end -------------------------------------------------------------------------------- /ansr_blacklight/lib/ansr_blacklight/solr/response/pagination_methods.rb: -------------------------------------------------------------------------------- 1 | require 'kaminari' 2 | module Ansr::Blacklight::Solr::Response::PaginationMethods 3 | 4 | include Kaminari::PageScopeMethods 5 | include Kaminari::ConfigurationMethods::ClassMethods 6 | 7 | def limit_value #:nodoc: 8 | rows 9 | end 10 | 11 | def offset_value #:nodoc: 12 | start 13 | end 14 | 15 | def total_count #:nodoc: 16 | total 17 | end 18 | 19 | def model_name 20 | if !docs.empty? and docs.first.respond_to? :model_name 21 | docs.first.model_name 22 | end 23 | end 24 | 25 | ## Methods in kaminari master that we'd like to use today. 26 | # Next page number in the collection 27 | def next_page 28 | current_page + 1 unless last_page? 29 | end 30 | 31 | # Previous page number in the collection 32 | def prev_page 33 | current_page - 1 unless first_page? 34 | end 35 | end -------------------------------------------------------------------------------- /ansr_dpla/lib/ansr_dpla/model/querying.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | module Ansr::Dpla 3 | module Model 4 | module Querying 5 | extend ActiveSupport::Concern 6 | 7 | module ClassMethods 8 | def build_default_scope 9 | Ansr::Dpla::Relation.new(model(), table()) 10 | end 11 | 12 | def api 13 | @api ||= begin 14 | a = (config[:api] || Ansr::Dpla::Api).new 15 | a.config{|x| x.merge!(self.config)} 16 | a 17 | end 18 | end 19 | 20 | def api=(api) 21 | @api = api 22 | end 23 | 24 | def connection_handler 25 | @connection_handler ||= Ansr::Model::ConnectionHandler.new(Ansr::Dpla::ConnectionAdapters::NoSqlAdapter) 26 | end 27 | 28 | def references 29 | ['provider', 'object'] 30 | end 31 | end 32 | end 33 | end 34 | end -------------------------------------------------------------------------------- /ansr_dpla/spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', '..', 'lib')) 2 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 3 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'app/models')) 4 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 5 | 6 | require 'rspec/autorun' 7 | require 'loggable' 8 | require 'ansr' 9 | require 'ansr_dpla' 10 | require 'adpla_test_api' 11 | require 'blacklight' 12 | require 'item' 13 | require 'collection' 14 | 15 | RSpec.configure do |config| 16 | 17 | end 18 | 19 | def fixture_path(path) 20 | File.join(File.dirname(__FILE__), '..', 'fixtures', path) 21 | end 22 | 23 | def fixture path, &block 24 | if block_given? 25 | open(fixture_path(path)) &block 26 | else 27 | open(fixture_path(path)) 28 | end 29 | end 30 | 31 | def read_fixture(path) 32 | _f = fixture(path) 33 | _f.read 34 | ensure 35 | _f and _f.close 36 | end 37 | -------------------------------------------------------------------------------- /lib/ansr/arel/nodes.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Arel::Nodes 2 | class ConfiguredUnary < ::Arel::Nodes::Node 3 | attr_reader :expr, :opts 4 | 5 | def initialize(expr, opts={}) 6 | @expr = expr 7 | @opts = opts 8 | end 9 | 10 | def method_missing(method, *args) 11 | @opts[method] = args if args.first 12 | @opts[method] 13 | end 14 | 15 | end 16 | 17 | class Facet < ConfiguredUnary 18 | 19 | def order(*val) 20 | if val.first 21 | val = val.first.downcase.to_sym if String === val 22 | @opts[:order] = val if val == :asc or val == :desc 23 | end 24 | @opts[:order] 25 | end 26 | 27 | def prefix(*val) 28 | @opts[:prefix] = val.first.to_s if val.first 29 | @opts[:prefix] 30 | end 31 | 32 | def limit(*val) 33 | @opts[:limit] = val.first.to_s if val.first 34 | @opts[:limit] 35 | end 36 | end 37 | class Filter < ConfiguredUnary; end 38 | class Highlight < ConfiguredUnary; end 39 | class ProjectionTraits < ConfiguredUnary; end 40 | class Spellcheck < ConfiguredUnary; end 41 | end -------------------------------------------------------------------------------- /ansr_blacklight/lib/ansr_blacklight/relation/solr_projection_methods.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Blacklight 2 | module SolrProjectionMethods 3 | def defType_value 4 | @values[:defType] 5 | end 6 | 7 | def defType_value=(value) 8 | raise ImmutableRelation if @loaded 9 | @values[:defType] = value 10 | end 11 | 12 | def defType(value) 13 | spawn.defType!(value) 14 | end 15 | 16 | def defType!(value) 17 | self.defType_value= value 18 | self 19 | end 20 | 21 | def defType_unscoping 22 | end 23 | 24 | def wt_value 25 | @values[:wt] 26 | end 27 | 28 | def wt_value=(value) 29 | raise ImmutableRelation if @loaded 30 | @values[:wt] = value 31 | end 32 | 33 | def wt(value) 34 | spawn.wt!(value) 35 | end 36 | 37 | def wt!(value) 38 | self.wt_value= (value) 39 | self 40 | end 41 | 42 | def wt_unscoping 43 | end 44 | 45 | # omitHeader 46 | 47 | # timeAllowed 48 | 49 | # debug (true, :timing, :query, :results) 50 | 51 | # explainOther 52 | 53 | # debug.explain.structured 54 | end 55 | end -------------------------------------------------------------------------------- /lib/ansr/arel/visitors/to_no_sql.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Arel::Visitors 2 | class ToNoSql < Arel::Visitors::Visitor 3 | attr_reader :table 4 | 5 | def initialize(big_table) 6 | super() 7 | @table = big_table 8 | end 9 | 10 | def query_builder(opts = nil) 11 | Ansr::Arel::Visitors::QueryBuilder.new(table, opts) 12 | end 13 | 14 | # the object generated by this method will be passed to the NoSqlAdapter#execute 15 | def visit_Arel_Nodes_SelectStatement(object, attribute) 16 | builder = query_builder 17 | 18 | if object.with 19 | builder.visit(object.with, attribute) 20 | end 21 | 22 | object.cores.each { |x| builder.visit_Arel_Nodes_SelectCore(x, attribute) } 23 | 24 | unless object.orders.empty? 25 | 26 | object.orders.each do |x| 27 | oa = Ansr::Arel::Visitors::Order.new(attribute) 28 | builder.visit x, oa 29 | end 30 | end 31 | 32 | builder.visit(object.limit, attribute) if object.limit 33 | builder.visit(object.offset, attribute) if object.offset 34 | # not relevant 35 | 36 | builder.query_opts 37 | end 38 | 39 | end 40 | 41 | end 42 | -------------------------------------------------------------------------------- /ansr.gemspec: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'lib/ansr/version') 2 | Gem::Specification.new do |spec| 3 | spec.name = 'ansr' 4 | spec.version = Ansr.version 5 | spec.platform = Gem::Platform::RUBY 6 | spec.authors = ["Benjamin Armintor"] 7 | spec.email = ["armintor@gmail.com"] 8 | spec.summary = 'ActiveRecord-style relations for no-sql data sources' 9 | spec.description = 'Wrapping the no-sql data sources in Rails-like models and relations' 10 | spec.homepage = 'http://github.com/barmintor/ansr' 11 | spec.license = "APACHE2" 12 | spec.required_ruby_version = '>= 1.9.3' 13 | spec.files = `git ls-files`.split("\n") 14 | spec.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 15 | spec.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 16 | spec.require_paths = ["lib"] 17 | 18 | spec.add_dependency 'loggable' 19 | spec.add_dependency 'arel', '~> 4', '>= 4.0.2' 20 | spec.add_dependency 'activerecord', '~> 4', '>= 4.0.3' 21 | 22 | spec.add_development_dependency("rake") 23 | spec.add_development_dependency("bundler", ">= 1.0.14") 24 | spec.add_development_dependency("rspec", '~>2') 25 | spec.add_development_dependency("yard") 26 | end -------------------------------------------------------------------------------- /ansr_dpla/ansr_dpla.gemspec: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '../lib/ansr/version') 2 | version = Ansr.version 3 | Gem::Specification.new do |spec| 4 | spec.name = 'ansr_dpla' 5 | spec.version = version 6 | spec.platform = Gem::Platform::RUBY 7 | spec.authors = ["Benjamin Armintor"] 8 | spec.email = ["armintor@gmail.com"] 9 | spec.summary = 'ActiveRecord-style models and relations for DPLA APIs' 10 | spec.description = 'Wrapping the DPLA APIs in Rails-like models and relations' 11 | spec.homepage = 'https://github.com/barmintor/ansr/tree/master/ansr_dpla' 12 | spec.files = `git ls-files`.split("\n") 13 | spec.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 14 | spec.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 15 | spec.require_paths = ["lib"] 16 | 17 | spec.add_dependency 'ansr', version 18 | spec.add_dependency 'json-ld' 19 | spec.add_dependency 'rest-client' 20 | spec.add_dependency 'loggable' 21 | spec.add_dependency 'blacklight', '>=5.1.0' 22 | spec.add_dependency 'sass-rails' 23 | spec.add_development_dependency("rake") 24 | spec.add_development_dependency("bundler", ">= 1.0.14") 25 | spec.add_development_dependency("rspec") 26 | spec.add_development_dependency("yard") 27 | end -------------------------------------------------------------------------------- /ansr_blacklight/lib/ansr_blacklight/solr/request.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Blacklight::Solr 2 | class Request < ::HashWithIndifferentAccess 3 | attr_accessor :path 4 | 5 | SINGULAR_KEYS = %W{ facet fl q qt rows start spellcheck spellcheck.q sort 6 | per_page wt hl group defType} 7 | ARRAY_KEYS = %W{facet.field facet.query facet.pivot fq hl.fl } 8 | 9 | def initialize(constructor = {}) 10 | if constructor.is_a?(Hash) 11 | super() 12 | update(constructor) 13 | else 14 | super(constructor) 15 | end 16 | ARRAY_KEYS.each do |key| 17 | self[key] ||= [] 18 | end 19 | end 20 | 21 | def append_filter_query(query) 22 | self['fq'] << query 23 | end 24 | 25 | def append_facet_fields(values) 26 | (self['facet.field'] += Array(values)).uniq! 27 | self['facet'] = true unless values.blank? 28 | end 29 | 30 | def append_facet_query(values) 31 | self['facet.query'] += Array(values) 32 | end 33 | 34 | def append_facet_pivot(query) 35 | self['facet.pivot'] << query 36 | end 37 | 38 | def append_highlight_field(query) 39 | self['hl.fl'] << query 40 | end 41 | 42 | def to_hash 43 | reject {|key, value| ARRAY_KEYS.include?(key) && value.blank?} 44 | end 45 | end 46 | end -------------------------------------------------------------------------------- /ansr_blacklight/lib/ansr_blacklight/solr/response/group_response.rb: -------------------------------------------------------------------------------- 1 | class Ansr::Blacklight::Solr::Response::GroupResponse 2 | 3 | include Ansr::Blacklight::Solr::Response::PaginationMethods 4 | 5 | attr_reader :key, :model, :group, :response 6 | 7 | def initialize key, model, group, response 8 | @key = key 9 | @model = model 10 | @group = group 11 | @response = response 12 | end 13 | 14 | alias_method :group_field, :key 15 | 16 | def groups 17 | @groups ||= group["groups"].map do |g| 18 | Ansr::Blacklight::Solr::Response::Group.new({key => g[:groupValue]}, model, g, self) 19 | end 20 | end 21 | 22 | def group_limit 23 | params.fetch(:'group.limit', 1).to_s.to_i 24 | end 25 | 26 | def total 27 | # ngroups is only available in Solr 4.1+ 28 | # fall back on the number of facet items for that field? 29 | (group["ngroups"] || (response.facet_by_field_name(key) || []).length).to_s.to_i 30 | end 31 | 32 | def start 33 | params[:start].to_s.to_i 34 | end 35 | 36 | def method_missing meth, *args, &block 37 | 38 | if response.respond_to? meth 39 | response.send(meth, *args, &block) 40 | else 41 | super 42 | end 43 | 44 | end 45 | 46 | def respond_to? meth 47 | response.respond_to?(meth) || super 48 | end 49 | 50 | end -------------------------------------------------------------------------------- /ansr_blacklight/lib/ansr_blacklight/connection_adapters/no_sql_adapter.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Blacklight::ConnectionAdapters 2 | class NoSqlAdapter < Ansr::ConnectionAdapters::NoSqlAdapter 3 | 4 | def self.connection_for(klass) 5 | Ansr::Blacklight.solr 6 | end 7 | 8 | def initialize(klass, logger = nil, pool = nil) #:nodoc: 9 | super(klass, klass.solr, logger, pool) 10 | # the RSolr class has one query method, with the name of the selector the first parm? 11 | @method = :send_and_receive 12 | @http_method = klass.method 13 | @visitor = Ansr::Blacklight::Arel::Visitors::ToNoSql.new(@table) 14 | end 15 | 16 | # RSolr 17 | def raw_connection 18 | @connection 19 | end 20 | 21 | def adapter_name 22 | 'Solr' 23 | end 24 | 25 | def to_sql(*args) 26 | to_nosql(*args) 27 | end 28 | 29 | def execute(query, name='ANSR-SOLR') 30 | query = query.dup 31 | # TODO: execution context to assign :post to params[:method] 32 | params = {params: query, method: @http_method} 33 | params[:data] = params.delete(:params) if @http_method == :post 34 | raw_response = @connection.send(@method, query.path || 'select', params) 35 | Ansr::Blacklight::Solr::Response.new(raw_response, raw_response['params']) 36 | end 37 | 38 | end 39 | end -------------------------------------------------------------------------------- /ansr_blacklight/README.md: -------------------------------------------------------------------------------- 1 | Ansr::Blacklight 2 | ================= 3 | 4 | A re-implementation of Blacklight's Solr model with find/search functionality moved behind ActiveRecord::Relation subclasses. 5 | 6 | QUESTIONS 7 | 8 | Is a closer conformation to the expectations from ActiveRecord valuable enough to forego use of Sunspot (https://github.com/sunspot/sunspot)? 9 | 10 | REQUEST REQUIREMENTS 11 | 12 | Considering the following block from the BL Solr request code: 13 | SINGULAR_KEYS = %W{ facet fl q qt rows start spellcheck spellcheck.q sort 14 | per_page wt hl group defType} 15 | ARRAY_KEYS = %W{facet.field facet.query facet.pivot fq hl.fl } 16 | 17 | facet : a boolean field indicating the requested presence of facet info in response 18 | fl : the selected fields 19 | q : the query (fielding?) 20 | qt : query type; indicates queryHandler in Solr 21 | rows : corresponds to limit 22 | start : corresponds to offset 23 | spellcheck : boolean? 24 | spellcheck.q : ? 25 | sort : ? 26 | facet.field : the fields for which facet info is requested 27 | facet.query : ? 28 | facet.pivot : ? 29 | fq : ? 30 | hl.fl : field to highlight 31 | How is facet query different from filter query (fq)? 32 | 33 | Relations must be configurable with default parameters; this is fairly easy to do with a template Relation to spawn the default scope from. 34 | 35 | RESPONSE REQUIREMENTS 36 | 37 | tbd 38 | -------------------------------------------------------------------------------- /ansr_blacklight/lib/ansr_blacklight/arel/big_table.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Blacklight::Arel 2 | class BigTable < Ansr::Arel::BigTable 3 | attr_accessor :name 4 | 5 | def initialize(klass, engine=nil, config=nil) 6 | super(klass, engine) 7 | @name = 'select' 8 | self.config(config) 9 | end 10 | 11 | delegate :index_fields, to: :config 12 | delegate :show_fields, to: :config 13 | delegate :sort_fields, to: :config 14 | 15 | def filterable 16 | config.facet_fields.keys 17 | end 18 | 19 | alias_method :facets, :filterable 20 | 21 | def filterable?(field) 22 | filterable.include? field 23 | end 24 | 25 | def constrainable 26 | index_fields.keys 27 | end 28 | 29 | def constrainable?(field) 30 | index_fields.include?(field) 31 | end 32 | 33 | def selectable 34 | show_fields.keys + index_fields.keys 35 | end 36 | 37 | def selectable?(field) 38 | show_fields.include? field 39 | end 40 | 41 | def fields 42 | (constrainable + selectable + filterable).uniq 43 | end 44 | 45 | def sortable 46 | sort_fields.keys 47 | end 48 | 49 | def sortable?(field) 50 | sort_fields.include? field 51 | end 52 | 53 | def primary_key 54 | @primary_key ||= ::Arel::Attribute.new(self, config.document_unique_id_param.to_s) 55 | end 56 | end 57 | end -------------------------------------------------------------------------------- /lib/ansr/arel/visitors/query_builder.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Arel::Visitors 2 | class QueryBuilder < Arel::Visitors::Visitor 3 | attr_reader :table 4 | def initialize(table) 5 | @table = table 6 | end 7 | 8 | def visit(object, attribute=nil) 9 | super(object, attribute) 10 | end 11 | 12 | def visit_Ansr_Arel_BigTable(object, attribute) 13 | visit object.name, attribute if Ansr::Arel::Visitors::From === attribute 14 | @table = object if Ansr::Arel::BigTable === object and Ansr::Arel::Visitors::From === attribute 15 | end 16 | 17 | def visit_Arel_Nodes_SelectCore(object, attribute) 18 | visit(object.froms, From.new(attribute)) if object.froms 19 | object.projections.each { |x| visit(x, attribute) if x} 20 | object.wheres.each { |x| visit(x, attribute) if x} 21 | object.groups.each {|x| visit(x, attribute) if x} 22 | self 23 | end 24 | 25 | def visit_Symbol o, a 26 | visit o.to_s, a 27 | end 28 | 29 | def visit_Array o, a 30 | o.map { |x| visit x, a } 31 | end 32 | 33 | def visit_Arel_Nodes_And(object, attribute) 34 | visit(object.children, attribute) 35 | end 36 | 37 | def field_key_from_node(node) 38 | table.model.field_name(node) 39 | end 40 | 41 | # determines whether multiple values should accumulate or overwrite in merges 42 | def multiple?(field_key) 43 | false 44 | end 45 | 46 | end 47 | end -------------------------------------------------------------------------------- /ansr_blacklight/ansr_blacklight.gemspec: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '../lib/ansr/version') 2 | version = Ansr.version 3 | Gem::Specification.new do |spec| 4 | spec.name = 'ansr_blacklight' 5 | spec.version = version 6 | spec.platform = Gem::Platform::RUBY 7 | spec.authors = ["Benjamin Armintor"] 8 | spec.email = ["armintor@gmail.com"] 9 | spec.summary = 'ActiveRecord-style models and relations for Blacklight' 10 | spec.description = 'Wrapping the Blacklight/RSolr in Rails-like models and relations' 11 | spec.homepage = 'https://github.com/barmintor/ansr/tree/master/ansr_blacklight' 12 | spec.files = `git ls-files`.split("\n") 13 | spec.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 14 | spec.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 15 | spec.require_paths = ["lib"] 16 | 17 | spec.add_dependency 'ansr', version 18 | spec.add_dependency 'json-ld' 19 | spec.add_dependency 'rest-client' 20 | spec.add_dependency 'loggable' 21 | spec.add_dependency "rails", ">= 3.2.6", "< 5" 22 | spec.add_dependency "rsolr", "~> 1.0.6" # Library for interacting with rSolr. 23 | spec.add_dependency "kaminari", "~> 0.13" # the pagination (page 1,2,3, etc..) of our search results 24 | spec.add_dependency 'sass-rails' 25 | spec.add_development_dependency("rake") 26 | spec.add_development_dependency("bundler", ">= 1.0.14") 27 | spec.add_development_dependency "rspec-rails" 28 | spec.add_development_dependency("yard") 29 | end -------------------------------------------------------------------------------- /ansr_dpla/lib/ansr_dpla/relation.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | module Ansr::Dpla 3 | class Relation < ::Ansr::Relation 4 | 5 | def initialize(klass, table, values = {}) 6 | raise "Cannot search nil model" if klass.nil? 7 | super(klass, table, values) 8 | end 9 | 10 | def facet_values=(values) 11 | values.each {|value| raise "#{value.expr.name.to_sym} is not facetable" unless table.facets.include? value.expr.name.to_sym} 12 | super 13 | end 14 | 15 | def empty? 16 | count == 0 17 | end 18 | 19 | def many? 20 | count > 1 21 | end 22 | 23 | def offset!(value) 24 | page_size = self.limit_value || default_limit_value 25 | if (value.to_i % page_size.to_i) != 0 26 | raise "Bad offset #{value} for page size #{page_size}" 27 | end 28 | self.offset_value=value 29 | self 30 | end 31 | 32 | def count 33 | self.load 34 | @response['count'] 35 | end 36 | 37 | def facets_from(response) 38 | f = {} 39 | (response['facets'] || {}).inject(f) do |h,(k,v)| 40 | 41 | if v['total'] != 0 42 | items = v['terms'].collect do |term| 43 | Ansr::Facets::FacetItem.new(:value => term['term'], :hits => term['count']) 44 | end 45 | options = {:sort => 'asc', :offset => 0} 46 | h[k] = Ansr::Facets::FacetField.new k, items, options 47 | end 48 | h 49 | end 50 | f 51 | end 52 | 53 | def docs_from(response) 54 | response['docs'] 55 | end 56 | 57 | end 58 | 59 | 60 | end -------------------------------------------------------------------------------- /ansr_dpla/fixtures/collections.json: -------------------------------------------------------------------------------- 1 | {"count":5,"start":0,"limit":10,"docs":[{"id":"460c76299e1b0a46afea352b1ab8f556","ingestDate":"2014-02-15T17:00:26.122359","_rev":"372-9d33a508d82b97621b456623321886b3","title":"Leslie Jones Collection","_id":"bpl--commonwealth:2j62s484w","ingestType":"collection","@id":"http://dp.la/api/collections/460c76299e1b0a46afea352b1ab8f556","ingestionSequence":5,"score":6.857883},{"id":"7c7eaa8eee08d3c9b32d55662c0de58b","ingestDate":"2014-02-05T03:09:43.108843","_rev":"137-6262bdd35a8cf3397aefed97d5f2731e","title":"Thomas Jones Davies Bible Records","_id":"scdl-usc--davies","ingestType":"collection","@id":"http://dp.la/api/collections/7c7eaa8eee08d3c9b32d55662c0de58b","ingestionSequence":7,"score":6.177192},{"id":"f807cdcefe2c93f7f71bf99f47899e1f","ingestDate":"2014-01-19T09:04:22.366278","_rev":"3-83d42699bd0836d639d948d921a73d66","title":"Kirby Jones Papers, 1963 - 1974","_id":"nara--1159","ingestType":"collection","@id":"http://dp.la/api/collections/f807cdcefe2c93f7f71bf99f47899e1f","ingestionSequence":5,"score":6.0006475},{"id":"c1ae618e5958fd9c9e869ebbfd47bc34","ingestDate":"2014-01-19T09:04:21.373908","_rev":"3-5f88173336cf37fb8e33ddbfb1c62705","title":"Joseph M. Jones Papers, 1928 - 1987","_id":"nara--604476","ingestType":"collection","@id":"http://dp.la/api/collections/c1ae618e5958fd9c9e869ebbfd47bc34","ingestionSequence":5,"score":5.391162},{"id":"e1e5fee5c47484e307ccdbb717a75e7f","ingestDate":"2014-01-30T12:12:19.274456","_rev":"4-6d0514009fcf0b2cc68a178fdf3209ac","title":"John Paul Jones papers, 1924-1999","_id":"smithsonian--john_paul_jones_papers_1924_1999","ingestType":"collection","@id":"http://dp.la/api/collections/e1e5fee5c47484e307ccdbb717a75e7f","ingestionSequence":8,"score":5.317501}],"facets":[]} -------------------------------------------------------------------------------- /lib/ansr/arel/big_table.rb: -------------------------------------------------------------------------------- 1 | require 'arel' 2 | module Ansr 3 | module Arel 4 | class BigTable < ::Arel::Table 5 | attr_writer :primary_key 6 | attr_reader :fields, :facets, :sorts 7 | 8 | 9 | attr_reader :klass 10 | alias :model :klass 11 | 12 | def self.primary_key 13 | @primary_key ||= 'id' 14 | end 15 | 16 | def self.primary_key=(key) 17 | @primary_key = key 18 | end 19 | 20 | def initialize(klass, engine=nil) 21 | super(klass.name, engine.nil? ? klass.engine : engine) 22 | @klass = klass.model 23 | @fields = [] 24 | @facets = [] 25 | @sorts = [] 26 | @field_configs = {} 27 | end 28 | 29 | def primary_key 30 | @primary_key ||= ::Arel::Attribute.new( self, self.class.primary_key ) 31 | end 32 | 33 | def primary_key=(key) 34 | @primary_key = ::Arel::Attribute.new( self, key.to_s ) 35 | end 36 | 37 | def [] name 38 | name = (name.respond_to? :name) ? name.name.to_sym : name.to_sym 39 | (@field_configs.include? name) ? Ansr::Arel::ConfiguredField.new(self, name, @field_configs[name]) : ::Arel::Attribute.new( self, name) 40 | end 41 | 42 | def configure_fields 43 | if block_given? 44 | yield @field_configs 45 | end 46 | @field_configs 47 | end 48 | def fields 49 | if block_given? 50 | yield @fields 51 | end 52 | @fields 53 | end 54 | def facets 55 | if block_given? 56 | yield @facets 57 | end 58 | @facets 59 | end 60 | def sorts 61 | if block_given? 62 | yield @sorts 63 | end 64 | @sorts 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /ansr_dpla/lib/ansr_dpla/connection_adapters/no_sql_adapter.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Dpla 2 | module ConnectionAdapters 3 | class NoSqlAdapter < Ansr::ConnectionAdapters::NoSqlAdapter 4 | 5 | def self.connection_for(klass) 6 | klass.api 7 | end 8 | 9 | def initialize(klass, logger = nil, pool = nil) #:nodoc: 10 | super(klass, klass.api, logger, pool) 11 | @visitor = Ansr::Dpla::Arel::Visitors::ToNoSql.new(@table) 12 | end 13 | 14 | def to_sql(*args) 15 | to_nosql(*args) 16 | end 17 | 18 | def execute(query, name='ANSR-DPLA') 19 | method = query.path 20 | query = query.to_h if Ansr::Dpla::Request === query 21 | query = query.dup 22 | aliases = query.delete(:aliases) 23 | json = @connection.send(method, query) 24 | json = json.length > 0 ? JSON.load(json) : {'docs' => [], 'facets' => []} 25 | if json['docs'] and aliases 26 | json['docs'].each do |doc| 27 | aliases.each do |k,v| 28 | if doc[k] 29 | old = doc.delete(k) 30 | if old and doc[v] 31 | doc[v] = Array(doc[v]) if doc[v] 32 | Array(old).each {|ov| doc[v] << ov} 33 | else 34 | doc[v] = old 35 | end 36 | end 37 | end 38 | end 39 | end 40 | json 41 | end 42 | 43 | def table_exists?(table_name) 44 | ['Collection', 'Item'].include? table_name 45 | end 46 | 47 | def sanitize_limit(limit_value) 48 | if (0..500) === limit_value.to_s.to_i 49 | limit_value 50 | else 51 | Ansr::Relation::DEFAULT_PAGE_SIZE 52 | end 53 | end 54 | 55 | end 56 | end 57 | 58 | end -------------------------------------------------------------------------------- /ansr_blacklight/lib/ansr_blacklight/relation.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Blacklight 2 | class Relation < Ansr::Relation 3 | include Ansr::Blacklight::SolrProjectionMethods 4 | ::ActiveRecord::Relation::VALID_UNSCOPING_VALUES << :defType << :wt 5 | ::ActiveRecord::Relation::SINGLE_VALUE_METHODS << :defType << :wt 6 | 7 | delegate :blacklight_config, to: :model 8 | 9 | delegate :docs, to: :response 10 | delegate :params, to: :response 11 | delegate :facet_pivot, to: :response 12 | delegate :facet_queries, to: :response 13 | # overrides for query response handling 14 | def docs_from(response) 15 | grouped? ? [] : response.docs 16 | end 17 | 18 | def facets_from(response) 19 | response.facets 20 | end 21 | 22 | def total 23 | response.total 24 | end 25 | 26 | # overrides for weird Blacklight expectations 27 | def max_pages 28 | if Kaminari.config.respond_to? :max_pages 29 | nil 30 | else 31 | super 32 | end 33 | end 34 | 35 | def limit_value 36 | (super || default_limit_value) 37 | end 38 | 39 | def build_arel 40 | arel = super 41 | solr_props = {} 42 | solr_props[:defType] = defType_value if defType_value 43 | solr_props[:wt] = wt_value if wt_value 44 | unless solr_props.empty? 45 | prop_node = Ansr::Arel::Nodes::ProjectionTraits.new arel.grouping(arel.projections), solr_props 46 | arel.projections = [prop_node] 47 | end 48 | arel 49 | end 50 | 51 | def spelling 52 | loaded 53 | response.spelling 54 | end 55 | 56 | def grouped? 57 | loaded? ? response.grouped? : !group_values.blank? 58 | end 59 | 60 | def group_by(key=self.group_values.first) 61 | loaded 62 | response.group(key, model) 63 | end 64 | end 65 | end -------------------------------------------------------------------------------- /ansr_blacklight/lib/ansr_blacklight.rb: -------------------------------------------------------------------------------- 1 | require 'ansr' 2 | require 'rsolr' 3 | module Ansr::Blacklight 4 | extend ActiveSupport::Autoload 5 | autoload :SolrProjectionMethods, 'ansr_blacklight/relation/solr_projection_methods' 6 | require 'ansr_blacklight/solr' 7 | require 'ansr_blacklight/request_builders' 8 | require 'ansr_blacklight/arel' 9 | require 'ansr_blacklight/connection_adapters/no_sql_adapter' 10 | require 'ansr_blacklight/relation' 11 | require 'ansr_blacklight/model/querying' 12 | require 'ansr_blacklight/base' 13 | 14 | def self.solr_file 15 | "#{::Rails.root.to_s}/config/solr.yml" 16 | end 17 | 18 | def self.solr 19 | @solr ||= RSolr.connect(Ansr::Blacklight.solr_config) 20 | end 21 | 22 | def self.solr_config 23 | @solr_config ||= begin 24 | raise "The #{::Rails.env} environment settings were not found in the solr.yml config" unless solr_yml[::Rails.env] 25 | solr_yml[::Rails.env].symbolize_keys 26 | end 27 | end 28 | 29 | def self.solr_yml 30 | require 'erb' 31 | require 'yaml' 32 | 33 | return @solr_yml if @solr_yml 34 | unless File.exists?(solr_file) 35 | raise "You are missing a solr configuration file: #{solr_file}. Have you run \"rails generate blacklight:install\"?" 36 | end 37 | 38 | begin 39 | @solr_erb = ERB.new(IO.read(solr_file)).result(binding) 40 | rescue Exception => e 41 | raise("solr.yml was found, but could not be parsed with ERB. \n#{$!.inspect}") 42 | end 43 | 44 | begin 45 | @solr_yml = YAML::load(@solr_erb) 46 | rescue StandardError => e 47 | raise("solr.yml was found, but could not be parsed.\n") 48 | end 49 | 50 | if @solr_yml.nil? || !@solr_yml.is_a?(Hash) 51 | raise("solr.yml was found, but was blank or malformed.\n") 52 | end 53 | 54 | return @solr_yml 55 | end 56 | 57 | end -------------------------------------------------------------------------------- /lib/ansr/model.rb: -------------------------------------------------------------------------------- 1 | module Ansr 2 | module Model 3 | extend ActiveSupport::Concern 4 | module ClassMethods 5 | def spawn 6 | s = build_default_scope 7 | s.references!(references()) 8 | end 9 | 10 | def inherited(subclass) 11 | super 12 | # a hack for sanitize sql overrides to work, and some others where @klass used in place of klass() 13 | subclass.instance_variable_set("@klass", subclass) 14 | # a hack for the intermediate abstract model classes to work with table_name 15 | subclass.instance_variable_set("@table_name", subclass.name) 16 | end 17 | 18 | def model 19 | m = begin 20 | instance_variable_get "@klass" 21 | end 22 | raise "#{name()}.model() -> nil" unless m 23 | m 24 | end 25 | 26 | def references 27 | [] 28 | end 29 | 30 | def table 31 | type = (config[:table_class] || Ansr::Arel::BigTable) 32 | if @table 33 | # allow the table class to be reconfigured 34 | @table = nil unless @table.class == type 35 | end 36 | @table ||= type.new(self) 37 | end 38 | 39 | def engine 40 | model() 41 | end 42 | 43 | def model 44 | @klass 45 | end 46 | 47 | def build_default_scope 48 | Ansr::Relation.new(model(), table()) 49 | end 50 | 51 | def column_types 52 | TypeProxy.new(table()) 53 | end 54 | 55 | class TypeProxy 56 | def initialize(table) 57 | @table = table 58 | end 59 | 60 | def [](name) 61 | # this should delegate to the NoSqlAdapter 62 | ::ActiveRecord::ConnectionAdapters::Column.new(name.to_s, nil, String) 63 | end 64 | end 65 | end 66 | 67 | require 'ansr/model/connection_handler' 68 | end 69 | end -------------------------------------------------------------------------------- /ansr_dpla/spec/lib/item_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Item do 4 | describe '.configure' do 5 | it "should store the configured Api class" do 6 | Item.configure {|x| x.merge!(:api=>Ansr::Dpla::TestApi, :api_key => :dummy)} 7 | expect(Item.api).to be_a Ansr::Dpla::TestApi 8 | end 9 | end 10 | 11 | describe '.find' do 12 | it "should find an item given an id" do 13 | mock_api = double('api') 14 | Item.api = mock_api 15 | mock_api.should_receive(:items).with(:id=>"123", :page_size=>1).and_return(read_fixture('item.jsonld')) 16 | Item.find('123') 17 | end 18 | it "should raise an exception for a bad id" do 19 | mock_api = double('api') 20 | Item.api = mock_api 21 | mock_api.should_receive(:items).with(:id=>"123", :page_size=>1).and_return(read_fixture('empty.jsonld')) 22 | expect {Item.find('123')}.to raise_error 23 | end 24 | end 25 | 26 | describe '.where' do 27 | before do 28 | Item.configure{|x| x.merge!({:api=>Ansr::Dpla::TestApi, :api_key => :dummy})} 29 | end 30 | it 'should return a Relation when there is query information' do 31 | expect(Item.where({:q=>'kittens'})).to be_a Ansr::Relation 32 | end 33 | it 'should return itself when there is no query information' do 34 | expect(Item.where({})).to be Item 35 | end 36 | end 37 | 38 | describe 'accessor methods' do 39 | before do 40 | mock_api = double('api') 41 | Item.api = mock_api 42 | @hash = JSON.parse(read_fixture('item.jsonld'))['docs'][0] 43 | @test = Item.new(@hash) 44 | end 45 | 46 | it 'should dispatch method names to the hash' do 47 | @test.dataProvider.should == "Boston Public Library" 48 | @test.sourceResource.identifier.should == [ "Local accession: 08_06_000884" ] 49 | end 50 | 51 | it 'should miss methods for undefined fields' do 52 | expect {@test.foo}.to raise_error 53 | end 54 | end 55 | 56 | 57 | end -------------------------------------------------------------------------------- /lib/ansr/base.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | module Ansr 3 | class Base < ActiveRecord::Base 4 | include Ansr::Model 5 | extend Ansr::Configurable 6 | extend Ansr::QueryMethods 7 | extend Ansr::ArelMethods 8 | include Ansr::Sanitization 9 | #TODO remove the dummy associations 10 | include Ansr::DummyAssociations 11 | 12 | self.abstract_class = true 13 | 14 | def self.method 15 | @method ||= :get 16 | end 17 | 18 | def self.method=(method) 19 | @method = method 20 | end 21 | 22 | def initialize doc={}, options={} 23 | super(filter_source_hash(doc), options) 24 | @source_doc = doc 25 | end 26 | 27 | def core_initialize(attributes = nil, options = {}) 28 | defaults = self.class.column_defaults.dup 29 | defaults.each { |k, v| defaults[k] = v.dup if v.duplicable? } 30 | 31 | @attributes = self.class.initialize_attributes(defaults) 32 | @column_types_override = nil 33 | @column_types = self.class.column_types 34 | 35 | init_internals 36 | init_changed_attributes 37 | ensure_proper_type 38 | populate_with_current_scope_attributes 39 | 40 | # +options+ argument is only needed to make protected_attributes gem easier to hook. 41 | # Remove it when we drop support to this gem. 42 | init_attributes(attributes, options) if attributes 43 | 44 | yield self if block_given? 45 | run_callbacks :initialize unless _initialize_callbacks.empty? 46 | end 47 | 48 | def filter_source_hash(doc) 49 | fields = self.class.model().table().fields() 50 | filtered = doc.select do |k,v| 51 | fields.include? k.to_sym 52 | end 53 | filtered.with_indifferent_access 54 | end 55 | 56 | def columns(name=self.name) 57 | super(name) 58 | end 59 | 60 | def [](key) 61 | @source_doc[key] 62 | end 63 | 64 | def has_key? key 65 | @source_doc.has_key? key 66 | end 67 | end 68 | end -------------------------------------------------------------------------------- /ansr_blacklight/spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', '..', 'lib')) 2 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 3 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'app/models')) 4 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 5 | 6 | ENV["RAILS_ENV"] ||= 'test' 7 | require 'ansr' 8 | require 'rails/all' 9 | require 'rspec/rails' 10 | require 'loggable' 11 | require 'ansr_blacklight' 12 | #require 'blacklight' 13 | 14 | RSpec.configure do |config| 15 | # == Mock Framework 16 | # 17 | # If you prefer to use mocha, flexmock or RR, uncomment the appropriate line: 18 | # 19 | # config.mock_with :mocha 20 | # config.mock_with :flexmock 21 | # config.mock_with :rr 22 | config.mock_with :rspec 23 | 24 | # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures 25 | # config.fixture_path = "#{::Rails.root}/spec/fixtures" 26 | 27 | # If you're not using ActiveRecord, or you'd prefer not to run each of your 28 | # examples within a transaction, remove the following line or assign false 29 | # instead of true. 30 | #config.use_transactional_fixtures = true 31 | end 32 | 33 | def fixture_path(path) 34 | File.join(File.dirname(__FILE__), '..', 'fixtures', path) 35 | end 36 | 37 | def fixture path, &block 38 | if block_given? 39 | open(fixture_path(path)) &block 40 | else 41 | open(fixture_path(path)) 42 | end 43 | end 44 | 45 | def read_fixture(path) 46 | _f = fixture(path) 47 | _f.read 48 | ensure 49 | _f and _f.close 50 | end 51 | 52 | def stub_solr(response='') 53 | solr = double('Solr') 54 | solr.stub(:send_and_receive).and_return(eval(response)) 55 | solr 56 | end 57 | 58 | def create_response(response, params = {}) 59 | Ansr::Blacklight::Solr::Response.new(response, params) 60 | end 61 | 62 | class TestModel < Ansr::Blacklight::Base 63 | configure do |config| 64 | config[:unique_key] = 'id' 65 | end 66 | def self.solr=(solr) 67 | @solr = solr 68 | end 69 | def self.solr 70 | @solr 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /ansr_dpla/test/system.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'app/models')) 2 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', '..', 'lib')) 3 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 4 | require 'ansr_dpla' 5 | require 'item' 6 | 7 | # you config the model with a hash including an API key for dp.la/v2, or the path to a YAML file 8 | open('config/dpla.yml') do |blob| 9 | Item.config {|x| x.merge! YAML.load(blob)} 10 | end 11 | 12 | # then you can find single items with known IDs 13 | puts Item.find("7eb617e559007e1ad6d95bd30a30b16b") 14 | 15 | 16 | # or you can search with ActiveRecord::Relations 17 | opts = {q: 'kittens', facets: 'sourceResource.contributor'} 18 | rel = Item.where(opts) 19 | # this means the results are lazy-loaded, #load or #to_a will load them 20 | rel.to_a 21 | # you can also assemble the queries piecemeal with the Relation's decorator pattern 22 | # the where decorator adds query fields 23 | # the select decorator adds response fields 24 | # the limit decorator adds a page size limit 25 | # the offset decorator adds a non-zero starting point in the response set 26 | # the filter decorator adds filter/facet fields and optionally values to query them on 27 | rel = Item.where(q: 'kittens').limit(2).facet('sourceResource.contributor').select('sourceResource.title') 28 | rel.to_a.each do |item| 29 | puts "#{item["id"]} \"#{item['sourceResource.title']}\"" 30 | end 31 | # the filter values for the query are available on the relation after it is loaded 32 | rel.facets.each do |k,f| 33 | puts "#{k} values" 34 | f.items.each do |item| 35 | puts "facet: \"#{item.value}\" : #{item.hits}" 36 | end 37 | end 38 | # the loaded Relation has attributes describing the response set 39 | rel.count # the size of the response 40 | # the where decorator can be negated 41 | rel = rel.where.not(q: 'cats') 42 | 43 | rel.to_a.each do |item| 44 | puts "#{item["id"]} \"#{item['sourceResource.title']}\" \"#{item['originalRecord']}\"" 45 | end 46 | 47 | rel.facets.each do |k,f| 48 | puts "#{k} values" 49 | f.items.each do |item| 50 | puts " \"#{item.value}\" : #{item.hits}" 51 | end 52 | end -------------------------------------------------------------------------------- /lib/ansr/utils.rb: -------------------------------------------------------------------------------- 1 | require 'ostruct' 2 | module Ansr 3 | class OpenStructWithHashAccess < OpenStruct 4 | delegate :keys, :each, :map, :has_key?, :empty?, :delete, :length, :reject!, :select!, :include, :fetch, :to_json, :as_json, :to => :to_h 5 | 6 | def []=(key, value) 7 | send "#{key}=", value 8 | end 9 | 10 | def [](key) 11 | send key 12 | end 13 | 14 | def to_h 15 | @table 16 | end 17 | 18 | def merge other_hash 19 | self.class.new to_h.merge((other_hash if other_hash.is_a? Hash) || other_hash.to_h) 20 | end 21 | 22 | def merge! other_hash 23 | @table.merge!((other_hash if other_hash.is_a? Hash) || other_hash.to_h) 24 | end 25 | end 26 | 27 | class NestedOpenStructWithHashAccess < OpenStructWithHashAccess 28 | attr_reader :nested_class 29 | delegate :default_proc=, :to => :to_h 30 | 31 | def initialize klass, *args 32 | @nested_class = klass 33 | hash = {} 34 | 35 | hashes_and_keys = args.flatten 36 | lazy_configs = hashes_and_keys.extract_options! 37 | 38 | args.each do |v| 39 | if v.is_a? Hash 40 | key = v.first 41 | value = v[key] 42 | 43 | hash[key] = nested_class.new value 44 | else 45 | hash[v] = nested_class.new 46 | end 47 | end 48 | 49 | lazy_configs.each do |k,v| 50 | hash[k] = nested_class.new v 51 | end 52 | 53 | super hash 54 | set_default_proc! 55 | end 56 | 57 | def << key 58 | @table[key] 59 | end 60 | 61 | def []=(key, value) 62 | if value.is_a? Hash 63 | send "#{key}=", nested_class.new(value) 64 | else 65 | send "#{key}=", value 66 | end 67 | end 68 | 69 | def marshal_dump 70 | h = to_h.dup 71 | h.default = nil 72 | 73 | [nested_class, h] 74 | end 75 | 76 | def marshal_load x 77 | @nested_class = x.first 78 | super x.last 79 | set_default_proc! 80 | end 81 | 82 | private 83 | def set_default_proc! 84 | self.default_proc = lambda do |hash, key| 85 | hash[key] = self.nested_class.new 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /ansr_dpla/spec/lib/relation/select_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ansr::Relation do 4 | before do 5 | @kittens = read_fixture('kittens.jsonld') 6 | @faceted = read_fixture('kittens_faceted.jsonld') 7 | @empty = read_fixture('empty.jsonld') 8 | @mock_api = double('api') 9 | Item.config{ |x| x[:api_key] = :foo} 10 | Item.engine.api= @mock_api 11 | end 12 | 13 | subject { Ansr::Dpla::Relation.new(Item, Item.table) } 14 | 15 | describe '#select' do 16 | describe 'with a block given' do 17 | it "should build an array" do 18 | test = subject.where(q:'kittens') 19 | @mock_api.should_receive(:items).with(:q => 'kittens').and_return(@kittens) 20 | actual = test.select {|d| true} 21 | expect(actual).to be_a(Array) 22 | expect(actual.length).to eql(test.limit_value) 23 | actual = test.select {|d| false} 24 | expect(actual).to be_a(Array) 25 | expect(actual.length).to eql(0) 26 | end 27 | end 28 | describe 'with a String or Symbol key given' do 29 | it 'should change the requested document fields' do 30 | test = subject.where(q:'kittens') 31 | @mock_api.should_receive(:items).with(:q => 'kittens', :fields=>:name).and_return('') 32 | test = test.select('name') 33 | test.load 34 | end 35 | end 36 | describe 'with a list of keys' do 37 | it "should add all the requested document fields" do 38 | test = subject.where(q:'kittens') 39 | @mock_api.should_receive(:items).with(:q => 'kittens', :fields=>[:name,:foo]).and_return('') 40 | test = test.select(['name','foo']) 41 | test.load 42 | end 43 | it "should add all the requested document fields and proxy them" do 44 | test = subject.where(q:'kittens') 45 | @mock_api.should_receive(:items).with(:q => 'kittens', :fields=>:object).and_return(@kittens) 46 | test = test.select('object AS my_object') 47 | test.load 48 | expect(test.to_a.first['object']).to be_nil 49 | expect(test.to_a.first['my_object']).to eql('http://ark.digitalcommonwealth.org/ark:/50959/6682xj30d/thumbnail') 50 | end 51 | end 52 | end 53 | 54 | end -------------------------------------------------------------------------------- /ansr_dpla/lib/ansr_dpla/api.rb: -------------------------------------------------------------------------------- 1 | require 'rest_client' 2 | module Ansr::Dpla 3 | class Api 4 | include Ansr::Configurable 5 | 6 | def config &block 7 | super &block 8 | raise "DPLA clients must be configured with an API key" unless @config[:api_key] 9 | @config 10 | end 11 | 12 | 13 | API_PARAM_KEYS = [:api_key, :callback, :facets, :fields, :page, :page_size, :sort_by, :sort_by_pin, :sort_order] 14 | 15 | def initialize(config=nil) 16 | self.config{|x| x.merge!(config)} if config 17 | end 18 | 19 | def api_key 20 | config[:api_key] 21 | end 22 | 23 | def url 24 | config[:url] || 'http://api.dp.la/v2/' 25 | end 26 | 27 | def path_for base, options = nil 28 | return "#{base}?api_key=#{self.api_key}" unless options.is_a? Hash 29 | options = {:api_key=>api_key}.merge(options) 30 | API_PARAM_KEYS.each do |query_key| 31 | options[query_key] = options[query_key].join(',') if options[query_key].is_a? Array 32 | end 33 | (options.keys - API_PARAM_KEYS).each do |query_key| 34 | options[query_key] = options[query_key].join(' AND ') if options[query_key].is_a? Array 35 | options[query_key].sub!(/^OR /,'') 36 | options[query_key].gsub!(/\s+AND\sOR\s+/, ' OR ') 37 | end 38 | "#{base}" + (("?#{options.map { |key, value| "#{CGI::escape(key.to_s)}=#{CGI::escape(value.to_s)}"}.join("&") }" if options and not options.empty?) || '') 39 | end 40 | 41 | def client 42 | @client ||= RestClient::Resource.new(self.url) 43 | end 44 | 45 | def items_path(options={}) 46 | path_for('items', options) 47 | end 48 | 49 | def items(options = {}) 50 | client[items_path(options)].get 51 | end 52 | 53 | def item_path(id) 54 | path_for("items/#{id}") 55 | end 56 | 57 | def item(id) 58 | client[item_path(id)].get 59 | end 60 | 61 | def collections_path(options={}) 62 | path_for('collections', options) 63 | end 64 | 65 | def collections(options = {}) 66 | client[collections_path(options)].get 67 | end 68 | 69 | def collection_path(id) 70 | path_for("collections/#{id}") 71 | end 72 | 73 | def collection(id) 74 | client[collection_path(id)].get 75 | end 76 | 77 | end 78 | end -------------------------------------------------------------------------------- /ansr_dpla/fixtures/collections.jsonld: -------------------------------------------------------------------------------- 1 | { 2 | "count": 5, 3 | "start": 0, 4 | "limit": 10, 5 | "docs": [ 6 | { 7 | "id": "460c76299e1b0a46afea352b1ab8f556", 8 | "ingestDate": "2014-02-15T17:00:26.122359", 9 | "_rev": "372-9d33a508d82b97621b456623321886b3", 10 | "title": "Leslie Jones Collection", 11 | "_id": "bpl--commonwealth:2j62s484w", 12 | "ingestType": "collection", 13 | "@id": "http://dp.la/api/collections/460c76299e1b0a46afea352b1ab8f556", 14 | "ingestionSequence": 5, 15 | "score": 6.857883 16 | }, 17 | { 18 | "id": "7c7eaa8eee08d3c9b32d55662c0de58b", 19 | "ingestDate": "2014-02-05T03:09:43.108843", 20 | "_rev": "137-6262bdd35a8cf3397aefed97d5f2731e", 21 | "title": "Thomas Jones Davies Bible Records", 22 | "_id": "scdl-usc--davies", 23 | "ingestType": "collection", 24 | "@id": "http://dp.la/api/collections/7c7eaa8eee08d3c9b32d55662c0de58b", 25 | "ingestionSequence": 7, 26 | "score": 6.177192 27 | }, 28 | { 29 | "id": "f807cdcefe2c93f7f71bf99f47899e1f", 30 | "ingestDate": "2014-01-19T09:04:22.366278", 31 | "_rev": "3-83d42699bd0836d639d948d921a73d66", 32 | "title": "Kirby Jones Papers, 1963 - 1974", 33 | "_id": "nara--1159", 34 | "ingestType": "collection", 35 | "@id": "http://dp.la/api/collections/f807cdcefe2c93f7f71bf99f47899e1f", 36 | "ingestionSequence": 5, 37 | "score": 6.0006475 38 | }, 39 | { 40 | "id": "c1ae618e5958fd9c9e869ebbfd47bc34", 41 | "ingestDate": "2014-01-19T09:04:21.373908", 42 | "_rev": "3-5f88173336cf37fb8e33ddbfb1c62705", 43 | "title": "Joseph M. Jones Papers, 1928 - 1987", 44 | "_id": "nara--604476", 45 | "ingestType": "collection", 46 | "@id": "http://dp.la/api/collections/c1ae618e5958fd9c9e869ebbfd47bc34", 47 | "ingestionSequence": 5, 48 | "score": 5.391162 49 | }, 50 | { 51 | "id": "e1e5fee5c47484e307ccdbb717a75e7f", 52 | "ingestDate": "2014-01-30T12:12:19.274456", 53 | "_rev": "4-6d0514009fcf0b2cc68a178fdf3209ac", 54 | "title": "John Paul Jones papers, 1924-1999", 55 | "_id": "smithsonian--john_paul_jones_papers_1924_1999", 56 | "ingestType": "collection", 57 | "@id": "http://dp.la/api/collections/e1e5fee5c47484e307ccdbb717a75e7f", 58 | "ingestionSequence": 8, 59 | "score": 5.317501 60 | } 61 | ], 62 | "facets": [ 63 | 64 | ] 65 | } -------------------------------------------------------------------------------- /ansr_blacklight/lib/ansr_blacklight/solr/response.rb: -------------------------------------------------------------------------------- 1 | ## copied directly from Blacklight::SolrResponse 2 | class Ansr::Blacklight::Solr::Response < HashWithIndifferentAccess 3 | 4 | require 'ansr_blacklight/solr/response/pagination_methods' 5 | 6 | autoload :Spelling, 'ansr_blacklight/solr/response/spelling' 7 | autoload :MoreLikeThis, 'ansr_blacklight/solr/response/more_like_this' 8 | autoload :GroupResponse, 'ansr_blacklight/solr/response/group_response' 9 | autoload :Group, 'ansr_blacklight/solr/response/group' 10 | 11 | include PaginationMethods 12 | 13 | attr_reader :request_params 14 | def initialize(data, request_params) 15 | super(data) 16 | @request_params = request_params 17 | extend Spelling 18 | extend Ansr::Facets 19 | extend InternalResponse 20 | extend MoreLikeThis 21 | end 22 | 23 | def header 24 | self['responseHeader'] 25 | end 26 | 27 | def update(other_hash) 28 | other_hash.each_pair { |key, value| self[key] = value } 29 | self 30 | end 31 | 32 | def params 33 | (header and header['params']) ? header['params'] : request_params 34 | end 35 | 36 | def rows 37 | params[:rows].to_i 38 | end 39 | 40 | def docs 41 | @docs ||= begin 42 | response['docs'] || [] 43 | end 44 | end 45 | 46 | def spelling 47 | self['spelling'] 48 | end 49 | 50 | def grouped(model) 51 | @groups ||= self["grouped"].map do |field, group| 52 | # grouped responses can either be grouped by: 53 | # - field, where this key is the field name, and there will be a list 54 | # of documents grouped by field value, or: 55 | # - function, where the key is the function, and the documents will be 56 | # further grouped by function value, or: 57 | # - query, where the key is the query, and the matching documents will be 58 | # in the doclist on THIS object 59 | if group["groups"] # field or function 60 | GroupResponse.new field, model, group, self 61 | else # query 62 | Group.new({field => field}, model, group, self) 63 | end 64 | end 65 | end 66 | 67 | def group key, model 68 | grouped(model).select { |x| x.key == key }.first 69 | end 70 | 71 | def grouped? 72 | self.has_key? "grouped" 73 | end 74 | 75 | module InternalResponse 76 | def response 77 | self[:response] || {} 78 | end 79 | 80 | # short cut to response['numFound'] 81 | def total 82 | response[:numFound].to_s.to_i 83 | end 84 | 85 | def start 86 | response[:start].to_s.to_i 87 | end 88 | 89 | def empty? 90 | total == 0 91 | end 92 | 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/ansr/connection_adapters/no_sql_adapter.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | require 'arel/visitors/bind_visitor' 3 | module Ansr 4 | module ConnectionAdapters 5 | class NoSqlAdapter < ActiveRecord::ConnectionAdapters::AbstractAdapter 6 | attr_reader :table 7 | def initialize(klass, connection, logger = nil, pool = nil) 8 | super(connection, logger, pool) 9 | @table = klass.table 10 | @visitor = nil 11 | end 12 | 13 | # Converts an arel AST to NOSQL Query 14 | def to_nosql(arel, binds = []) 15 | arel = arel.ast if arel.respond_to?(:ast) 16 | if arel.is_a? ::Arel::Nodes::Node 17 | binds = binds.dup 18 | visitor.accept(arel) do 19 | quote(*binds.shift.reverse) 20 | end 21 | else # assume it is already serialized 22 | arel 23 | end 24 | end 25 | 26 | # attr_accessor :visitor is a self.class::BindSubstitution in unprepared contexts 27 | class BindSubstitution < ::Arel::Visitors::MySQL # :nodoc: 28 | include ::Arel::Visitors::BindVisitor 29 | end 30 | 31 | # Executes +query+ statement in the context of this connection using 32 | # +binds+ as the bind substitutes. +name+ is logged along with 33 | # the executed +query+ statement. 34 | def execute(query, name = 'ANSR-NOSQL') 35 | end 36 | 37 | # called back from ::Arel::Table 38 | def primary_key(table_name) 39 | 'id' # table.primary_key || 'id' 40 | end 41 | 42 | def table_exists?(name) 43 | true 44 | end 45 | 46 | def schema_cache 47 | ActiveRecord::ConnectionAdapters::SchemaCache.new(self) 48 | end 49 | 50 | # this is called by the BigTable impl 51 | # should it be retired in favor of the more domain-appropriate 'fields'? Not usually seen by clients anyway. 52 | def columns(table_name, *rest) 53 | @table.fields.map {|s| ::ActiveRecord::ConnectionAdapters::Column.new(s.to_s, nil, String)} 54 | end 55 | 56 | def sanitize_limit(limit_value) 57 | if limit_value.to_s.to_i >= 0 58 | limit_value 59 | else 60 | Ansr::Relation::DEFAULT_PAGE_SIZE 61 | end 62 | end 63 | 64 | def sanitize_filter_name(filter_value) 65 | if filter_value.is_a? Array 66 | return filter_value.collect {|x| sanitize_filter_name(x)}.compact 67 | else 68 | if @table.facets.include? filter_value.to_sym 69 | return filter_value 70 | else 71 | raise "#{filter_value} is not a filterable field" 72 | #Rails.logger.warn "Ignoring #{filter_value} (not a filterable field)" if Rails.logger 73 | #return nil 74 | end 75 | end 76 | end 77 | 78 | end 79 | end 80 | end -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ansr 2 | ==== 3 | 4 | ActiveRecord(No-SQL)::Relation + Blacklight 5 | 6 | Ansr is a library for building ActiveRecord-style models and Relation implementations that query no-SQL data sources. 7 | Ansr is motivated by a proposed refactoring of Blacklight at Code4Lib 2014. 8 | 9 | [Blacklight](https://github.com/projectblacklight/blacklight) (BL) defines itself as “an open source Solr user interface discovery platform.” The coupling to Solr is evident in the structure: Solr querying facilities are sprinkled throughout several mixins that are included in BL controllers. This results in a codebase that cannot, as is regularly asked on the mailing lists, be used in front of another document store (eg ElasticSeach). But this is not necessarily the case. 10 | 11 | BL might be refactored to locate the actual Solr querying machinery behind the core model of BL apps (currently called SolrDocument). Refactoring the codebase this way would realize several benefits: 12 | 13 | 1. Adherence to the Principle of Least Surprise: The BL document model would behave more like a Rails model backed by RDBMS. When bringing new developers into a BL project, familiarity with the standard patterns of Rails would translate more immediately to the BL context. 14 | 15 | 2. Flexible abstraction of the document store: Moving the specifics of querying the document store would make the introduction of models interacting with other stores possible. For example, the DPLA REST API exposes some Solr-like concepts, and a proof-of-concept model for an ActiveRecord-like approach to searching them can be seen at (https://github.com/barmintor/ansr/tree/master/ansr_dpla) 16 | 17 | 3. Clearer testing strategies and ease of console debugging 18 | 19 | 4. Clarification of BL’s relationship to RSolr as the provider to an analog for ActiveRecord::Relation 20 | 21 | What would such a refactor require? 22 | 23 | 1. A definition of the backend requirements of BL beyond a reference to Solr per se: indexed documents with fields, a concept of facets corresponding to the Solr/Lucene definitions, the ability to expose Hash-like representations of results. 24 | 25 | 2. A relocation of the searching methods from Blacklight::Catalog and Blacklight::SolrHelper into a model generated to include Solr code 26 | 27 | 3. An accommodation of controller-specific Solr configuration, possibly resolved by having the BL config register Solr parms with the model a la SolrDocument extensions in BL 4 28 | 29 | 4. An abstraction of the fielded/faceted search parameters to mimic ActiveRecord limits 30 | 31 | 5. A partner institution capable of producing integration and system testing support for, at minimum, another Lucene-backed document store (ElasticSearch) 32 | 33 | Since these changes are incompatible with current BL, they are proposed as a principal feature of BL 6.0. 34 | 35 | An example of the kinds of models to be implemented can be seen in a [DPLA proof of concept](https://github.com/barmintor/ansr/tree/master/ansr_dpla). 36 | -------------------------------------------------------------------------------- /ansr_dpla/README.md: -------------------------------------------------------------------------------- 1 | Ansr::Dpla 2 | ===== 3 | 4 | DPLA + ActiveRecord::Relation + Blacklight 5 | 6 | This project creates a Rails model that can be used to search the DPLA's public REST API (http://dp.la/info/developers/codex/). The goal of this project is to provide an avenue by which data from the DPLA REST API might be explored via a [Blacklight](https://github.com/projectblacklight/blacklight) application. It is proof-of-concept for a broader proposal described at (https://github.com/barmintor/ansr) 7 | 8 | To use the Item and Collection models, they must first be configured with a DPLA API key: 9 | 10 | Item.config({:api_key => 'your api key'}) 11 | Instructions for creating an API key are here: http://dp.la/info/developers/codex/policies/#get-a-key 12 | 13 | Once the model is configured, it can be queried like any other Rails model. 14 | 15 | item_id = "7eb617e559007e1ad6d95bd30a30b16b" 16 | Item.find(item_id) 17 | Item.dataProvider # returns a single string value 18 | 19 | The Item and Collection models are searched like other Rails models, with relations. To search a single field, call 'where': 20 | 21 | rel = Item.where(q: 'kittens') # full text search for 'kittens' 22 | 23 | Where clauses can be negated: 24 | 25 | rel = rel.where.not(q: => 'cats') # maximize cuteness density 26 | Where clauses support simple unions: 27 | 28 | rel = rel.where.or(q: => 'puppies') # so egalitarian 29 | These relations are lazy-loaded; they make no queries until data is required: 30 | 31 | rel.load # loads the data if not loaded 32 | rel.to_a # loads the data if not loaded, returns an array of model instances 33 | rel.filters # loads the data if not loaded, returns a list of the filters/facets for the query 34 | rel.count # returns the number of records loaded 35 | 36 | This is to support a decorator pattern on the relations: 37 | 38 | rel = Item.where(q; 'kittens').where.not(q: 'cats') 39 | rel = rel.limit(25).offset(50) # start on the third page of 25 40 | 41 | In addition to where, there are a number of other decorator clauses: 42 | 43 | # select limits the fields returned 44 | rel = rel.select(:id, :"sourceResource.title") 45 | # limit sets the maximum number of records to return, default is 10 46 | rel = rel.limit(25) 47 | # offset sets a starting point; it must be a multiple of the limit 48 | rel = rel.offset(50) # start on page 3 49 | # order adds sort clauses 50 | rel = rel.order(:"sourceResource.title") 51 | 52 | When using the filter decorator, you can add field names without a query. This will add the field to the relations filter information after load: 53 | 54 | # filter adds filter/facet clauses 55 | rel = rel.filter(:"collection.id" => '460c76299e1b0a46afea352b1ab8f556') 56 | rel = rel.filter(:isShownAt) 57 | rel.filters # -> a list of two filters, and the counts for the filter constraint values in the response set 58 | 59 | And more, as per ActiveRecord and illustrated in the specs. 60 | -------------------------------------------------------------------------------- /lib/ansr/facets.rb: -------------------------------------------------------------------------------- 1 | require 'ostruct' 2 | 3 | module Ansr::Facets 4 | 5 | # represents a facet value; which is a field value and its hit count 6 | class FacetItem < OpenStruct 7 | def initialize *args 8 | options = args.extract_options! 9 | 10 | # Backwards-compat method signature 11 | value = args.shift 12 | hits = args.shift 13 | 14 | options[:value] = value if value 15 | options[:hits] = hits if hits 16 | 17 | super(options) 18 | end 19 | 20 | def label 21 | super || value 22 | end 23 | 24 | def as_json(props = nil) 25 | table.as_json(props) 26 | end 27 | end 28 | 29 | # represents a facet; which is a field and its values 30 | class FacetField 31 | attr_reader :name, :items 32 | def initialize name, items, options = {} 33 | @name, @items = name, items 34 | @options = options 35 | end 36 | 37 | def limit 38 | @options[:limit] 39 | end 40 | 41 | def sort 42 | @options[:sort] || 'index' 43 | end 44 | 45 | def offset 46 | @options[:offset] || 0 47 | end 48 | end 49 | 50 | # @response.facets.each do |facet| 51 | # facet.name 52 | # facet.items 53 | # end 54 | # "caches" the result in the @facets instance var 55 | def facets 56 | @facets ||= begin 57 | facet_fields.map do |(facet_field_name,values_and_hits)| 58 | items = [] 59 | options = {} 60 | values_and_hits.each_slice(2) do |k,v| 61 | items << FacetItem.new(:value => k, :hits => v) 62 | end 63 | options[:sort] = (params[:"f.#{facet_field_name}.facet.sort"] || params[:'facet.sort']) 64 | if params[:"f.#{facet_field_name}.facet.limit"] || params[:"facet.limit"] 65 | options[:limit] = (params[:"f.#{facet_field_name}.facet.limit"] || params[:"facet.limit"]).to_i 66 | end 67 | 68 | if params[:"f.#{facet_field_name}.facet.offset"] || params[:'facet.offset'] 69 | options[:offset] = (params[:"f.#{facet_field_name}.facet.offset"] || params[:'facet.offset']).to_i 70 | end 71 | FacetField.new(facet_field_name, items, options) 72 | end 73 | end 74 | end 75 | 76 | # pass in a facet field name and get back a Facet instance 77 | def facet_by_field_name(name) 78 | @facets_by_field_name ||= {} 79 | @facets_by_field_name[name] ||= ( 80 | facets.detect{|facet|facet.name.to_s == name.to_s} 81 | ) 82 | end 83 | 84 | def facet_counts 85 | @facet_counts ||= self['facet_counts'] || {} 86 | end 87 | 88 | # Returns the hash of all the facet_fields (ie: {'instock_b' => ['true', 123, 'false', 20]} 89 | def facet_fields 90 | @facet_fields ||= facet_counts['facet_fields'] || {} 91 | end 92 | 93 | # Returns all of the facet queries 94 | def facet_queries 95 | @facet_queries ||= facet_counts['facet_queries'] || {} 96 | end 97 | 98 | # Returns all of the facet queries 99 | def facet_pivot 100 | @facet_pivot ||= facet_counts['facet_pivot'] || {} 101 | end 102 | 103 | end # end Facets 104 | -------------------------------------------------------------------------------- /ansr_dpla/spec/lib/relation/where_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ansr::Relation do 4 | before do 5 | @kittens = read_fixture('kittens.jsonld') 6 | @faceted = read_fixture('kittens_faceted.jsonld') 7 | @empty = read_fixture('empty.jsonld') 8 | @mock_api = double('api') 9 | Item.config{ |x| x[:api_key] = :foo} 10 | Item.engine.api= @mock_api 11 | end 12 | describe '#where' do 13 | it "do a single field, single value where" do 14 | test = Ansr::Relation.new(Item, Item.table).where(:q=>'kittens') 15 | @mock_api.should_receive(:items).with(:q => 'kittens').and_return('') 16 | test.load 17 | end 18 | it "do a single field, multiple value where" do 19 | test = Ansr::Relation.new(Item, Item.table).where(:q=>['kittens', 'cats']) 20 | @mock_api.should_receive(:items).with(:q => ['kittens','cats']).and_return('') 21 | test.load 22 | end 23 | it "do merge single field, multiple value wheres" do 24 | test = Ansr::Relation.new(Item, Item.table).where(:q=>'kittens').where(:q=>'cats') 25 | @mock_api.should_receive(:items).with(:q => ['kittens','cats']).and_return('') 26 | test.load 27 | end 28 | it "do a multiple field, single value where" do 29 | test = Ansr::Relation.new(Item, Item.table).where(:q=>'kittens',:foo=>'bears') 30 | @mock_api.should_receive(:items).with(:q => 'kittens', :foo=>'bears').and_return('') 31 | test.load 32 | end 33 | it "should keep scope distinct from spawned Relations" do 34 | test = Ansr::Relation.new(Item, Item.table).where(:q=>'kittens') 35 | test.where(:q=>'cats') 36 | @mock_api.should_receive(:items).with(:q => 'kittens').and_return('') 37 | test.load 38 | end 39 | describe '#not' do 40 | it 'should exclude a specified map of field values' do 41 | test = Ansr::Relation.new(Item, Item.table) 42 | test = test.where(:foo =>'kittens') 43 | test = test.where.not(:foo => 'cats') 44 | @mock_api.should_receive(:items).with(:foo => ['kittens', 'NOT cats']).and_return('') 45 | test.load 46 | end 47 | 48 | pending 'should exclude a value from the default query' do 49 | test = Ansr::Relation.new(Item, Item.table) 50 | test = test.where('kittens') 51 | test = test.where.not('cats') 52 | @mock_api.should_receive(:items).with(:q => ['kittens', 'NOT cats']).and_return('') 53 | test.load 54 | end 55 | 56 | pending 'should exclude a specified field' do 57 | test = Ansr::Relation.new(Item, Item.table) 58 | test = test.where(:foo => 'kittens') 59 | test = test.where.not('cats') 60 | @mock_api.should_receive(:items).with(:foo => 'kittens', :q => 'NOT cats').and_return('') 61 | test.load 62 | end 63 | end 64 | describe '#or' do 65 | it 'should union a specified map of field values' do 66 | test = Ansr::Relation.new(Item, Item.table) 67 | test = test.where(:foo =>'kittens') 68 | test = test.where.or(:foo => 'cats') 69 | @mock_api.should_receive(:items).with(:foo => ['kittens', 'OR cats']).and_return('') 70 | test.load 71 | end 72 | end 73 | end 74 | end -------------------------------------------------------------------------------- /ansr_blacklight/lib/ansr_blacklight/solr/response/spelling.rb: -------------------------------------------------------------------------------- 1 | # A mixin for making access to the spellcheck component data easy. 2 | # 3 | # response.spelling.words 4 | # 5 | module Ansr::Blacklight::Solr::Response::Spelling 6 | 7 | def spelling 8 | @spelling ||= Base.new(self) 9 | end 10 | 11 | class Base 12 | 13 | attr :response 14 | 15 | def initialize(response) 16 | @response = response 17 | end 18 | 19 | # returns an array of spelling suggestion for specific query words, 20 | # as provided in the solr response. Only includes words with higher 21 | # frequency of occurrence than word in original query. 22 | # can't do a full query suggestion because we only get info for each word; 23 | # combination of words may not have results. 24 | # Thanks to Naomi Dushay! 25 | def words 26 | @words ||= ( 27 | word_suggestions = [] 28 | spellcheck = self.response[:spellcheck] 29 | if spellcheck && spellcheck[:suggestions] 30 | suggestions = spellcheck[:suggestions] 31 | unless suggestions.nil? 32 | # suggestions is an array: 33 | # (query term) 34 | # (hash of term info and term suggestion) 35 | # ... 36 | # (query term) 37 | # (hash of term info and term suggestion) 38 | # 'correctlySpelled' 39 | # true/false 40 | # collation 41 | # (suggestion for collation) 42 | if suggestions.index("correctlySpelled") #if extended results 43 | i_stop = suggestions.index("correctlySpelled") 44 | elsif suggestions.index("collation") 45 | i_stop = suggestions.index("collation") 46 | else 47 | i_stop = suggestions.length 48 | end 49 | # step through array in 2s to get info for each term 50 | 0.step(i_stop-1, 2) do |i| 51 | term = suggestions[i] 52 | term_info = suggestions[i+1] 53 | # term_info is a hash: 54 | # numFound => 55 | # startOffset => 56 | # endOffset => 57 | # origFreq => 58 | # suggestion => [{ frequency =>, word => }] # for extended results 59 | # suggestion => ['word'] # for non-extended results 60 | origFreq = term_info['origFreq'] 61 | if suggestions.index("correctlySpelled") 62 | word_suggestions << term_info['suggestion'].map do |suggestion| 63 | suggestion['word'] if suggestion['freq'] > origFreq 64 | end 65 | else 66 | # only extended suggestions have frequency so we just return all suggestions 67 | word_suggestions << term_info['suggestion'] 68 | end 69 | end 70 | end 71 | end 72 | word_suggestions.flatten.compact.uniq 73 | ) 74 | end 75 | 76 | def collation 77 | # FIXME: DRY up with words 78 | spellcheck = self.response[:spellcheck] 79 | if spellcheck && spellcheck[:suggestions] 80 | suggestions = spellcheck[:suggestions] 81 | unless suggestions.nil? 82 | if suggestions.index("collation") 83 | suggestions[suggestions.index("collation") + 1] 84 | end 85 | end 86 | end 87 | end 88 | 89 | end 90 | 91 | end 92 | 93 | -------------------------------------------------------------------------------- /ansr_dpla/spec/lib/relation/facet_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ansr::Dpla::Relation do 4 | before do 5 | @kittens = read_fixture('kittens.jsonld') 6 | @faceted = read_fixture('kittens_faceted.jsonld') 7 | @empty = read_fixture('empty.jsonld') 8 | @mock_api = double('api') 9 | Item.config{ |x| x[:api_key] = :foo} 10 | Item.engine.api= @mock_api 11 | end 12 | 13 | subject { Ansr::Dpla::Relation.new(Item, Item.table) } 14 | describe '#filter' do 15 | describe "do a single field, single value filter" do 16 | it "with facet" do 17 | test = subject.filter(:object=>'kittens').facet(:object) 18 | @mock_api.should_receive(:items).with(:object => 'kittens', :facets => :object).and_return('') 19 | test.load 20 | end 21 | it "without facet" do 22 | test = subject.filter(:object=>'kittens') 23 | @mock_api.should_receive(:items).with(:object => 'kittens').and_return('') 24 | test.load 25 | end 26 | end 27 | it "do a single field, multiple value filter" do 28 | test = subject.filter(:object=>['kittens', 'cats']).facet(:object) 29 | @mock_api.should_receive(:items).with(:object => ['kittens','cats'], :facets => :object).and_return('') 30 | test.load 31 | end 32 | it "do merge single field, multiple value filters" do 33 | test = subject.filter(:"provider.name"=>'kittens').filter(:"provider.name"=>'cats').facet(:"provider.name") 34 | @mock_api.should_receive(:items).with(hash_including(:"provider.name" => ['kittens','cats'], :facets => :"provider.name")).and_return('') 35 | test.load 36 | end 37 | it "do a multiple field, single value filter" do 38 | test = subject.filter(:object=>'kittens',:isShownAt=>'bears').facet([:object, :isShownAt]) 39 | @mock_api.should_receive(:items).with(hash_including(:object => 'kittens', :isShownAt=>'bears', :facets => [:object, :isShownAt])).and_return('') 40 | test.load 41 | end 42 | it "should keep scope distinct from spawned Relations" do 43 | test = subject.filter(:"provider.name"=>'kittens').facet(:"provider.name") 44 | test.where(:q=>'cats') 45 | @mock_api.should_receive(:items).with(:"provider.name" => 'kittens', :facets => :"provider.name").and_return('') 46 | test.load 47 | end 48 | it "should raise an error if the requested field is not a facetable field" do 49 | expect {subject.facet(:foo)}.to raise_error 50 | end 51 | it "should facet without a filter" do 52 | test = subject.facet(:object) 53 | @mock_api.should_receive(:items).with(:facets => :object).and_return('') 54 | test.load 55 | end 56 | end 57 | 58 | describe '#filters' do 59 | it 'should return Blacklight types' do 60 | # Blacklight::SolrResponse::Facets::FacetItem.new(:value => s, :hits => v) 61 | # options = {:sort => 'asc', :offset => 0} 62 | # Blacklight::SolrResponse::Facets::FacetField.new name, items, options 63 | test = subject.where(:q=>'kittens') 64 | @mock_api.should_receive(:items).with(:q => 'kittens').and_return(@faceted) 65 | test.load 66 | fkey = test.facets.keys.first 67 | facet = test.facets[fkey] 68 | expect(facet).to be_a(Ansr::Facets::FacetField) 69 | facet.items 70 | end 71 | it 'should dispatch a query with no docs requested if not loaded' do 72 | test = subject.where(:q=>'kittens') 73 | @mock_api.should_receive(:items).with(:q => 'kittens', :page_size=>0).once.and_return(@faceted) 74 | fkey = test.facets.keys.first 75 | facet = test.facets[fkey] 76 | expect(facet).to be_a(Ansr::Facets::FacetField) 77 | expect(test.loaded?).to be_false 78 | test.facets # make sure we memoized the facet values 79 | end 80 | end 81 | 82 | end -------------------------------------------------------------------------------- /lib/ansr/relation/predicate_builder.rb: -------------------------------------------------------------------------------- 1 | module Ansr 2 | class PredicateBuilder # :nodoc: 3 | def self.build_from_hash(klass, attributes, default_table) 4 | queries = [] 5 | 6 | attributes.each do |column, value| 7 | table = default_table 8 | 9 | if value.is_a?(Hash) 10 | if value.empty? 11 | queries << '1=0' 12 | else 13 | table = default_table.class.new(column, default_table.engine) 14 | association = klass.reflect_on_association(column.to_sym) 15 | 16 | value.each do |k, v| 17 | queries.concat expand(association && association.klass, table, k, v) 18 | end 19 | end 20 | else 21 | column = column.to_s 22 | # we remove the below since big table fields don't really have associations 23 | # if column.include?('.') 24 | # table_name, column = column.split('.', 2) 25 | # table = Ansr::Arel::BigTable.new(table_name, default_table.engine) 26 | # end 27 | 28 | queries.concat expand(klass, table, column, value) 29 | end 30 | end 31 | 32 | queries 33 | end 34 | 35 | def self.expand(klass, table, column, value) 36 | queries = [] 37 | 38 | # Find the foreign key when using queries such as: 39 | # Post.where(author: author) 40 | # 41 | # For polymorphic relationships, find the foreign key and type: 42 | # PriceEstimate.where(estimate_of: treasure) 43 | if klass && value.class < Base && reflection = klass.reflect_on_association(column.to_sym) 44 | if reflection.polymorphic? 45 | queries << build(table[reflection.foreign_type], value.class.base_class) 46 | end 47 | 48 | column = reflection.foreign_key 49 | end 50 | 51 | queries << build(table[column], value) 52 | queries 53 | end 54 | 55 | def self.references(attributes) 56 | attributes.map do |key, value| 57 | if value.is_a?(Hash) 58 | key 59 | else 60 | key = key.to_s 61 | key.split('.').first if key.include?('.') 62 | end 63 | end.compact 64 | end 65 | 66 | private 67 | def self.build(attribute, value) 68 | case value 69 | when Array 70 | values = value.to_a.map {|x| x.is_a?(Base) ? x.id : x} 71 | ranges, values = values.partition {|v| v.is_a?(Range)} 72 | 73 | values_predicate = if values.include?(nil) 74 | values = values.compact 75 | 76 | case values.length 77 | when 0 78 | attribute.eq(nil) 79 | when 1 80 | attribute.eq(values.first).or(attribute.eq(nil)) 81 | else 82 | attribute.in(values).or(attribute.eq(nil)) 83 | end 84 | else 85 | attribute.in(values) 86 | end 87 | 88 | array_predicates = ranges.map { |range| attribute.in(range) } 89 | array_predicates << values_predicate 90 | array_predicates.inject { |composite, predicate| composite.or(predicate) } 91 | when ActiveRecord::Relation 92 | value = value.select(value.klass.arel_table[value.klass.primary_key]) if value.select_values.empty? 93 | attribute.in(value.arel.ast) 94 | when Range 95 | attribute.in(value) 96 | when ActiveRecord::Base 97 | attribute.eq(value.id) 98 | when Class 99 | # FIXME: I think we need to deprecate this behavior 100 | attribute.eq(value.name) 101 | else 102 | attribute.eq(value) 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /ansr_dpla/lib/ansr_dpla/arel/big_table.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Dpla 2 | module Arel 3 | class BigTable < Ansr::Arel::BigTable 4 | 5 | FIELDS = [ 6 | # can we list the fields from the DPLA v2 api? 7 | # the sourceResource, originalRecord, and provider fields need to be associations, right? 8 | :"_id", 9 | :"dataProvider", 10 | :"sourceResource", 11 | :"object", 12 | :"ingestDate", 13 | :"originalRecord", 14 | :"ingestionSequence", 15 | :"isShownAt", 16 | :"hasView", 17 | :"provider", 18 | :"@context", 19 | :"ingestType", 20 | :"@id", 21 | :"id" 22 | ] 23 | 24 | FACETS = [ 25 | :"sourceResource.contributor", 26 | :"sourceResource.date.begin", 27 | :"sourceResource.date.end", 28 | :"sourceResource.language.name", 29 | :"sourceResource.language.iso639", 30 | :"sourceResource.format", 31 | :"sourceResource.stateLocatedIn.name", 32 | :"sourceResource.stateLocatedIn.iso3166-2", 33 | :"sourceResource.spatial.name", 34 | :"sourceResource.spatial.country", 35 | :"sourceResource.spatial.region", 36 | :"sourceResource.spatial.county", 37 | :"sourceResource.spatial.state", 38 | :"sourceResource.spatial.city", 39 | :"sourceResource.spatial.iso3166-2", 40 | :"sourceResource.spatial.coordinates", 41 | :"sourceResource.subject.@id", 42 | :"sourceResource.subject.name", 43 | :"sourceResource.temporal.begin", 44 | :"sourceResource.temporal.end", 45 | :"sourceResource.type", 46 | :"hasView.@id", 47 | :"hasView.format", 48 | :"isPartOf.@id", 49 | :"isPartOf.name", 50 | :"isShownAt", 51 | :"object", 52 | :"provider.@id", 53 | :"provider.name", 54 | ] 55 | 56 | SORTS = [ 57 | :"id", 58 | :"@id", 59 | :"sourceResource.id", 60 | :"sourceResource.contributor", 61 | :"sourceResource.date.begin", 62 | :"sourceResource.date.end", 63 | :"sourceResource.extent", 64 | :"sourceResource.language.name", 65 | :"sourceResource.language.iso639", 66 | :"sourceResource.format", 67 | :"sourceResource.stateLocatedIn.name", 68 | :"sourceResource.stateLocatedIn.iso3166-2", 69 | :"sourceResource.spatial.name", 70 | :"sourceResource.spatial.country", 71 | :"sourceResource.spatial.region", 72 | :"sourceResource.spatial.county", 73 | :"sourceResource.spatial.state", 74 | :"sourceResource.spatial.city", 75 | :"sourceResource.spatial.iso3166-2", 76 | :"sourceResource.spatial.coordinates", 77 | :"sourceResource.subject.@id", 78 | :"sourceResource.subject.type", 79 | :"sourceResource.subject.name", 80 | :"sourceResource.temporal.begin", 81 | :"sourceResource.temporal.end", 82 | :"sourceResource.title", 83 | :"sourceResource.type", 84 | :"hasView.@id", 85 | :"hasView.format", 86 | :"isPartOf.@id", 87 | :"isPartOf.name", 88 | :"isShownAt", 89 | :"object", 90 | :"provider.@id", 91 | :"provider.name", 92 | ] 93 | 94 | def initialize(klass, opts={}) 95 | super(klass.model()) 96 | self.name = klass.name.downcase.pluralize 97 | @fields += (opts[:fields] || FIELDS) 98 | @facets += (opts[:facets] || FACETS) 99 | @sorts += (opts[:sorts] || SORTS) 100 | self.config(opts[:config]) if opts[:config] 101 | end 102 | 103 | def name 104 | super.pluralize.downcase 105 | end 106 | end 107 | end 108 | end -------------------------------------------------------------------------------- /lib/ansr/relation.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | require 'kaminari' 3 | module Ansr 4 | class Relation < ::ActiveRecord::Relation 5 | attr_reader :response 6 | attr_accessor :filters, :count, :context, :resource 7 | ::ActiveRecord::Relation::VALID_UNSCOPING_VALUES << :facet << :spellcheck 8 | ::ActiveRecord::Relation::SINGLE_VALUE_METHODS << :spellcheck 9 | DEFAULT_PAGE_SIZE = 10 10 | 11 | include Sanitization::ClassMethods 12 | include QueryMethods 13 | include ::Kaminari::PageScopeMethods 14 | 15 | alias :start :offset_value 16 | 17 | def initialize(klass, table, values = {}) 18 | raise "Cannot search nil model" if klass.nil? 19 | super(klass, table, values) 20 | end 21 | 22 | def resource 23 | rsrc = @klass.name.downcase 24 | rsrc << ((rsrc =~ /s$/) ? 'es' : 's') 25 | rsrc.to_sym 26 | end 27 | 28 | def default_limit_value 29 | DEFAULT_PAGE_SIZE 30 | end 31 | 32 | def load 33 | exec_queries unless loaded? 34 | self 35 | end 36 | 37 | # Converts relation objects to Array. 38 | def to_a 39 | load 40 | @records 41 | end 42 | 43 | # Forces reloading of relation. 44 | def reload 45 | reset 46 | load 47 | end 48 | 49 | def empty? 50 | count == 0 51 | end 52 | 53 | def many? 54 | count > 1 55 | end 56 | 57 | def offset!(value) 58 | self.offset_value=value 59 | self 60 | end 61 | 62 | def count() 63 | self.load 64 | @response.count 65 | end 66 | 67 | def total 68 | count 69 | end 70 | alias :total_count :total 71 | 72 | def max_pages 73 | (total.to_f / limit_value).ceil 74 | end 75 | 76 | # override to parse filters from response 77 | def facets_from(response) 78 | {} and raise "this is a dead method!" 79 | end 80 | 81 | # override to parse docs from response 82 | def docs_from(response) 83 | [] 84 | end 85 | 86 | def facets 87 | if loaded? 88 | @facet_cache = facets_from(response) 89 | else 90 | @facet_cache ||= begin 91 | query = self.limit(0) 92 | query.load 93 | query.facets 94 | end 95 | end 96 | end 97 | 98 | def spawn 99 | s = self.class.new(@klass, @table, @values.dup) 100 | s.references!(references_values()) 101 | s 102 | end 103 | 104 | def grouped? 105 | false 106 | end 107 | 108 | def group_by(key=self.group_values.first) 109 | [] 110 | end 111 | 112 | def to_nosql 113 | spawn.to_nosql! 114 | end 115 | 116 | def to_nosql! 117 | ansr_query(arel, bind_values) 118 | end 119 | 120 | private 121 | 122 | # override to prevent default selection of all fields 123 | def build_select(arel, selects) 124 | unless selects.empty? 125 | @implicit_readonly = false 126 | arel.project(*selects) 127 | #else 128 | # arel.project(@klass.arel_table[Arel.star]) 129 | end 130 | end 131 | 132 | 133 | def exec_queries 134 | default_scoped = with_default_scope 135 | 136 | if default_scoped.equal?(self) 137 | @response = model.find_by_nosql(arel, bind_values) 138 | @records = docs_from(@response).collect do |d| 139 | model.new(d) 140 | end 141 | 142 | # this is ceremonial, it's always true 143 | readonly = readonly_value.nil? || readonly_value 144 | @records.each { |record| record.readonly! } if readonly 145 | else 146 | @records = default_scoped.to_a 147 | end 148 | 149 | self.limit_value = default_limit_value unless self.limit_value 150 | self.offset_value = 0 unless self.offset_value 151 | @filter_cache = nil # unload any cached filters 152 | @loaded = true 153 | @records 154 | end 155 | end 156 | 157 | end -------------------------------------------------------------------------------- /ansr_blacklight/spec/lib/request_builders_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ansr::Blacklight::RequestBuilders do 4 | class RequestBuildersTestClass 5 | attr_accessor :table 6 | include Ansr::Blacklight::RequestBuilders 7 | end 8 | subject { RequestBuildersTestClass.new } 9 | let(:relation) { double('Relation')} 10 | before(:each) do 11 | subject.table = {'facet_name' => Ansr::Arel::ConfiguredField.new(relation, 'facet_name',:date => nil, :query => nil, :tag => nil)} 12 | end 13 | describe "filter_value_to_fq_string" do 14 | 15 | it "should use the raw handler for strings" do 16 | expect(subject.send(:filter_value_to_fq_string, "facet_name", "my value")).to eq "{!raw f=facet_name}my value" 17 | end 18 | 19 | it "should pass booleans through" do 20 | expect(subject.send(:filter_value_to_fq_string, "facet_name", true)).to eq "facet_name:true" 21 | end 22 | 23 | it "should pass boolean-like strings through" do 24 | expect(subject.send(:filter_value_to_fq_string, "facet_name", "true")).to eq "facet_name:true" 25 | end 26 | 27 | it "should pass integers through" do 28 | expect(subject.send(:filter_value_to_fq_string, "facet_name", 1)).to eq "facet_name:1" 29 | end 30 | 31 | it "should pass integer-like strings through" do 32 | expect(subject.send(:filter_value_to_fq_string, "facet_name", "1")).to eq "facet_name:1" 33 | end 34 | 35 | it "should pass floats through" do 36 | expect(subject.send(:filter_value_to_fq_string, "facet_name", 1.11)).to eq "facet_name:1\\.11" 37 | end 38 | 39 | it "should pass floats through" do 40 | expect(subject.send(:filter_value_to_fq_string, "facet_name", "1.11")).to eq "facet_name:1\\.11" 41 | end 42 | 43 | it "should escape negative integers" do 44 | expect(subject.send(:filter_value_to_fq_string, "facet_name", -1)).to eq "facet_name:\\-1" 45 | end 46 | 47 | it "should pass date-type fields through" do 48 | subject.table = {'facet_name' => Ansr::Arel::ConfiguredField.new(relation, 'facet_name',:date => true, :query => nil, :tag => nil)} 49 | 50 | expect(subject.send(:filter_value_to_fq_string, "facet_name", "2012-01-01")).to eq "facet_name:2012\\-01\\-01" 51 | end 52 | 53 | it "should escape datetime-type fields" do 54 | subject.table['facet_name'] = Ansr::Arel::ConfiguredField.new(relation, 'facet_name',:date => true, :query => nil, :tag => nil) 55 | 56 | expect(subject.send(:filter_value_to_fq_string, "facet_name", "2003-04-09T00:00:00Z")).to eq "facet_name:2003\\-04\\-09T00\\:00\\:00Z" 57 | end 58 | 59 | it "should format Date objects correctly" do 60 | subject.table['facet_name'] = Ansr::Arel::ConfiguredField.new(relation, 'facet_name',:date => nil, :query => nil, :tag => nil) 61 | d = DateTime.parse("2003-04-09T00:00:00") 62 | expect(subject.send(:filter_value_to_fq_string, "facet_name", d)).to eq "facet_name:2003\\-04\\-09T00\\:00\\:00Z" 63 | end 64 | 65 | it "should handle range requests" do 66 | expect(subject.send(:filter_value_to_fq_string, "facet_name", 1..5)).to eq "facet_name:[1 TO 5]" 67 | end 68 | 69 | it "should add tag local parameters" do 70 | subject.table['facet_name'] = Ansr::Arel::ConfiguredField.new(relation, 'facet_name',:date => nil, :query => nil, :tag => 'asdf') 71 | 72 | expect(subject.send(:filter_value_to_fq_string, "facet_name", true)).to eq "{!tag=asdf}facet_name:true" 73 | expect(subject.send(:filter_value_to_fq_string, "facet_name", "my value")).to eq "{!raw f=facet_name tag=asdf}my value" 74 | end 75 | 76 | describe "#with_tag_ex" do 77 | it "should add an !ex local parameter if the facet configuration requests it" do 78 | expect(subject.with_ex_local_param("xyz", "some-value")).to eq "{!ex=xyz}some-value" 79 | end 80 | 81 | it "should not add an !ex local parameter if it isn't configured" do 82 | mock_field = double() 83 | expect(subject.with_ex_local_param(nil, "some-value")).to eq "some-value" 84 | end 85 | end 86 | 87 | end 88 | end -------------------------------------------------------------------------------- /lib/ansr/dummy_associations.rb: -------------------------------------------------------------------------------- 1 | module Ansr 2 | module DummyAssociations 3 | extend ActiveSupport::Concern 4 | module ClassMethods 5 | def create_reflection(macro, name, scope, options, active_record) 6 | case macro 7 | when :has_many, :belongs_to, :has_one, :has_and_belongs_to_many 8 | klass = options[:through] ? DummyThroughReflection : DummyAssociationReflection 9 | reflection = klass.new(macro, name, scope, options, active_record) 10 | when :composed_of 11 | reflection = DummyAggregateReflection.new(macro, name, scope, options, active_record) 12 | end 13 | 14 | self.reflections = self.reflections.merge(name => reflection) 15 | reflection 16 | end 17 | 18 | # Returns an array of AggregateReflection objects for all the aggregations in the class. 19 | def reflect_on_all_aggregations 20 | reflections.values.grep(DummyAggregateReflection) 21 | end 22 | 23 | # Returns the AggregateReflection object for the named +aggregation+ (use the symbol). 24 | # 25 | # Account.reflect_on_aggregation(:balance) # => the balance AggregateReflection 26 | # 27 | def reflect_on_aggregation(aggregation) 28 | reflection = reflections[aggregation] 29 | reflection if reflection.is_a?(DummyAggregateReflection) 30 | end 31 | 32 | # Returns an array of DummyAssociationReflection objects for all the 33 | # associations in the class. If you only want to reflect on a certain 34 | # association type, pass in the symbol (:has_many, :has_one, 35 | # :belongs_to) as the first parameter. 36 | # 37 | # Example: 38 | # 39 | # Account.reflect_on_all_associations # returns an array of all associations 40 | # Account.reflect_on_all_associations(:has_many) # returns an array of all has_many associations 41 | # 42 | def reflect_on_all_associations(macro = nil) 43 | association_reflections = reflections.values.grep(DummyAssociationReflection) 44 | macro ? association_reflections.select { |reflection| reflection.macro == macro } : association_reflections 45 | end 46 | 47 | # Returns the DummyAssociationReflection object for the +association+ (use the symbol). 48 | # 49 | # Account.reflect_on_association(:owner) # returns the owner AssociationReflection 50 | # Invoice.reflect_on_association(:line_items).macro # returns :has_many 51 | # 52 | def reflect_on_association(association) 53 | reflection = reflections[association] 54 | reflection if reflection.is_a?(DummyAssociationReflection) 55 | end 56 | 57 | # Returns an array of AssociationReflection objects for all associations which have :autosave enabled. 58 | def reflect_on_all_autosave_associations 59 | reflections.values.select { |reflection| reflection.options[:autosave] } 60 | end 61 | end 62 | class DummyReflection < ActiveRecord::Reflection::AggregateReflection 63 | def initialize(macro, name, scope, options, active_record) 64 | super(macro, name, scope, options, active_record) 65 | @symbol = name 66 | end 67 | 68 | def polymorphic? 69 | false 70 | end 71 | 72 | def foreign_key 73 | @symbol # ?? 74 | end 75 | def collection? 76 | [:has_one, :has_many, :has_and_belongs_to_many].include? macro 77 | end 78 | def validate? 79 | false 80 | end 81 | def association_class 82 | DummyAssociation 83 | end 84 | 85 | def check_validity! 86 | true 87 | end 88 | end 89 | class DummyAssociationReflection < DummyReflection; end 90 | class DummyAggregateReflection < DummyReflection; end 91 | class DummyThroughReflection < DummyReflection; end 92 | class DummyAssociation < ActiveRecord::Associations::Association 93 | def writer(*args); end 94 | def reader(*args) 95 | self 96 | end 97 | def loaded? 98 | true 99 | end 100 | def identifier 101 | nil 102 | end 103 | end 104 | end 105 | end -------------------------------------------------------------------------------- /ansr_blacklight/spec/lib/queryable_relation_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ansr::Blacklight::Relation do 4 | 5 | class ConfiguredTable < Ansr::Arel::BigTable 6 | 7 | end 8 | 9 | class OtherTable < ConfiguredTable 10 | def name 11 | 'outside' 12 | end 13 | 14 | end 15 | 16 | context "a bunch of query stuff" do 17 | 18 | before(:each) do 19 | Object.const_set('QueryTestModel', Class.new(TestModel)) 20 | QueryTestModel.configure do |config| 21 | config[:table_class] = ConfiguredTable 22 | end 23 | QueryTestModel.solr = stub_solr 24 | other_table.configure_fields do |config| 25 | hash = (config[:configured] ||= {}) 26 | hash[:local] = {:property => 'test', :escape => 'tes"t'} 27 | end 28 | @visitor = Ansr::Blacklight::Arel::Visitors::ToNoSql.new(QueryTestModel.table) 29 | 30 | ## COMMON AREL CONCEPTS ## 31 | # from() indicates the big table name for the relation; in BL/Solr this maps to the request path 32 | @relation = QueryTestModel.from(ConfiguredTable.new(QueryTestModel)) 33 | # as() indicates an alias for the big table; in BL/Solr this maps to the :qt param 34 | @relation.as!('hey') 35 | # constraints map directly 36 | @relation.where!(:configured=> "what's") 37 | 38 | # as do offsets and limits 39 | @relation.offset!(21) 40 | @relation.limit!(12) 41 | @relation.group!("I") 42 | ## COMMON NO-SQL CONCEPTS ## 43 | # facets are a kind of projection with attributes (attribute support is optional) 44 | @relation.facet!("title_facet", limit: "vest") 45 | # filters are a type of constraint 46 | @relation.filter!({"name_facet" => "Fedo"}) 47 | @relation.facet!("name_facet", limit: 10) 48 | @relation.facet!(limit: 20) 49 | @relation.highlight!("I", 'fl' => "wish") 50 | @relation.spellcheck!("a", q: "fleece") 51 | ## SOLR ECCENTRICITIES ## 52 | # these are present for compatibility, but not expected to be used generically 53 | @relation.wt!("going") 54 | @relation.defType!("had") 55 | end 56 | 57 | after(:each) do 58 | @relation = nil 59 | Object.send(:remove_const, :QueryTestModel) 60 | end 61 | 62 | let(:table) { QueryTestModel.table } 63 | let(:other_table) { OtherTable.new(QueryTestModel) } 64 | describe "#from" do 65 | 66 | let(:visitor) { @visitor } 67 | subject {@relation.from(other_table)} 68 | 69 | it "should set the path to the table name" do 70 | query = visitor.accept subject.build_arel.ast 71 | expect(query.path).to eql('outside') 72 | end 73 | 74 | it "should change the table" do 75 | expect(subject.from_value.first).to be_a ConfiguredTable 76 | end 77 | end 78 | 79 | describe "#as" do 80 | 81 | subject {@relation.as('hey')} 82 | let(:visitor) { @visitor } 83 | 84 | it "should set the :qt parameter" do 85 | query = visitor.accept subject.build_arel.ast 86 | expect(query.to_hash[:qt]).to eql 'hey' 87 | end 88 | end 89 | 90 | describe "#facet" do 91 | 92 | subject { @relation.facet(limit: 20)} 93 | let(:visitor) { @visitor } 94 | 95 | it "should set default facet parms when no field expr is given" do 96 | rel = subject.facet(limit: 20) 97 | query = visitor.accept rel.build_arel.ast 98 | end 99 | 100 | it "should set pivot facet field params" do 101 | end 102 | end 103 | 104 | context "a mix of queryable relations" do 105 | subject { @relation.from(other_table) } 106 | let(:visitor) { @visitor } 107 | 108 | it "should accept valid parameters" do 109 | query = visitor.accept subject.build_arel.ast 110 | expect(query.path).to eq('outside') 111 | expect(query.to_hash).to eq({"defType" => "had", 112 | "f.name_facet.facet.limit" => "10", 113 | "f.title_facet.facet.limit" => "vest", 114 | "facet" => true, 115 | "facet.field" => [:title_facet,:name_facet], 116 | "facet.limit" => "20", 117 | "fq" => ["{!raw f=name_facet}Fedo"], 118 | "group" => "I", 119 | "hl" => "I", 120 | "hl.fl" => "wish", 121 | "q" => "{!property=test escape='tes\\\"t'}what's", 122 | "qt" => "hey", 123 | "rows" => "12", 124 | "spellcheck" => "a", 125 | "spellcheck.q" => "fleece", 126 | "start" => "21", 127 | "wt" => "going" 128 | }) 129 | end 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /ansr_dpla/spec/lib/api_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ansr::Dpla::Api do 4 | 5 | describe '#config' do 6 | before do 7 | @test = Ansr::Dpla::Api.new 8 | end 9 | 10 | it "should be configurable with a Hash" do 11 | config_fixture = {:api_key => :foo, :url => :bar} 12 | @test.config{|x| x.merge!(config_fixture)} 13 | expect(@test.url).to eql(:bar) 14 | end 15 | 16 | it "should be configurable with a path to a yaml" do 17 | open(fixture_path('dpla.yml')) do |blob| 18 | config = YAML.load(blob) 19 | @test.config{|x| x.merge!(config)} 20 | end 21 | expect(@test.url).to eql('http://fake.dp.la/v0/') 22 | end 23 | 24 | it "should raise an error of the config doesn't have required fields" do 25 | expect { Ansr::Dpla::Api.new.config{|x| x.merge!(:url => :foo)}}.to raise_error 26 | Ansr::Dpla::Api.new.config {|x| x.merge!({:url => :foo, :api_key => :foo})} 27 | end 28 | 29 | end 30 | 31 | describe '#path_for' do 32 | before do 33 | @test = Ansr::Dpla::Api.new 34 | @test.config{|x| x.merge!(:api_key => :testing)} 35 | end 36 | 37 | 38 | describe 'queries' do 39 | it "should build paths for basic queries" do 40 | fixture = {:foo => ['kittens', 'cats'], :bar => ['puppies', 'dogs']} 41 | expected = 'tests?api_key=testing&foo=kittens+AND+cats&bar=puppies+AND+dogs' 42 | expect(@test.path_for('tests', fixture)).to eql(expected) 43 | expect(@test.items_path(fixture)).to eql(expected.sub(/^tests/,'items')) 44 | expect(@test.collections_path(fixture)).to eql(expected.sub(/^tests/,'collections')) 45 | expect(@test.item_path('foo')).to eql('items/foo?api_key=testing') 46 | expect(@test.collection_path('foo')).to eql('collections/foo?api_key=testing') 47 | end 48 | 49 | it "should build paths for OR and NOT queries" do 50 | fixture = {:foo => ['kittens', 'NOT cats']} 51 | expected = 'tests?api_key=testing&foo=kittens+AND+NOT+cats' 52 | expect(@test.path_for('tests', fixture)).to eql(expected) 53 | 54 | fixture = {:foo => ['NOT kittens', 'cats']} 55 | expected = 'tests?api_key=testing&foo=NOT+kittens+AND+cats' 56 | expect(@test.path_for('tests', fixture)).to eql(expected) 57 | 58 | fixture = {:foo => ['kittens', 'OR cats']} 59 | expected = 'tests?api_key=testing&foo=kittens+OR+cats' 60 | expect(@test.path_for('tests', fixture)).to eql(expected) 61 | 62 | fixture = {:foo => ['OR kittens', 'cats']} 63 | expected = 'tests?api_key=testing&foo=kittens+AND+cats' 64 | expect(@test.path_for('tests', fixture)).to eql(expected) 65 | end 66 | end 67 | 68 | it "should build paths for facets right" do 69 | fixture = {:foo => 'kittens', :facets => :bar} 70 | expected = 'tests?api_key=testing&foo=kittens&facets=bar' 71 | expect(@test.path_for('tests', fixture)).to eql(expected) 72 | 73 | fixture = {:foo => 'kittens', :facets => [:bar, :baz]} 74 | expected = 'tests?api_key=testing&foo=kittens&facets=bar%2Cbaz' 75 | expect(@test.path_for('tests', fixture)).to eql(expected) 76 | 77 | fixture = {:foo => 'kittens', :facets => 'bar,baz'} 78 | expected = 'tests?api_key=testing&foo=kittens&facets=bar%2Cbaz' 79 | expect(@test.path_for('tests', fixture)).to eql(expected) 80 | end 81 | 82 | it "should build paths for sorts right" do 83 | fixture = {:foo => 'kittens', :sort_by => [:bar, :baz]} 84 | expected = 'tests?api_key=testing&foo=kittens&sort_by=bar%2Cbaz' 85 | expect(@test.path_for('tests', fixture)).to eql(expected) 86 | 87 | fixture = {:foo => 'kittens', :sort_by => 'bar,baz'} 88 | expected = 'tests?api_key=testing&foo=kittens&sort_by=bar%2Cbaz' 89 | expect(@test.path_for('tests', fixture)).to eql(expected) 90 | 91 | fixture = {:foo => 'kittens', :sort_by => 'bar,baz', :sort_order => :desc} 92 | expected = 'tests?api_key=testing&foo=kittens&sort_by=bar%2Cbaz&sort_order=desc' 93 | expect(@test.path_for('tests', fixture)).to eql(expected) 94 | end 95 | 96 | it "should build paths for field selections right" do 97 | fixture = {:foo => 'kittens', :fields => [:bar, :baz]} 98 | expected = 'tests?api_key=testing&foo=kittens&fields=bar%2Cbaz' 99 | expect(@test.path_for('tests', fixture)).to eql(expected) 100 | 101 | fixture = {:foo => 'kittens', :fields => 'bar,baz'} 102 | expected = 'tests?api_key=testing&foo=kittens&fields=bar%2Cbaz' 103 | expect(@test.path_for('tests', fixture)).to eql(expected) 104 | end 105 | 106 | it "should build paths for limits and page sizes right" do 107 | fixture = {:foo => 'kittens', :page_size=>25, :page=>4} 108 | expected = 'tests?api_key=testing&foo=kittens&page_size=25&page=4' 109 | expect(@test.path_for('tests', fixture)).to eql(expected) 110 | end 111 | 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /ansr_blacklight/spec/lib/relation/grouping_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require 'spec_helper' 3 | 4 | # check the methods that do solr requests. Note that we are not testing if 5 | # solr gives "correct" responses, as that's out of scope (it's a part of 6 | # testing the solr code itself). We *are* testing if blacklight code sends 7 | # queries to solr such that it gets appropriate results. When a user does a search, 8 | # do we get data back from solr (i.e. did we properly configure blacklight code 9 | # to talk with solr and get results)? when we do a document request, does 10 | # blacklight code get a single document returned?) 11 | # 12 | describe Ansr::Blacklight do 13 | 14 | class TestTable < Ansr::Arel::BigTable 15 | 16 | def [](val) 17 | key = (Arel::Attributes::Attribute === val) ? val.name.to_sym : val.to_sym 18 | key == :configured ? Ansr::Arel::ConfiguredField.new(key, {:property => 'test', :escape => 'tes"t'}) : super(val) 19 | end 20 | 21 | def fields 22 | [:id] 23 | end 24 | 25 | end 26 | 27 | def stub_solr(mock_query_response) 28 | @solr ||= double('Solr') 29 | @solr.stub(:send_and_receive).and_return(eval(mock_query_response)) 30 | @solr 31 | end 32 | 33 | before do 34 | Object.const_set('GroupModel', Class.new(TestModel)) 35 | GroupModel.solr = stub_solr(sample_response.to_s) 36 | GroupModel.configure do |config| 37 | config[:table_class] = TestTable 38 | end 39 | end 40 | 41 | after do 42 | Object.send(:remove_const, :GroupModel) 43 | end 44 | 45 | let(:response) do 46 | rel = GroupModel.group("result_group_ssi") 47 | rel.load 48 | rel 49 | end 50 | 51 | let(:group_response) do 52 | response.group_by 53 | end 54 | 55 | let(:groups) do 56 | group_response.groups #.first 57 | end 58 | 59 | subject do 60 | groups.first 61 | end 62 | 63 | describe Ansr::Blacklight::Solr::Response::Group do 64 | describe "#doclist" do 65 | it "should be the raw list of documents from solr" do 66 | expect(subject.doclist).to be_a Hash 67 | expect(subject.doclist['docs'].first[:id]).to eq 1 68 | end 69 | end 70 | 71 | describe "#total" do 72 | it "should be the number of results found in a group" do 73 | expect(subject.total).to eq 2 74 | end 75 | end 76 | 77 | describe "#start" do 78 | it "should be the offset for the results in the group" do 79 | expect(subject.start).to eq 0 80 | end 81 | end 82 | 83 | describe "#docs" do 84 | it "should be a list of GroupModels" do 85 | subject.docs.each do |doc| 86 | expect(doc).to be_a_kind_of GroupModel 87 | end 88 | 89 | expect(subject.docs.first.id).to eq 1 90 | end 91 | end 92 | 93 | describe "#field" do 94 | it "should be the field the group belongs to" do 95 | expect(subject.field).to eq "result_group_ssi" 96 | end 97 | end 98 | end 99 | 100 | describe Ansr::Blacklight::Solr::Response do 101 | let(:response) do 102 | create_response(sample_response) 103 | end 104 | 105 | let(:group) do 106 | response.grouped(GroupModel).select { |x| x.key == "result_group_ssi" }.first 107 | end 108 | 109 | describe "groups" do 110 | it "should return an array of Groups" do 111 | response.grouped(GroupModel).should be_a Array 112 | 113 | expect(group.groups).to have(2).items 114 | group.groups.each do |group| 115 | expect(group).to be_a Ansr::Blacklight::Solr::Response::Group 116 | end 117 | end 118 | it "should include a list of SolrDocuments" do 119 | 120 | group.groups.each do |group| 121 | group.docs.each do |doc| 122 | expect(doc).to be_a GroupModel 123 | end 124 | end 125 | end 126 | end 127 | 128 | describe "total" do 129 | it "should return the ngroups value" do 130 | expect(group.total).to eq 3 131 | end 132 | end 133 | 134 | describe "facets" do 135 | it "should exist in the response object (not testing, we just extend the module)" do 136 | expect(group).to respond_to :facets 137 | end 138 | end 139 | 140 | describe "rows" do 141 | it "should get the rows from the response" do 142 | expect(group.rows).to eq 3 143 | end 144 | end 145 | 146 | describe "group_field" do 147 | it "should be the field name for the current group" do 148 | expect(group.group_field).to eq "result_group_ssi" 149 | end 150 | end 151 | 152 | describe "group_limit" do 153 | it "should be the number of documents to return for a group" do 154 | expect(group.group_limit).to eq 5 155 | end 156 | end 157 | end 158 | 159 | def sample_response 160 | {"responseHeader" => {"params" =>{"rows" => 3, "group.limit" => 5}}, 161 | "grouped" => 162 | {'result_group_ssi' => 163 | {'groups' => [{'groupValue'=>"Group 1", 'doclist'=>{'numFound'=>2, 'start' => 0, 'docs'=>[{:id=>1}, {:id => 'x'}]}}, 164 | {'groupValue'=>"Group 2", 'doclist'=>{'numFound'=>3, 'docs'=>[{:id=>2}, :id=>3]}} 165 | ], 166 | 'ngroups' => "3" 167 | } 168 | } 169 | } 170 | end 171 | end -------------------------------------------------------------------------------- /ansr_dpla/fixtures/item.json: -------------------------------------------------------------------------------- 1 | {"docs":[{"_id":"bpl--oai:digitalcommonwealth.org:commonwealth:6682xj30d","dataProvider":"Boston Public Library","sourceResource":{"isPartOf":["Leslie Jones Collection","Leslie Jones Collection. Animals: Dogs & Cats"],"description":["Title from information provided by Leslie Jones or the Boston Public Library on the negative or negative sleeve.","Date supplied by cataloger."],"creator":"Jones, Leslie, 1886-1967","rights":"Copyright (c) Leslie Jones. This work is licensed for use under a Creative Commons Attribution Non-Commercial No Derivatives License (CC BY-NC-ND).","collection":[{"@id":"http://dp.la/api/collections/460c76299e1b0a46afea352b1ab8f556","id":"460c76299e1b0a46afea352b1ab8f556","title":"Leslie Jones Collection"}],"date":{"begin":"1917","end":"1934","displayDate":"1917-1934"},"extent":"1 negative : glass, black & white ; 4 x 5 in.","stateLocatedIn":[{"name":"Massachusetts"}],"title":"Kittens","identifier":["Local accession: 08_06_000884"],"type":"image","subject":[{"name":"Cats"},{"name":"Baby animals"}]},"object":"http://ark.digitalcommonwealth.org/ark:/50959/6682xj30d/thumbnail","ingestDate":"2014-02-15T15:10:38.797024","originalRecord":{"header":{"datestamp":"2013-09-20T14:14:36Z","identifier":"oai:digitalcommonwealth.org:commonwealth:6682xj30d","setSpec":"commonwealth:2j62s484w"},"metadata":{"mods:mods":{"mods:subject":[{"mods:topic":"Cats","authority":"lctgm"},{"mods:topic":"Baby animals","authority":"lctgm"}],"mods:genre":[{"displayLabel":"general","authorityURI":"http://id.loc.gov/vocabulary/graphicMaterials","#text":"Photographs","authority":"gmgpc","valueURI":"http://id.loc.gov/vocabulary/graphicMaterials/tgm007721"},{"displayLabel":"specific","authorityURI":"http://id.loc.gov/vocabulary/graphicMaterials","#text":"Glass negatives","authority":"gmgpc","valueURI":"http://id.loc.gov/vocabulary/graphicMaterials/tgm004561"}],"xmlns:mods":"http://www.loc.gov/mods/v3","mods:typeOfResource":"still image","mods:recordInfo":{"mods:languageOfCataloging":{"mods:languageTerm":{"authorityURI":"http://id.loc.gov/vocabulary/iso639-2","#text":"English","type":"text","authority":"iso639-2b","valueURI":"http://id.loc.gov/vocabulary/iso639-2/eng"}},"mods:descriptionStandard":{"#text":"gihc","authority":"marcdescription"},"mods:recordOrigin":"human prepared","mods:recordContentSource":"Boston Public Library"},"mods:titleInfo":{"usage":"primary","mods:title":"Kittens"},"mods:physicalDescription":{"mods:extent":"1 negative : glass, black & white ; 4 x 5 in.","mods:digitalOrigin":"reformatted digital","mods:internetMediaType":["image/tiff","image/jpeg"]},"mods:identifier":[{"#text":"08_06_000884","type":"local-accession"},{"#text":"http://ark.digitalcommonwealth.org/ark:/50959/6682xj30d","type":"uri"}],"version":"3.4","mods:note":["Title from information provided by Leslie Jones or the Boston Public Library on the negative or negative sleeve.","Date supplied by cataloger."],"mods:name":{"mods:namePart":["Jones, Leslie",{"#text":"1886-1967","type":"date"}],"mods:role":{"mods:roleTerm":{"authorityURI":"http://id.loc.gov/vocabulary/relators","#text":"Photographer","type":"text","authority":"marcrelator","valueURI":"http://id.loc.gov/vocabulary/relators/pht"}},"type":"personal","authority":"local"},"mods:location":[{"mods:physicalLocation":"Boston Public Library","mods:holdingSimple":{"mods:copyInformation":{"mods:subLocation":"Print Department"}}},{"mods:url":[{"access":"preview","#text":"http://ark.digitalcommonwealth.org/ark:/50959/6682xj30d/thumbnail"},{"usage":"primary","access":"object in context","#text":"http://ark.digitalcommonwealth.org/ark:/50959/6682xj30d"}]}],"mods:accessCondition":[{"#text":"Copyright (c) Leslie Jones.","type":"use and reproduction"},{"#text":"This work is licensed for use under a Creative Commons Attribution Non-Commercial No Derivatives License (CC BY-NC-ND).","type":"use and reproduction"}],"xmlns:xsi":"http://www.w3.org/2001/XMLSchema-instance","xmlns:xlink":"http://www.w3.org/1999/xlink","xsi:schemaLocation":"http://www.loc.gov/mods/v3 http://www.loc.gov/standards/mods/v3/mods-3-4.xsd","mods:originInfo":{"mods:dateCreated":[{"encoding":"w3cdtf","#text":"1917","qualifier":"approximate","keyDate":"yes","point":"start"},{"encoding":"w3cdtf","#text":"1934","qualifier":"approximate","point":"end"}]},"mods:relatedItem":[{"type":"host","mods:titleInfo":{"mods:title":"Leslie Jones Collection"}},{"type":"series","mods:titleInfo":{"mods:title":"Animals: Dogs & Cats"}}]}},"id":"oai:digitalcommonwealth.org:commonwealth:6682xj30d","collection":[{"@id":"http://dp.la/api/collections/460c76299e1b0a46afea352b1ab8f556","id":"460c76299e1b0a46afea352b1ab8f556","title":"Leslie Jones Collection"}],"provider":{"@id":"http://dp.la/api/contributor/digital-commonwealth","name":"Digital Commonwealth"}},"ingestionSequence":5,"isShownAt":"http://ark.digitalcommonwealth.org/ark:/50959/6682xj30d","provider":{"@id":"http://dp.la/api/contributor/digital-commonwealth","name":"Digital Commonwealth"},"@context":{"begin":{"@id":"dpla:dateRangeStart","@type":"xsd:date"},"@vocab":"http://purl.org/dc/terms/","hasView":"edm:hasView","name":"xsd:string","object":"edm:object","dpla":"http://dp.la/terms/","collection":"dpla:aggregation","edm":"http://www.europeana.eu/schemas/edm/","end":{"@id":"dpla:end","@type":"xsd:date"},"state":"dpla:state","aggregatedDigitalResource":"dpla:aggregatedDigitalResource","coordinates":"dpla:coordinates","isShownAt":"edm:isShownAt","stateLocatedIn":"dpla:stateLocatedIn","sourceResource":"edm:sourceResource","dataProvider":"edm:dataProvider","originalRecord":"dpla:originalRecord","provider":"edm:provider","LCSH":"http://id.loc.gov/authorities/subjects"},"ingestType":"item","@id":"http://dp.la/api/items/9f695d8c16a4978061a25076a43aa11f","id":"9f695d8c16a4978061a25076a43aa11f"}],"count":1} -------------------------------------------------------------------------------- /ansr_blacklight/lib/ansr_blacklight/request_builders.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Blacklight 2 | ## 3 | # This module contains methods that transform user parameters into parameters that are sent 4 | # as a request to Solr when RequestBuilders#solr_search_params is called. 5 | # 6 | module RequestBuilders 7 | extend ActiveSupport::Concern 8 | 9 | def local_field_params(facet_field) 10 | cf = table[facet_field] 11 | if (cf.is_a? Ansr::Arel::ConfiguredField) 12 | return cf.config.fetch(:local, {}) 13 | else 14 | return {} 15 | end 16 | end 17 | # A helper method used for generating solr LocalParams, put quotes 18 | # around the term unless it's a bare-word. Escape internal quotes 19 | # if needed. 20 | def solr_param_quote(val, options = {}) 21 | options[:quote] ||= '"' 22 | unless val =~ /^[a-zA-Z0-9$_\-\^]+$/ 23 | val = options[:quote] + 24 | # Yes, we need crazy escaping here, to deal with regexp esc too! 25 | val.gsub("'", "\\\\\'").gsub('"', "\\\\\"") + 26 | options[:quote] 27 | end 28 | return val 29 | end 30 | 31 | ## 32 | # Take the user-entered query, and put it in the solr params, 33 | # including config's "search field" params for current search field. 34 | # also include setting spellcheck.q. 35 | def add_query_to_solr(field_key, value, opts={}) 36 | ### 37 | # Merge in search field configured values, if present, over-writing general 38 | # defaults 39 | ### 40 | 41 | if (::Arel::Nodes::As === field_key) 42 | solr_request[:qt] = field_key.right.to_s 43 | field_key = field_key.left 44 | end 45 | 46 | search_field = table[field_key] 47 | ## 48 | # Create Solr 'q' including the user-entered q, prefixed by any 49 | # solr LocalParams in config, using solr LocalParams syntax. 50 | # http://wiki.apache.org/solr/LocalParams 51 | ## 52 | if (Ansr::Arel::ConfiguredField === search_field && !search_field.config.empty?) 53 | local_params = search_field.config.fetch(:local,{}).merge(opts).collect do |key, val| 54 | key.to_s + "=" + solr_param_quote(val, :quote => "'") 55 | end.join(" ") 56 | solr_request[:q] = local_params.empty? ? value : "{!#{local_params}}#{value}" 57 | search_field.config.fetch(:query,{}).each do |k,v| 58 | solr_request[k] = v 59 | end 60 | else 61 | solr_request[:q] = value if value 62 | end 63 | 64 | ## 65 | # Set Solr spellcheck.q to be original user-entered query, without 66 | # our local params, otherwise it'll try and spellcheck the local 67 | # params! Unless spellcheck.q has already been set by someone, 68 | # respect that. 69 | # 70 | # TODO: Change calling code to expect this as a symbol instead of 71 | # a string, for consistency? :'spellcheck.q' is a symbol. Right now 72 | # rspec tests for a string, and can't tell if other code may 73 | # insist on a string. 74 | solr_request["spellcheck.q"] = value unless solr_request["spellcheck.q"] 75 | end 76 | 77 | ## 78 | # Add any existing facet limits, stored in app-level HTTP query 79 | # as :f, to solr as appropriate :fq query. 80 | def add_filter_fq_to_solr(solr_request, user_params) 81 | 82 | # convert a String value into an Array 83 | if solr_request[:fq].is_a? String 84 | solr_request[:fq] = [solr_request[:fq]] 85 | end 86 | 87 | # :fq, map from :f. 88 | if ( user_params[:f]) 89 | f_request_params = user_params[:f] 90 | 91 | f_request_params.each_pair do |facet_field, value_list| 92 | opts = local_field_params(facet_field).merge(user_params.fetch(:opts,{})) 93 | Array(value_list).each do |value| 94 | solr_request.append_filter_query filter_value_to_fq_string(facet_field, value, user_params[:opts]) 95 | end 96 | end 97 | end 98 | end 99 | 100 | def with_ex_local_param(ex, value) 101 | if ex 102 | "{!ex=#{ex}}#{value}" 103 | else 104 | value 105 | end 106 | end 107 | 108 | private 109 | 110 | ## 111 | # Convert a filter/value pair into a solr fq parameter 112 | def filter_value_to_fq_string(facet_key, value, facet_opts=nil) 113 | facet_field = table[facet_key] 114 | facet_config = (Ansr::Arel::ConfiguredField === facet_field) ? facet_field : nil 115 | facet_default = (::Arel.star == facet_key) 116 | local_params = local_field_params(facet_key) 117 | local_params.merge!(facet_opts) if facet_opts 118 | local_params = local_params.collect {|k,v| "#{k.to_s}=#{v.to_s}"} 119 | local_params << "tag=#{facet_config.tag}" if facet_config and facet_config.tag 120 | 121 | prefix = "" 122 | prefix = "{!#{local_params.join(" ")}}" unless local_params.empty? 123 | 124 | fq = case 125 | when (facet_config and facet_config.query) 126 | facet_config.query[value][:fq] if facet_config.query[value] 127 | when (facet_config and facet_config.date) 128 | # in solr 3.2+, this could be replaced by a !term query 129 | "#{prefix}#{facet_field.name}:#{RSolr.escape(value)}" 130 | when (value.is_a?(DateTime) or value.is_a?(Date) or value.is_a?(Time)) 131 | "#{prefix}#{facet_field.name}:#{RSolr.escape(value.to_time.utc.strftime("%Y-%m-%dT%H:%M:%SZ"))}" 132 | when (value.is_a?(TrueClass) or value.is_a?(FalseClass) or value == 'true' or value == 'false'), 133 | (value.is_a?(Integer) or (value.to_i.to_s == value if value.respond_to? :to_i)), 134 | (value.is_a?(Float) or (value.to_f.to_s == value if value.respond_to? :to_f)) 135 | "#{prefix}#{facet_field.name}:#{RSolr.escape(value.to_s)}" 136 | when value.is_a?(Range) 137 | "#{prefix}#{facet_field.name}:[#{RSolr.escape(value.first.to_s)} TO #{RSolr.escape(value.last.to_s)}]" 138 | else 139 | "{!raw f=#{facet_field.name}#{(" " + local_params.join(" ")) unless local_params.empty?}}#{value}" 140 | end 141 | 142 | 143 | end 144 | end 145 | end -------------------------------------------------------------------------------- /ansr_dpla/lib/ansr_dpla/arel/visitors/query_builder.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Dpla::Arel::Visitors 2 | class QueryBuilder < Ansr::Arel::Visitors::QueryBuilder 3 | attr_reader :query_opts 4 | 5 | def initialize(table, query_opts=nil) 6 | super(table) 7 | @query_opts = query_opts ||= Ansr::Dpla::Request.new 8 | @query_opts.path = table.name 9 | end 10 | 11 | # determines whether multiple values should accumulate or overwrite in merges 12 | def multiple?(field_key) 13 | true 14 | end 15 | 16 | def visit_String o, a 17 | case a 18 | when Ansr::Arel::Visitors::From 19 | query_opts.path = o 20 | when Ansr::Arel::Visitors::Facet 21 | filter_field(o.to_sym) 22 | when Ansr::Arel::Visitors::Order 23 | order(o) 24 | else 25 | raise "visited String \"#{o}\" with #{a.to_s}" 26 | end 27 | end 28 | 29 | def visit_Arel_SqlLiteral(n, attribute) 30 | select_val = n.to_s.split(" AS ") 31 | case attribute 32 | when Ansr::Arel::Visitors::Order 33 | order(n.to_s) 34 | when Ansr::Arel::Visitors::Facet 35 | filter_field(select_val[0].to_sym) 36 | else 37 | field(select_val[0].to_sym) 38 | if select_val[1] 39 | query_opts.aliases ||= {} 40 | query_opts.aliases[select_val[0]] = select_val[1] 41 | end 42 | end 43 | end 44 | 45 | def visit_Ansr_Arel_Nodes_Filter(object, attribute) 46 | expr = object.expr 47 | case expr 48 | when ::Arel::SqlLiteral 49 | visit expr, Ansr::Arel::Visitors::Filter.new(attribute) if object.select 50 | when ::Arel::Attributes::Attribute 51 | name = object.expr.name 52 | name = "#{expr.relation.name}.#{name}" if expr.relation.name.to_s != table.name.to_s 53 | visit name, Ansr::Arel::Visitors::Filter.new(attribute) if object.select 54 | else 55 | raise "Unexpected filter expression type #{object.expr.class}" 56 | end 57 | end 58 | 59 | def visit_Ansr_Arel_Nodes_Facet(object, attribute) 60 | expr = object.expr 61 | case expr 62 | when ::Arel::SqlLiteral 63 | visit expr, Ansr::Arel::Visitors::Facet.new(attribute) 64 | when ::Arel::Attributes::Attribute 65 | name = object.expr.name 66 | name = "#{expr.relation.name}.#{name}" if expr.relation.name.to_s != table.name.to_s 67 | visit name, Ansr::Arel::Visitors::Facet.new(attribute) if object.select 68 | else 69 | raise "Unexpected filter expression type #{object.expr.class}" 70 | end 71 | end 72 | 73 | def projections 74 | query_opts[:fields] || [] 75 | end 76 | 77 | def filter_projections 78 | query_opts[:facets] || [] 79 | end 80 | 81 | 82 | def field(field_name) 83 | return unless field_name 84 | old = query_opts[:fields] ? Array(query_opts[:fields]) : [] 85 | field_names = (old + Array(field_name)).uniq 86 | if field_names[0] 87 | query_opts[:fields] = field_names[1] ? field_names : field_names[0] 88 | end 89 | end 90 | 91 | def filter_field(field_name) 92 | return unless field_name 93 | field_name = Array(field_name) 94 | field_name.each {|fn| raise "#{fn} is not a facetable field" unless table.facets.include? fn.to_sym} 95 | old = query_opts[:facets] ? Array(query_opts[:facets]) : [] 96 | field_names = (old + Array(field_name)).uniq 97 | if field_names[0] 98 | query_opts[:facets] = field_names[1] ? field_names : field_names[0] 99 | end 100 | end 101 | 102 | def add_where_clause(attr_node, val) 103 | field_key = field_key_from_node(attr_node) 104 | if query_opts[field_key] 105 | query_opts[field_key] = Array(query_opts[field_key]) << val 106 | else 107 | query_opts[field_key] = val 108 | end 109 | end 110 | 111 | # the DPLA API makes no distinction between filter and normal queries 112 | def visit_Arel_Nodes_Equality(object, attribute) 113 | add_where_clause(object.left, object.right) 114 | end 115 | 116 | def visit_Arel_Nodes_NotEqual(object, attribute) 117 | add_where_clause(object.left, "NOT " + object.right) 118 | end 119 | def visit_Arel_Nodes_Or(object, attribute) 120 | add_where_clause(object.left, "OR " + object.right) 121 | end 122 | 123 | def visit_Arel_Nodes_Grouping(object, attribute) 124 | visit object.expr, attribute 125 | end 126 | 127 | def visit_Arel_Nodes_Ordering(object, attribute) 128 | if query_opts[:sort_by] 129 | query_opts[:sort_by] = Array[query_opts[:sort_by]] << object.expr.name 130 | else 131 | query_opts[:sort_by] = object.expr.name 132 | end 133 | direction = :asc if (::Arel::Nodes::Ascending === object and direction) 134 | direction = :desc if (::Arel::Nodes::Descending === object) 135 | query_opts[:sort_order] = direction if direction 136 | end 137 | 138 | def order(*arel_nodes) 139 | direction = nil 140 | nodes = [] 141 | arel_nodes.inject(nodes) do |c, n| 142 | if ::Arel::Nodes::Ordering === n 143 | c << n 144 | elsif n.is_a? String 145 | _ns = n.split(',') 146 | _ns.each do |_n| 147 | _p = _n.split(/\s+/) 148 | if (_p[1]) 149 | _p[1] = _p[1].downcase.to_sym 150 | else 151 | _p[1] = :asc 152 | end 153 | c << table[_p[0].to_sym].send(_p[1]) 154 | end 155 | end 156 | c 157 | end 158 | nodes.each do |node| 159 | if ::Arel::Nodes::Ordering === node 160 | if query_opts[:sort_by] 161 | query_opts[:sort_by] = Array[query_opts[:sort_by]] << node.expr.name 162 | else 163 | query_opts[:sort_by] = node.expr.name 164 | end 165 | direction = :asc if (::Arel::Nodes::Ascending === node and direction) 166 | direction = :desc if (::Arel::Nodes::Descending === node) 167 | end 168 | end 169 | query_opts[:sort_order] = direction if direction 170 | end 171 | 172 | def visit_Arel_Nodes_Limit(object, attribute) 173 | value = object.expr 174 | if value and (value = value.to_i) 175 | raise "Page size cannot be > 500 (#{value}" if value > 500 176 | query_opts[:page_size] = value 177 | end 178 | end 179 | 180 | def visit_Arel_Nodes_Offset(object, attribute) 181 | value = object.expr 182 | if value 183 | query_opts[:page] = (value.to_i / (query_opts[:page_size] || Ansr::Relation::DEFAULT_PAGE_SIZE)) + 1 184 | end 185 | end 186 | 187 | end 188 | end -------------------------------------------------------------------------------- /ansr_blacklight/lib/ansr_blacklight/arel/visitors/query_builder.rb: -------------------------------------------------------------------------------- 1 | module Ansr::Blacklight::Arel::Visitors 2 | class QueryBuilder < Ansr::Arel::Visitors::QueryBuilder 3 | include Ansr::Blacklight::RequestBuilders 4 | attr_reader :solr_request, :path 5 | 6 | def initialize(table) 7 | super(table) 8 | @solr_request = Ansr::Blacklight::Solr::Request.new 9 | table.configure_fields.each do |k,v| 10 | unless v[:select].blank? 11 | v[:select].each do |sk, sv| 12 | key = "f.#{k}.#{sk}".to_sym 13 | @solr_request[key] = sv 14 | end 15 | end 16 | end 17 | @path = table.name || 'select' 18 | end 19 | 20 | public 21 | def query_opts 22 | solr_request 23 | end 24 | 25 | # determines whether multiple values should accumulate or overwrite in merges 26 | def multiple?(field_key) 27 | true 28 | end 29 | 30 | def visit_String o, a 31 | case a 32 | when Ansr::Arel::Visitors::From 33 | query_opts.path = o 34 | when Ansr::Arel::Visitors::Filter 35 | filter_field(o.to_sym) 36 | when Ansr::Arel::Visitors::Order 37 | order(o) 38 | when Ansr::Arel::Visitors::ProjectionTraits 39 | select_val = o.to_s.split(" AS ") 40 | field(select_val[0].to_sym) 41 | if select_val[1] 42 | query_opts.aliases ||= {} 43 | query_opts.aliases[select_val[0]] = select_val[1] 44 | end 45 | else 46 | raise "visited String \"#{o}\" with #{a.to_s}" 47 | end 48 | end 49 | 50 | 51 | def visit_Arel_Nodes_TableAlias(object, attribute) 52 | solr_request[:qt] = object.name.to_s 53 | opts = {qt: object.name.to_s} 54 | if (cf = table[object.name]).is_a? Ansr::Arel::ConfiguredField 55 | opts.merge!(cf.config.fetch(:query,{})) 56 | end 57 | solr_request.merge!(opts) 58 | visit object.relation, attribute 59 | end 60 | 61 | def visit_Ansr_Arel_Nodes_ProjectionTraits(object, attribute) 62 | solr_request[:wt] = object.wt if object.wt 63 | solr_request[:defType] = object.defType if object.defType 64 | visit(object.expr, Ansr::Arel::Visitors::ProjectionTraits.new(attribute)) 65 | end 66 | 67 | def visit_Arel_SqlLiteral(n, attribute) 68 | select_val = n.to_s.split(" AS ") 69 | if Ansr::Arel::Visitors::Filter === attribute 70 | solr_request.append_facet_fields(select_val[0].to_sym) 71 | else 72 | field(select_val[0].to_sym) 73 | if select_val[1] 74 | query_opts.aliases ||= {} 75 | query_opts.aliases[select_val[0]] = select_val[1] 76 | end 77 | end 78 | end 79 | 80 | def from(value) 81 | if value.respond_to? :name 82 | solr_request.path = value.name 83 | else 84 | solr_request.path = value.to_s 85 | end 86 | self.table=value if (value.is_a? Ansr::Arel::BigTable) 87 | end 88 | 89 | def field(field_name) 90 | return unless field_name 91 | old = query_opts[:fields] ? Array(query_opts[:fields]) : [] 92 | field_names = (old + Array(field_name)).uniq 93 | if field_names[0] 94 | query_opts[:fields] = field_names[1] ? field_names : field_names[0] 95 | end 96 | end 97 | 98 | def filter_field(field_name) 99 | return unless field_name 100 | old = solr_request[:"facet.field"] ? Array(solr_request[:"facet.field"]) : [] 101 | fields = Array(field_name).delete_if {|x| old.include? x} 102 | solr_request.append_facet_fields(fields) 103 | end 104 | 105 | def visit_Arel_Nodes_Equality(object, attribute) 106 | field_key = (object.left.respond_to? :expr) ? field_key_from_node(object.left.expr) : field_key_from_node(object.left) 107 | opts = {} 108 | opts.merge!(local_field_params(field_key)) 109 | opts.merge!(object.left.config.fetch(:local,{})) if object.left.respond_to? :config 110 | if Ansr::Arel::Visitors::Filter === attribute or Ansr::Arel::Nodes::Filter === object.left 111 | add_filter_fq_to_solr(solr_request, f: {field_key => object.right}, opts: opts) 112 | else 113 | # check the table for configured fields 114 | add_query_to_solr(field_key, object.right, opts) 115 | end 116 | end 117 | 118 | def visit_Arel_Nodes_NotEqual(object, attribute) 119 | end 120 | 121 | def visit_Arel_Nodes_Or(object, attribute) 122 | end 123 | 124 | def visit_Arel_Nodes_Grouping(object, attribute) 125 | visit object.expr, attribute 126 | end 127 | 128 | def visit_Arel_Nodes_Group(object, attribute) 129 | solr_request[:group] = object.expr.to_s 130 | end 131 | 132 | def visit_Ansr_Arel_Nodes_Facet(object, attribute) 133 | name = object.expr 134 | name = name.name if name.respond_to? :name 135 | default = object.pivot || object.query 136 | if name == ::Arel.star 137 | prefix = "facet." 138 | default = true 139 | else 140 | filter_field(name.to_sym) unless default 141 | solr_request.append_facet_fields(name.to_sym) unless default 142 | prefix = "f.#{name}.facet." 143 | end 144 | # there's got to be a helper for this 145 | if object.pivot 146 | solr_request.append_facet_pivot with_ex_local_param(object.ex, Array(object.pivot).join(",")) 147 | elsif object.query 148 | solr_request.append_facet_query object.query.map { |k, x| with_ex_local_param(object.ex, x[:fq]) } 149 | else 150 | object.opts.each do |att, value| 151 | solr_request["#{prefix}#{att.to_s}".to_sym] = value.to_s unless att == :ex 152 | end 153 | solr_request.append_facet_fields with_ex_local_param(object.ex, name.to_sym) unless default 154 | end 155 | end 156 | 157 | def visit_Ansr_Arel_Nodes_Spellcheck(object, attribute) 158 | unless object.expr == false 159 | solr_request[:spellcheck] = object.expr.to_s 160 | end 161 | object.opts.each do |att, val| 162 | solr_request["spellcheck.#{att.to_s}".to_sym] = val if att != :select 163 | end 164 | end 165 | 166 | def visit_Ansr_Arel_Nodes_Highlight(object, attribute) 167 | unless object.expr == false or object.expr == true 168 | solr_request[:hl] = object.expr.to_s 169 | end 170 | object.opts.each do |att, val| 171 | solr_request["hl.#{att.to_s}".to_sym] = val if att != :select 172 | end 173 | end 174 | 175 | def order(*arel_nodes) 176 | direction = nil 177 | nodes = [] 178 | arel_nodes.inject(nodes) do |c, n| 179 | if ::Arel::Nodes::Ordering === n 180 | c << n 181 | elsif n.is_a? String 182 | _ns = n.split(/,\s*/) 183 | _ns.each do |_n| 184 | _p = _n.split(/\s+/) 185 | if (_p[1]) 186 | _p[1] = _p[1].downcase.to_sym 187 | else 188 | _p[1] = :asc 189 | end 190 | c << table[_p[0].to_sym].send(_p[1]) 191 | end 192 | end 193 | c 194 | end 195 | nodes.each do |node| 196 | if ::Arel::Nodes::Ordering === node 197 | if solr_request[:sort_by] 198 | solr_request[:sort_by] = Array[solr_request[:sort_by]] << node.expr.name 199 | else 200 | solr_request[:sort_by] = node.expr.name 201 | end 202 | direction = :asc if (::Arel::Nodes::Ascending === node and direction) 203 | direction = :desc if (::Arel::Nodes::Descending === node) 204 | end 205 | end 206 | solr_request[:sort_order] = direction if direction 207 | end 208 | 209 | def visit_Arel_Nodes_Limit(object, attribute) 210 | value = object.expr 211 | if value and (value = value.to_i) 212 | raise "Page size cannot be > 500 (#{value}" if value > 500 213 | solr_request[:rows] = value.to_s 214 | end 215 | end 216 | 217 | def visit_Arel_Nodes_Offset(object, attribute) 218 | value = object.expr 219 | if value 220 | solr_request[:start] = value.to_s 221 | end 222 | end 223 | 224 | end 225 | end -------------------------------------------------------------------------------- /lib/ansr/relation/query_methods.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | module Ansr 3 | module QueryMethods 4 | include Ansr::Configurable 5 | include ActiveRecord::QueryMethods 6 | 7 | ActiveRecord::QueryMethods::WhereChain.class_eval <<-RUBY 8 | def or(opts, *rest) 9 | where_value = @scope.send(:build_where, opts, rest).map do |rel| 10 | case rel 11 | when ::Arel::Nodes::In 12 | ::Arel::Nodes::Or.new(rel.left, rel.right) 13 | when ::Arel::Nodes::Equality 14 | ::Arel::Nodes::Or.new(rel.left, rel.right) 15 | when String 16 | ::Arel::Nodes::Or.new(::Arel::Nodes::SqlLiteral.new(rel)) 17 | else 18 | ::Arel::Nodes::Or.new(rel) 19 | end 20 | end 21 | @scope.where_values += where_value.flatten 22 | @scope 23 | end 24 | RUBY 25 | 26 | 27 | def filter_values 28 | @values[:filter] ||= [] 29 | end 30 | 31 | def filter_values=(values) 32 | raise ImmutableRelation if @loaded 33 | @values[:filter] = values 34 | end 35 | 36 | def filter(expr) 37 | check_if_method_has_arguments!("filter", expr) 38 | spawn.filter!(expr) 39 | end 40 | 41 | def filter!(expr) 42 | return self if expr.empty? 43 | 44 | filter_nodes = build_where(expr) 45 | return self unless filter_nodes 46 | filters = [] 47 | filter_nodes.each do |filter_node| 48 | case filter_node 49 | when ::Arel::Nodes::In, ::Arel::Nodes::Equality 50 | filter_node.left = Ansr::Arel::Nodes::Filter.new(filter_node.left) 51 | when ::Arel::SqlLiteral 52 | filter_node = Ansr::Arel::Nodes::Filter.new(filter_node) 53 | when String, Symbol 54 | filter_node = Ansr::Arel::Nodes::Filter.new(::Arel::SqlLiteral.new(filter_node.to_s)) 55 | else 56 | raise "unexpected filter node type #{filter_node.class}" 57 | end 58 | filters << filter_node 59 | end 60 | self.filter_values+= filters 61 | 62 | self 63 | end 64 | 65 | def filter_unscoping(target_value) 66 | target_value_sym = target_value.to_sym 67 | 68 | filter_values.reject! do |rel| 69 | case rel 70 | when ::Arel::Nodes::In, ::Arel::Nodes::Equality 71 | subrelation = (rel.left.kind_of?(::Arel::Attributes::Attribute) ? rel.left : rel.right) 72 | subrelation.name.to_sym == target_value_sym 73 | else 74 | raise "unscope(filter: #{target_value.inspect}) failed: unscoping #{rel.class} is unimplemented." 75 | end 76 | end 77 | end 78 | 79 | def facet(expr, opts = {}) 80 | spawn.facet!(expr, opts) 81 | end 82 | 83 | def facet!(expr, opts={}) 84 | self.facet_values+= build_facets(expr, opts) 85 | self 86 | end 87 | 88 | def facet_values 89 | @values[:facets] || [] 90 | end 91 | 92 | def facet_values=(values) 93 | raise ActiveRecord::ImmutableRelation if @loaded 94 | @values[:facets]=values 95 | end 96 | 97 | def facet_unscoping(target_value) 98 | end 99 | 100 | def filter_name(expr) 101 | connection.sanitize_filter_name(field_name(expr)) 102 | end 103 | 104 | def as(args) 105 | spawn.as!(args) 106 | end 107 | 108 | def as!(args) 109 | self.as_value= args 110 | self 111 | end 112 | 113 | def as_value 114 | @values[:as] 115 | end 116 | 117 | def as_value=(args) 118 | raise ActiveRecord::ImmutableRelation if @loaded 119 | @values[:as] = args 120 | end 121 | 122 | def as_unscoping(*args) 123 | @values.delete(:as) 124 | end 125 | 126 | def highlight(expr, opts={}) 127 | spawn.highlight!(expr, opts) 128 | end 129 | 130 | def highlight!(expr, opts = {}) 131 | self.highlight_value= Ansr::Arel::Nodes::Highlight.new(expr, opts) 132 | end 133 | 134 | def highlight_value 135 | @values[:highlight] 136 | end 137 | 138 | def highlight_value=(val) 139 | raise ActiveRecord::ImmutableRelation if @loaded 140 | @values[:highlight] = val 141 | end 142 | 143 | def highlight_unscoping(*args) 144 | @values.delete(:highlight) 145 | end 146 | 147 | def spellcheck(expr, opts={}) 148 | spawn.spellcheck!(expr, opts) 149 | end 150 | 151 | def spellcheck!(expr, opts = {}) 152 | self.spellcheck_value= Ansr::Arel::Nodes::Spellcheck.new(expr, opts) 153 | end 154 | 155 | def spellcheck_value 156 | @values[:spellcheck] 157 | end 158 | 159 | def spellcheck_value=(val) 160 | raise ActiveRecord::ImmutableRelation if @loaded 161 | @values[:spellcheck] = val 162 | end 163 | 164 | def spellcheck_unscoping(*args) 165 | @values.delete(:spellcheck) 166 | end 167 | 168 | def field_name(expr) 169 | if expr.is_a? Array 170 | return expr.collect{|x| field_name(x)}.compact 171 | else 172 | case expr 173 | when ::Arel::Nodes::Binary 174 | if expr.left.relation.name != model().table.name 175 | # oof, this is really hacky 176 | field_name = "#{expr.left.relation.name}.#{expr.left.name}".to_sym 177 | field_name = expr.left.name.to_sym 178 | else 179 | field_name = expr.left.name.to_sym 180 | end 181 | when ::Arel::Attributes::Attribute, Ansr::Arel::ConfiguredField 182 | if expr.relation.name != model().table.name 183 | # oof, this is really hacky 184 | field_name = "#{expr.relation.name}.#{expr.name}".to_sym 185 | field_name = expr.name.to_sym 186 | else 187 | field_name = expr.name.to_sym 188 | end 189 | when ::Arel::Nodes::Unary, Ansr::Arel::Nodes::Filter 190 | if expr.expr.relation.name != model().table.name 191 | # oof, this is really hacky 192 | field_name = "#{expr.expr.relation.name}.#{expr.expr.name}".to_sym 193 | field_name = expr.expr.name.to_sym 194 | else 195 | field_name = expr.expr.name.to_sym 196 | end 197 | else 198 | field_name = expr.to_sym 199 | end 200 | return field_name 201 | end 202 | end 203 | 204 | def all_facet_fields 205 | FACETS 206 | end 207 | 208 | def all_sort_fields 209 | SORTS 210 | end 211 | 212 | def find(id) 213 | klass = model() 214 | rel = where(klass.table.primary_key.name => id).limit(1) 215 | rel.to_a 216 | unless rel.to_a[0] 217 | raise 'Bad ID' 218 | end 219 | rel.to_a.first 220 | end 221 | 222 | def collapse_wheres(arel, wheres) 223 | predicates = wheres.map do |where| 224 | next where if ::Arel::Nodes::Equality === where 225 | where = Arel.sql(where) if String === where # SqlLiteral-ize 226 | ::Arel::Nodes::Grouping.new(where) 227 | end 228 | 229 | arel.where(::Arel::Nodes::And.new(predicates)) if predicates.present? 230 | end 231 | 232 | def collapse_filters(arel, filters) 233 | predicates = filters.map do |filter| 234 | next filter if ::Arel::Nodes::Equality === filter 235 | filter = Arel.sql(filter) if String === filter # SqlLiteral-ize 236 | ::Arel::Nodes::Grouping.new(filter) 237 | end 238 | 239 | arel.where(::Arel::Nodes::And.new(predicates)) if predicates.present? 240 | end 241 | 242 | # Could filtering be moved out of intersection into one arel tree? 243 | def build_arel 244 | arel = super 245 | collapse_filters(arel, (filter_values).uniq) 246 | arel.projections << @values[:spellcheck] if @values[:spellcheck] 247 | arel.projections << @values[:highlight] if @values[:highlight] 248 | arel.projections += facet_values 249 | arel.from arel.create_table_alias(arel.source.left, as_value) if as_value 250 | arel 251 | end 252 | 253 | # cloning from ActiveRecord::QueryMethods.build_where as a first pass 254 | def build_facets(expr, opts={}) 255 | case expr 256 | when Hash 257 | build_facets(::Arel.star,expr) 258 | when Array 259 | r = expr.inject([]) {|m,e| m.concat build_facets(e,opts)} 260 | when String, Symbol, ::Arel::Nodes::SqlLiteral 261 | [Ansr::Arel::Nodes::Facet.new(::Arel::Attributes::Attribute.new(table, expr.to_s), opts)] 262 | when ::Arel::Attributes::Attribute 263 | [Ansr::Arel::Nodes::Facet.new(expr, opts)] 264 | else 265 | [expr] 266 | end 267 | end 268 | 269 | # cloning from ActiveRecord::QueryMethods.build_where to use our PredicateBuilder 270 | def build_where(opts, other = []) 271 | case opts 272 | when String, Array 273 | #TODO: Remove duplication with: /activerecord/lib/active_record/sanitization.rb:113 274 | values = Hash === other.first ? other.first.values : other 275 | 276 | values.grep(ActiveRecord::Relation) do |rel| 277 | self.bind_values += rel.bind_values 278 | end 279 | 280 | [@klass.send(:sanitize_sql, other.empty? ? opts : ([opts] + other))] 281 | when Hash 282 | attributes = @klass.send(:expand_hash_conditions_for_aggregates, opts) 283 | 284 | attributes.values.grep(ActiveRecord::Relation) do |rel| 285 | self.bind_values += rel.bind_values 286 | end 287 | 288 | PredicateBuilder.build_from_hash(klass, attributes, table) 289 | else 290 | [opts] 291 | end 292 | end 293 | 294 | def find_by_nosql(*args) 295 | model.connection.execute(ansr_query(*args)) 296 | end 297 | 298 | def ansr_query(*args) 299 | if args.first 300 | arel, bind_values = [args[0], args[1]] 301 | @ansr_query = model.connection.to_nosql(arel, bind_values) 302 | end 303 | @ansr_query 304 | end 305 | end 306 | end -------------------------------------------------------------------------------- /ansr_dpla/fixtures/item.jsonld: -------------------------------------------------------------------------------- 1 | { 2 | "docs": [ 3 | { 4 | "_id": "bpl--oai:digitalcommonwealth.org:commonwealth:6682xj30d", 5 | "dataProvider": "Boston Public Library", 6 | "sourceResource": { 7 | "isPartOf": [ 8 | "Leslie Jones Collection", 9 | "Leslie Jones Collection. Animals: Dogs & Cats" 10 | ], 11 | "description": [ 12 | "Title from information provided by Leslie Jones or the Boston Public Library on the negative or negative sleeve.", 13 | "Date supplied by cataloger." 14 | ], 15 | "creator": "Jones, Leslie, 1886-1967", 16 | "rights": "Copyright (c) Leslie Jones. This work is licensed for use under a Creative Commons Attribution Non-Commercial No Derivatives License (CC BY-NC-ND).", 17 | "collection": [ 18 | { 19 | "@id": "http://dp.la/api/collections/460c76299e1b0a46afea352b1ab8f556", 20 | "id": "460c76299e1b0a46afea352b1ab8f556", 21 | "title": "Leslie Jones Collection" 22 | } 23 | ], 24 | "date": { 25 | "begin": "1917", 26 | "end": "1934", 27 | "displayDate": "1917-1934" 28 | }, 29 | "extent": "1 negative : glass, black & white ; 4 x 5 in.", 30 | "stateLocatedIn": [ 31 | { 32 | "name": "Massachusetts" 33 | } 34 | ], 35 | "title": "Kittens", 36 | "identifier": [ 37 | "Local accession: 08_06_000884" 38 | ], 39 | "type": "image", 40 | "subject": [ 41 | { 42 | "name": "Cats" 43 | }, 44 | { 45 | "name": "Baby animals" 46 | } 47 | ] 48 | }, 49 | "object": "http://ark.digitalcommonwealth.org/ark:/50959/6682xj30d/thumbnail", 50 | "ingestDate": "2014-02-15T15:10:38.797024", 51 | "originalRecord": { 52 | "header": { 53 | "datestamp": "2013-09-20T14:14:36Z", 54 | "identifier": "oai:digitalcommonwealth.org:commonwealth:6682xj30d", 55 | "setSpec": "commonwealth:2j62s484w" 56 | }, 57 | "metadata": { 58 | "mods:mods": { 59 | "mods:subject": [ 60 | { 61 | "mods:topic": "Cats", 62 | "authority": "lctgm" 63 | }, 64 | { 65 | "mods:topic": "Baby animals", 66 | "authority": "lctgm" 67 | } 68 | ], 69 | "mods:genre": [ 70 | { 71 | "displayLabel": "general", 72 | "authorityURI": "http://id.loc.gov/vocabulary/graphicMaterials", 73 | "#text": "Photographs", 74 | "authority": "gmgpc", 75 | "valueURI": "http://id.loc.gov/vocabulary/graphicMaterials/tgm007721" 76 | }, 77 | { 78 | "displayLabel": "specific", 79 | "authorityURI": "http://id.loc.gov/vocabulary/graphicMaterials", 80 | "#text": "Glass negatives", 81 | "authority": "gmgpc", 82 | "valueURI": "http://id.loc.gov/vocabulary/graphicMaterials/tgm004561" 83 | } 84 | ], 85 | "xmlns:mods": "http://www.loc.gov/mods/v3", 86 | "mods:typeOfResource": "still image", 87 | "mods:recordInfo": { 88 | "mods:languageOfCataloging": { 89 | "mods:languageTerm": { 90 | "authorityURI": "http://id.loc.gov/vocabulary/iso639-2", 91 | "#text": "English", 92 | "type": "text", 93 | "authority": "iso639-2b", 94 | "valueURI": "http://id.loc.gov/vocabulary/iso639-2/eng" 95 | } 96 | }, 97 | "mods:descriptionStandard": { 98 | "#text": "gihc", 99 | "authority": "marcdescription" 100 | }, 101 | "mods:recordOrigin": "human prepared", 102 | "mods:recordContentSource": "Boston Public Library" 103 | }, 104 | "mods:titleInfo": { 105 | "usage": "primary", 106 | "mods:title": "Kittens" 107 | }, 108 | "mods:physicalDescription": { 109 | "mods:extent": "1 negative : glass, black & white ; 4 x 5 in.", 110 | "mods:digitalOrigin": "reformatted digital", 111 | "mods:internetMediaType": [ 112 | "image/tiff", 113 | "image/jpeg" 114 | ] 115 | }, 116 | "mods:identifier": [ 117 | { 118 | "#text": "08_06_000884", 119 | "type": "local-accession" 120 | }, 121 | { 122 | "#text": "http://ark.digitalcommonwealth.org/ark:/50959/6682xj30d", 123 | "type": "uri" 124 | } 125 | ], 126 | "version": "3.4", 127 | "mods:note": [ 128 | "Title from information provided by Leslie Jones or the Boston Public Library on the negative or negative sleeve.", 129 | "Date supplied by cataloger." 130 | ], 131 | "mods:name": { 132 | "mods:namePart": [ 133 | "Jones, Leslie", 134 | { 135 | "#text": "1886-1967", 136 | "type": "date" 137 | } 138 | ], 139 | "mods:role": { 140 | "mods:roleTerm": { 141 | "authorityURI": "http://id.loc.gov/vocabulary/relators", 142 | "#text": "Photographer", 143 | "type": "text", 144 | "authority": "marcrelator", 145 | "valueURI": "http://id.loc.gov/vocabulary/relators/pht" 146 | } 147 | }, 148 | "type": "personal", 149 | "authority": "local" 150 | }, 151 | "mods:location": [ 152 | { 153 | "mods:physicalLocation": "Boston Public Library", 154 | "mods:holdingSimple": { 155 | "mods:copyInformation": { 156 | "mods:subLocation": "Print Department" 157 | } 158 | } 159 | }, 160 | { 161 | "mods:url": [ 162 | { 163 | "access": "preview", 164 | "#text": "http://ark.digitalcommonwealth.org/ark:/50959/6682xj30d/thumbnail" 165 | }, 166 | { 167 | "usage": "primary", 168 | "access": "object in context", 169 | "#text": "http://ark.digitalcommonwealth.org/ark:/50959/6682xj30d" 170 | } 171 | ] 172 | } 173 | ], 174 | "mods:accessCondition": [ 175 | { 176 | "#text": "Copyright (c) Leslie Jones.", 177 | "type": "use and reproduction" 178 | }, 179 | { 180 | "#text": "This work is licensed for use under a Creative Commons Attribution Non-Commercial No Derivatives License (CC BY-NC-ND).", 181 | "type": "use and reproduction" 182 | } 183 | ], 184 | "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", 185 | "xmlns:xlink": "http://www.w3.org/1999/xlink", 186 | "xsi:schemaLocation": "http://www.loc.gov/mods/v3 http://www.loc.gov/standards/mods/v3/mods-3-4.xsd", 187 | "mods:originInfo": { 188 | "mods:dateCreated": [ 189 | { 190 | "encoding": "w3cdtf", 191 | "#text": "1917", 192 | "qualifier": "approximate", 193 | "keyDate": "yes", 194 | "point": "start" 195 | }, 196 | { 197 | "encoding": "w3cdtf", 198 | "#text": "1934", 199 | "qualifier": "approximate", 200 | "point": "end" 201 | } 202 | ] 203 | }, 204 | "mods:relatedItem": [ 205 | { 206 | "type": "host", 207 | "mods:titleInfo": { 208 | "mods:title": "Leslie Jones Collection" 209 | } 210 | }, 211 | { 212 | "type": "series", 213 | "mods:titleInfo": { 214 | "mods:title": "Animals: Dogs & Cats" 215 | } 216 | } 217 | ] 218 | } 219 | }, 220 | "id": "oai:digitalcommonwealth.org:commonwealth:6682xj30d", 221 | "collection": [ 222 | { 223 | "@id": "http://dp.la/api/collections/460c76299e1b0a46afea352b1ab8f556", 224 | "id": "460c76299e1b0a46afea352b1ab8f556", 225 | "title": "Leslie Jones Collection" 226 | } 227 | ], 228 | "provider": { 229 | "@id": "http://dp.la/api/contributor/digital-commonwealth", 230 | "name": "Digital Commonwealth" 231 | } 232 | }, 233 | "ingestionSequence": 5, 234 | "isShownAt": "http://ark.digitalcommonwealth.org/ark:/50959/6682xj30d", 235 | "provider": { 236 | "@id": "http://dp.la/api/contributor/digital-commonwealth", 237 | "name": "Digital Commonwealth" 238 | }, 239 | "@context": { 240 | "begin": { 241 | "@id": "dpla:dateRangeStart", 242 | "@type": "xsd:date" 243 | }, 244 | "@vocab": "http://purl.org/dc/terms/", 245 | "hasView": "edm:hasView", 246 | "name": "xsd:string", 247 | "object": "edm:object", 248 | "dpla": "http://dp.la/terms/", 249 | "collection": "dpla:aggregation", 250 | "edm": "http://www.europeana.eu/schemas/edm/", 251 | "end": { 252 | "@id": "dpla:end", 253 | "@type": "xsd:date" 254 | }, 255 | "state": "dpla:state", 256 | "aggregatedDigitalResource": "dpla:aggregatedDigitalResource", 257 | "coordinates": "dpla:coordinates", 258 | "isShownAt": "edm:isShownAt", 259 | "stateLocatedIn": "dpla:stateLocatedIn", 260 | "sourceResource": "edm:sourceResource", 261 | "dataProvider": "edm:dataProvider", 262 | "originalRecord": "dpla:originalRecord", 263 | "provider": "edm:provider", 264 | "LCSH": "http://id.loc.gov/authorities/subjects" 265 | }, 266 | "ingestType": "item", 267 | "@id": "http://dp.la/api/items/9f695d8c16a4978061a25076a43aa11f", 268 | "id": "9f695d8c16a4978061a25076a43aa11f" 269 | } 270 | ], 271 | "count": 1 272 | } -------------------------------------------------------------------------------- /ansr_dpla/spec/lib/relation_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ansr::Dpla::Relation do 4 | before do 5 | @kittens = read_fixture('kittens.jsonld') 6 | @faceted = read_fixture('kittens_faceted.jsonld') 7 | @empty = read_fixture('empty.jsonld') 8 | @mock_api = double('api') 9 | Item.config {|x| x[:api_key] = :foo } 10 | Item.engine.api= @mock_api 11 | end 12 | 13 | subject { Ansr::Dpla::Relation.new(Item, Item.table) } 14 | 15 | describe "#initialize" do 16 | it "should identify the correct resource for the model" do 17 | class Foo 18 | def self.table 19 | nil 20 | end 21 | end 22 | 23 | test = Ansr::Dpla::Relation.new(Foo, Foo.table) 24 | expect(test.resource).to eql(:foos) 25 | end 26 | end 27 | 28 | describe ".load" do 29 | it "should fetch the appropriate REST resource" do 30 | test = subject.where(:q=>'kittens') 31 | @mock_api.should_receive(:items).with(:q => 'kittens').and_return('') 32 | test.load 33 | end 34 | 35 | describe "Relation attributes" do 36 | it "should set attributes correctly from a response" do 37 | test = subject.where(:q=>'kittens') 38 | @mock_api.stub(:items).and_return(@kittens) 39 | test.load 40 | expect(test.count).to eql(144) 41 | expect(test.offset_value).to eql(0) 42 | expect(test.limit_value).to eql(10) 43 | end 44 | 45 | it "should set attributes correctly for an empty response" do 46 | test = subject.where(:q=>'kittens') 47 | @mock_api.stub(:items).and_return(@empty) 48 | test.load 49 | expect(test.count).to eql(0) 50 | expect(test.offset_value).to eql(0) 51 | expect(test.limit_value).to eql(10) 52 | end 53 | end 54 | end 55 | 56 | describe '#all' do 57 | it 'should fail because we don\'t have a test yet' 58 | end 59 | 60 | describe '#none' do 61 | it 'should fail because we don\'t have a test yet' 62 | end 63 | 64 | describe '.order' do 65 | describe 'with symbol parms' do 66 | it "should add sort clause to query" do 67 | test = subject.where(:q=>'kittens').order(:foo) 68 | expect(test).to be_a(Ansr::Relation) 69 | @mock_api.should_receive(:items).with(:q => 'kittens',:sort_by=>:foo).and_return('') 70 | test.load 71 | end 72 | 73 | it "should add multiple sort clauses to query" do 74 | test = subject.where(:q=>'kittens').order(:foo).order(:bar) 75 | expect(test).to be_a(Ansr::Relation) 76 | @mock_api.should_receive(:items).with(:q => 'kittens',:sort_by=>[:foo,:bar]).and_return('') 77 | test.load 78 | end 79 | 80 | it "should sort in descending order if necessary" do 81 | test = subject.where(:q=>'kittens').order(:foo => :desc) 82 | expect(test).to be_a(Ansr::Relation) 83 | @mock_api.should_receive(:items).with(:q => 'kittens',:sort_by=>:foo, :sort_order=>:desc).and_return('') 84 | test.load 85 | end 86 | end 87 | describe 'with String parms' do 88 | it "should add sort clause to query" do 89 | test = subject.where(q:'kittens').order("foo") 90 | expect(test).to be_a(Ansr::Relation) 91 | @mock_api.should_receive(:items).with(:q => 'kittens',:sort_by=>:foo).and_return('') 92 | test.load 93 | end 94 | 95 | it "should sort in descending order if necessary" do 96 | test = subject.where(q:'kittens').order("foo DESC") 97 | expect(test).to be_a(Ansr::Relation) 98 | @mock_api.should_receive(:items).with(:q => 'kittens',:sort_by=>:foo, :sort_order=>:desc).and_return('') 99 | test.load 100 | end 101 | end 102 | end 103 | 104 | describe '#reorder' do 105 | it "should replace existing order" do 106 | test = subject.where(q:'kittens').order("foo DESC") 107 | test = test.reorder("foo ASC") 108 | expect(test).to be_a(Ansr::Relation) 109 | @mock_api.should_receive(:items).with(:q => 'kittens',:sort_by=>:foo).and_return('') 110 | test.load 111 | end 112 | end 113 | 114 | describe '#reverse_order' do 115 | it "should replace existing DESC order" do 116 | test = subject.where(q:'kittens').order("foo DESC") 117 | test = test.reverse_order 118 | expect(test).to be_a(Ansr::Relation) 119 | @mock_api.should_receive(:items).with(:q => 'kittens',:sort_by=>:foo).and_return('') 120 | test.load 121 | end 122 | 123 | it "should replace existing ASC order" do 124 | test = subject.where(q:'kittens').order("foo ASC") 125 | test = test.reverse_order 126 | expect(test).to be_a(Ansr::Relation) 127 | @mock_api.should_receive(:items).with(:q => 'kittens',:sort_by=>:foo, :sort_order=>:desc).and_return('') 128 | test.load 129 | end 130 | end 131 | 132 | describe '#unscope' do 133 | it 'should remove clauses only from spawned Relation' do 134 | test = subject.where(q:'kittens').order("foo DESC") 135 | test2 = test.unscope(:order) 136 | expect(test2).to be_a(Ansr::Relation) 137 | @mock_api.should_receive(:items).with(:q => 'kittens').and_return('') 138 | test2.load 139 | expect(test.order_values.empty?).to be_false 140 | end 141 | # ActiveRecord::QueryMethods.VALID_UNSCOPING_VALUES => 142 | # Set.new([:where, :select, :group, :order, :lock, :limit, :offset, :joins, :includes, :from, :readonly, :having]) 143 | it 'should reject bad scope keys' do 144 | test = subject.where(q:'kittens').order("foo DESC") 145 | expect { test.unscope(:foo) }.to raise_error 146 | end 147 | end 148 | 149 | describe '#select' do 150 | describe 'with a block given' do 151 | it "should build an array" do 152 | test = subject.where(q:'kittens') 153 | @mock_api.should_receive(:items).with(:q => 'kittens').and_return(@kittens) 154 | actual = test.select {|d| true} 155 | expect(actual).to be_a(Array) 156 | expect(actual.length).to eql(test.limit_value) 157 | actual = test.select {|d| false} 158 | expect(actual).to be_a(Array) 159 | expect(actual.length).to eql(0) 160 | end 161 | end 162 | describe 'with a String or Symbol key given' do 163 | it 'should change the requested document fields' do 164 | test = subject.where(q:'kittens') 165 | @mock_api.should_receive(:items).with(:q => 'kittens', :fields=>:name).and_return('') 166 | test = test.select('name') 167 | test.load 168 | end 169 | end 170 | describe 'with a list of keys' do 171 | it "should add all the requested document fields" do 172 | test = subject.where(q:'kittens') 173 | @mock_api.should_receive(:items).with(:q => 'kittens', :fields=>[:name,:foo]).and_return('') 174 | test = test.select(['name','foo']) 175 | test.load 176 | end 177 | it "should add all the requested document fields and proxy them" do 178 | test = subject.where(q:'kittens') 179 | @mock_api.should_receive(:items).with(:q => 'kittens', :fields=>:object).and_return(@kittens) 180 | test = test.select('object AS my_object') 181 | test.load 182 | expect(test.to_a.first['object']).to be_nil 183 | expect(test.to_a.first['my_object']).to eql('http://ark.digitalcommonwealth.org/ark:/50959/6682xj30d/thumbnail') 184 | end 185 | end 186 | end 187 | 188 | describe '#limit' do 189 | it "should add page_size to the query params" do 190 | test = subject.where(q:'kittens') 191 | @mock_api.should_receive(:items).with(:q => 'kittens', :page_size=>17).and_return('') 192 | test = test.limit(17) 193 | test.load 194 | end 195 | it "should raise an error if limit > 500" do 196 | test = subject.where(q:'kittens') 197 | test = test.limit(500) 198 | expect {test.load }.to raise_error 199 | end 200 | end 201 | 202 | describe '#offset' do 203 | it "should add page to the query params when page_size is defaulted" do 204 | test = subject.where(q:'kittens') 205 | @mock_api.should_receive(:items).with(:q => 'kittens', :page=>3).and_return('') 206 | test = test.offset(20) 207 | test.load 208 | end 209 | it 'should raise an error if an offset is requested that is not a multiple of the page size' do 210 | test = subject.where(q:'kittens').limit(12) 211 | expect{test.offset(17)}.to raise_error(/^Bad/) 212 | test.offset(24) 213 | end 214 | end 215 | 216 | describe '#to_a' do 217 | it 'should load the records and return them' do 218 | end 219 | end 220 | 221 | describe '#loaded?' do 222 | it 'should be false before and true after' do 223 | test = subject.where(q:'kittens') 224 | @mock_api.stub(:items).with(:q => 'kittens').and_return('') 225 | expect(test.loaded?).to be_false 226 | test.load 227 | expect(test.loaded?).to be_true 228 | end 229 | end 230 | 231 | 232 | describe '#any?' 233 | 234 | 235 | describe '#blank?' do 236 | it 'should load the records via to_a' 237 | 238 | end 239 | 240 | describe '#count' do 241 | end 242 | 243 | describe '#empty?' do 244 | it 'should not make another call if records are loaded' do 245 | test = subject.where(q:'kittens') 246 | @mock_api.should_receive(:items).with(:q => 'kittens').once.and_return(@empty) 247 | test.load 248 | expect(test.loaded?).to be_true 249 | expect(test.empty?).to be_true 250 | end 251 | 252 | end 253 | 254 | describe "#spawn" do 255 | it "should create new, independent Relations from Query methods" 256 | 257 | end 258 | 259 | describe 'RDBMS-specific methods' do 260 | describe '#references' do 261 | it 'should not do anything at all' 262 | end 263 | describe '#joins' do 264 | it 'should not do anything at all' 265 | end 266 | describe '#distinct and #uniq' do 267 | it 'should not do anything at all' 268 | end 269 | describe '#preload' do 270 | it 'should not do anything at all' 271 | end 272 | describe '#includes' do 273 | it 'should not do anything at all' 274 | end 275 | describe '#having' do 276 | it 'should not do anything at all' 277 | end 278 | end 279 | 280 | describe 'read-write methods' do 281 | describe '#readonly' do 282 | it "should blissfully ignore no-args or true" 283 | 284 | it "should refuse values of false" 285 | 286 | end 287 | describe '#create_with' do 288 | it 'should not do anything at all' 289 | end 290 | describe '#insert' do 291 | it 'should not do anything at all' 292 | end 293 | describe '#delete' do 294 | it 'should not do anything at all' 295 | end 296 | describe '#delete_all' do 297 | it 'should not do anything at all' 298 | end 299 | describe '#destroy' do 300 | it 'should not do anything at all' 301 | end 302 | describe '#destroy_all' do 303 | it 'should not do anything at all' 304 | end 305 | end 306 | 307 | end -------------------------------------------------------------------------------- /ansr_blacklight/spec/lib/loaded_relation_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ansr::Blacklight::Relation do 4 | 5 | def create_response 6 | raw_response = eval(mock_query_response) 7 | Ansr::Blacklight::Solr::Response.new(raw_response, raw_response['params']) 8 | end 9 | 10 | def stub_solr 11 | @solr ||= double('Solr') 12 | @solr.stub(:send_and_receive).and_return(eval(mock_query_response)) 13 | @solr 14 | end 15 | 16 | before do 17 | Object.const_set('LoadTestModel', Class.new(TestModel)) 18 | LoadTestModel.solr = stub_solr 19 | @relation = Ansr::Blacklight::Relation.new(LoadTestModel, Ansr::Arel::BigTable.new(LoadTestModel)) 20 | @relation.load 21 | end 22 | after do 23 | Object.send(:remove_const, :LoadTestModel) 24 | end 25 | subject { @relation } 26 | 27 | let(:r) { subject } 28 | 29 | describe 'pagination' do 30 | it 'should have accurate pagination numbers' do 31 | expect(r.limit_value).to eq 10 32 | expect(r.count).to eq 26 33 | expect(r.offset_value).to eq 0 34 | end 35 | 36 | it "should provide kaminari pagination helpers" do 37 | expect(r.total_count).to eq(r.count) 38 | expect(r.next_page).to eq(r.current_page + 1) 39 | expect(r.prev_page).to eq(nil) 40 | if Kaminari.config.respond_to? :max_pages 41 | expect(r.max_pages).to be_nil 42 | end 43 | expect(r).to be_a_kind_of Kaminari::PageScopeMethods 44 | end 45 | end 46 | 47 | it 'should create a valid response class' do 48 | expect(r).to respond_to(:response) 49 | expect(r.docs).to have(11).docs 50 | expect(r.params[:echoParams]).to eq 'EXPLICIT' 51 | 52 | expect(r).to respond_to(:facets) 53 | end 54 | 55 | it 'should provide facet helpers' do 56 | r.facets.size.should == 2 57 | 58 | field_names = r.facets.collect{|facet|facet.name} 59 | field_names.include?('cat').should == true 60 | field_names.include?('manu').should == true 61 | 62 | first_facet = r.facets.select { |x| x.name == 'cat'}.first 63 | first_facet.name.should == 'cat' 64 | 65 | first_facet.items.size.should == 10 66 | 67 | expected = "electronics - 14, memory - 3, card - 2, connector - 2, drive - 2, graphics - 2, hard - 2, monitor - 2, search - 2, software - 2" 68 | received = first_facet.items.collect do |item| 69 | item.value + ' - ' + item.hits.to_s 70 | end.join(', ') 71 | 72 | expect(received).to eq expected 73 | 74 | r.facets.each do |facet| 75 | expect(facet).to respond_to :name 76 | expect(facet).to respond_to :sort 77 | expect(facet).to respond_to :offset 78 | expect(facet).to respond_to :limit 79 | facet.items.each do |item| 80 | expect(item).to respond_to :value 81 | expect(item).to respond_to :hits 82 | end 83 | end 84 | end 85 | 86 | it "should provide a model name helper" do 87 | expect(r.model_name).to eq LoadTestModel.name 88 | end 89 | 90 | describe "FacetItem" do 91 | it "should work with a field,value tuple" do 92 | item = Ansr::Facets::FacetItem.new('value', 15) 93 | puts item.class.name 94 | item.value.should == 'value' 95 | item.hits.should == 15 96 | end 97 | 98 | it "should work with a field,value + hash triple" do 99 | item = Ansr::Facets::FacetItem.new('value', 15, :a => 1, :value => 'ignored') 100 | item.value.should == 'value' 101 | item.hits.should == 15 102 | item.a.should == 1 103 | end 104 | 105 | it "should work like an openstruct" do 106 | item = Ansr::Facets::FacetItem.new(:value => 'value', :hits => 15) 107 | 108 | item.hits.should == 15 109 | item.value.should == 'value' 110 | item.should be_a_kind_of(OpenStruct) 111 | end 112 | 113 | it "should provide a label accessor" do 114 | item = Ansr::Facets::FacetItem.new('value', :hits => 15) 115 | item.label.should == 'value' 116 | end 117 | 118 | it "should use a provided label" do 119 | item = Ansr::Facets::FacetItem.new('value', 15, :label => 'custom label') 120 | item.label.should == 'custom label' 121 | 122 | end 123 | 124 | end 125 | 126 | it 'should return the correct value when calling facet_by_field_name' do 127 | r = create_response 128 | facet = r.facet_by_field_name('cat') 129 | facet.name.should == 'cat' 130 | end 131 | 132 | it 'should provide the responseHeader params' do 133 | raw_response = eval(mock_query_response) 134 | raw_response['responseHeader']['params']['test'] = :test 135 | r = Ansr::Blacklight::Solr::Response.new(raw_response, raw_response['params']) 136 | r.params['test'].should == :test 137 | end 138 | 139 | it 'should provide the solr-returned params and "rows" should be 11' do 140 | raw_response = eval(mock_query_response) 141 | r = Ansr::Blacklight::Solr::Response.new(raw_response, {}) 142 | r.params[:rows].to_s.should == '11' 143 | end 144 | 145 | it 'should provide the ruby request params if responseHeader["params"] does not exist' do 146 | raw_response = eval(mock_query_response) 147 | raw_response.delete 'responseHeader' 148 | r = Ansr::Blacklight::Solr::Response.new(raw_response, :rows => 999) 149 | r.params[:rows].to_s.should == '999' 150 | end 151 | 152 | it 'should provide spelling suggestions for regular spellcheck results' do 153 | raw_response = eval(mock_response_with_spellcheck) 154 | r = Ansr::Blacklight::Solr::Response.new(raw_response, {}) 155 | r.spelling.words.should include("dell") 156 | r.spelling.words.should include("ultrasharp") 157 | end 158 | 159 | it 'should provide spelling suggestions for extended spellcheck results' do 160 | raw_response = eval(mock_response_with_spellcheck_extended) 161 | r = Ansr::Blacklight::Solr::Response.new(raw_response, {}) 162 | r.spelling.words.should include("dell") 163 | r.spelling.words.should include("ultrasharp") 164 | end 165 | 166 | it 'should provide no spelling suggestions when extended results and suggestion frequency is the same as original query frequency' do 167 | raw_response = eval(mock_response_with_spellcheck_same_frequency) 168 | r = Ansr::Blacklight::Solr::Response.new(raw_response, {}) 169 | r.spelling.words.should == [] 170 | end 171 | 172 | it 'should provide spelling suggestions for a regular spellcheck results with a collation' do 173 | raw_response = eval(mock_response_with_spellcheck_collation) 174 | r = Ansr::Blacklight::Solr::Response.new(raw_response, {}) 175 | r.spelling.words.should include("dell") 176 | r.spelling.words.should include("ultrasharp") 177 | end 178 | 179 | it 'should provide spelling suggestion collation' do 180 | raw_response = eval(mock_response_with_spellcheck_collation) 181 | r = Ansr::Blacklight::Solr::Response.new(raw_response, {}) 182 | r.spelling.collation.should == 'dell ultrasharp' 183 | end 184 | 185 | it "should provide MoreLikeThis suggestions" do 186 | raw_response = eval(mock_response_with_more_like_this) 187 | r = Ansr::Blacklight::Solr::Response.new(raw_response, {}) 188 | r.more_like(double(:id => '79930185')).should have(2).items 189 | end 190 | 191 | it "should be empty when the response has no results" do 192 | r = Ansr::Blacklight::Solr::Response.new({}, {}) 193 | r.stub(:total => 0) 194 | expect(r).to be_empty 195 | end 196 | 197 | def mock_query_response 198 | %({'responseHeader'=>{'status'=>0,'QTime'=>5,'params'=>{'facet.limit'=>'10','wt'=>'ruby','rows'=>'11','facet'=>'true','facet.field'=>['cat','manu'],'echoParams'=>'EXPLICIT','q'=>'*:*','facet.sort'=>'true'}},'response'=>{'numFound'=>26,'start'=>0,'docs'=>[{'id'=>'SP2514N','inStock'=>true,'manu'=>'Samsung Electronics Co. Ltd.','name'=>'Samsung SpinPoint P120 SP2514N - hard drive - 250 GB - ATA-133','popularity'=>6,'price'=>92.0,'sku'=>'SP2514N','timestamp'=>'2009-03-20T14:42:49.795Z','cat'=>['electronics','hard drive'],'spell'=>['Samsung SpinPoint P120 SP2514N - hard drive - 250 GB - ATA-133'],'features'=>['7200RPM, 8MB cache, IDE Ultra ATA-133','NoiseGuard, SilentSeek technology, Fluid Dynamic Bearing (FDB) motor']},{'id'=>'6H500F0','inStock'=>true,'manu'=>'Maxtor Corp.','name'=>'Maxtor DiamondMax 11 - hard drive - 500 GB - SATA-300','popularity'=>6,'price'=>350.0,'sku'=>'6H500F0','timestamp'=>'2009-03-20T14:42:49.877Z','cat'=>['electronics','hard drive'],'spell'=>['Maxtor DiamondMax 11 - hard drive - 500 GB - SATA-300'],'features'=>['SATA 3.0Gb/s, NCQ','8.5ms seek','16MB cache']},{'id'=>'F8V7067-APL-KIT','inStock'=>false,'manu'=>'Belkin','name'=>'Belkin Mobile Power Cord for iPod w/ Dock','popularity'=>1,'price'=>19.95,'sku'=>'F8V7067-APL-KIT','timestamp'=>'2009-03-20T14:42:49.937Z','weight'=>4.0,'cat'=>['electronics','connector'],'spell'=>['Belkin Mobile Power Cord for iPod w/ Dock'],'features'=>['car power adapter, white']},{'id'=>'IW-02','inStock'=>false,'manu'=>'Belkin','name'=>'iPod & iPod Mini USB 2.0 Cable','popularity'=>1,'price'=>11.5,'sku'=>'IW-02','timestamp'=>'2009-03-20T14:42:49.944Z','weight'=>2.0,'cat'=>['electronics','connector'],'spell'=>['iPod & iPod Mini USB 2.0 Cable'],'features'=>['car power adapter for iPod, white']},{'id'=>'MA147LL/A','inStock'=>true,'includes'=>'earbud headphones, USB cable','manu'=>'Apple Computer Inc.','name'=>'Apple 60 GB iPod with Video Playback Black','popularity'=>10,'price'=>399.0,'sku'=>'MA147LL/A','timestamp'=>'2009-03-20T14:42:49.962Z','weight'=>5.5,'cat'=>['electronics','music'],'spell'=>['Apple 60 GB iPod with Video Playback Black'],'features'=>['iTunes, Podcasts, Audiobooks','Stores up to 15,000 songs, 25,000 photos, or 150 hours of video','2.5-inch, 320x240 color TFT LCD display with LED backlight','Up to 20 hours of battery life','Plays AAC, MP3, WAV, AIFF, Audible, Apple Lossless, H.264 video','Notes, Calendar, Phone book, Hold button, Date display, Photo wallet, Built-in games, JPEG photo playback, Upgradeable firmware, USB 2.0 compatibility, Playback speed control, Rechargeable capability, Battery level indication']},{'id'=>'TWINX2048-3200PRO','inStock'=>true,'manu'=>'Corsair Microsystems Inc.','name'=>'CORSAIR XMS 2GB (2 x 1GB) 184-Pin DDR SDRAM Unbuffered DDR 400 (PC 3200) Dual Channel Kit System Memory - Retail','popularity'=>5,'price'=>185.0,'sku'=>'TWINX2048-3200PRO','timestamp'=>'2009-03-20T14:42:49.99Z','cat'=>['electronics','memory'],'spell'=>['CORSAIR XMS 2GB (2 x 1GB) 184-Pin DDR SDRAM Unbuffered DDR 400 (PC 3200) Dual Channel Kit System Memory - Retail'],'features'=>['CAS latency 2, 2-3-3-6 timing, 2.75v, unbuffered, heat-spreader']},{'id'=>'VS1GB400C3','inStock'=>true,'manu'=>'Corsair Microsystems Inc.','name'=>'CORSAIR ValueSelect 1GB 184-Pin DDR SDRAM Unbuffered DDR 400 (PC 3200) System Memory - Retail','popularity'=>7,'price'=>74.99,'sku'=>'VS1GB400C3','timestamp'=>'2009-03-20T14:42:50Z','cat'=>['electronics','memory'],'spell'=>['CORSAIR ValueSelect 1GB 184-Pin DDR SDRAM Unbuffered DDR 400 (PC 3200) System Memory - Retail']},{'id'=>'VDBDB1A16','inStock'=>true,'manu'=>'A-DATA Technology Inc.','name'=>'A-DATA V-Series 1GB 184-Pin DDR SDRAM Unbuffered DDR 400 (PC 3200) System Memory - OEM','popularity'=>5,'sku'=>'VDBDB1A16','timestamp'=>'2009-03-20T14:42:50.004Z','cat'=>['electronics','memory'],'spell'=>['A-DATA V-Series 1GB 184-Pin DDR SDRAM Unbuffered DDR 400 (PC 3200) System Memory - OEM'],'features'=>['CAS latency 3, 2.7v']},{'id'=>'3007WFP','inStock'=>true,'includes'=>'USB cable','manu'=>'Dell, Inc.','name'=>'Dell Widescreen UltraSharp 3007WFP','popularity'=>6,'price'=>2199.0,'sku'=>'3007WFP','timestamp'=>'2009-03-20T14:42:50.017Z','weight'=>401.6,'cat'=>['electronics','monitor'],'spell'=>['Dell Widescreen UltraSharp 3007WFP'],'features'=>['30" TFT active matrix LCD, 2560 x 1600, .25mm dot pitch, 700:1 contrast']},{'id'=>'VA902B','inStock'=>true,'manu'=>'ViewSonic Corp.','name'=>'ViewSonic VA902B - flat panel display - TFT - 19"','popularity'=>6,'price'=>279.95,'sku'=>'VA902B','timestamp'=>'2009-03-20T14:42:50.034Z','weight'=>190.4,'cat'=>['electronics','monitor'],'spell'=>['ViewSonic VA902B - flat panel display - TFT - 19"'],'features'=>['19" TFT active matrix LCD, 8ms response time, 1280 x 1024 native resolution']},{'id'=>'0579B002','inStock'=>true,'manu'=>'Canon Inc.','name'=>'Canon PIXMA MP500 All-In-One Photo Printer','popularity'=>6,'price'=>179.99,'sku'=>'0579B002','timestamp'=>'2009-03-20T14:42:50.062Z','weight'=>352.0,'cat'=>['electronics','multifunction printer','printer','scanner','copier'],'spell'=>['Canon PIXMA MP500 All-In-One Photo Printer'],'features'=>['Multifunction ink-jet color photo printer','Flatbed scanner, optical scan resolution of 1,200 x 2,400 dpi','2.5" color LCD preview screen','Duplex Copying','Printing speed up to 29ppm black, 19ppm color','Hi-Speed USB','memory card: CompactFlash, Micro Drive, SmartMedia, Memory Stick, Memory Stick Pro, SD Card, and MultiMediaCard']}]},'facet_counts'=>{'facet_queries'=>{},'facet_fields'=>{'cat'=>['electronics',14,'memory',3,'card',2,'connector',2,'drive',2,'graphics',2,'hard',2,'monitor',2,'search',2,'software',2],'manu'=>['inc',8,'apach',2,'belkin',2,'canon',2,'comput',2,'corp',2,'corsair',2,'foundat',2,'microsystem',2,'softwar',2]},'facet_dates'=>{}}}) 199 | end 200 | 201 | # These spellcheck responses are all Solr 1.4 responses 202 | def mock_response_with_spellcheck 203 | %|{'responseHeader'=>{'status'=>0,'QTime'=>9,'params'=>{'spellcheck'=>'true','spellcheck.collate'=>'true','wt'=>'ruby','q'=>'hell ultrashar'}},'response'=>{'numFound'=>0,'start'=>0,'docs'=>[]},'spellcheck'=>{'suggestions'=>['hell',{'numFound'=>1,'startOffset'=>0,'endOffset'=>4,'suggestion'=>['dell']},'ultrashar',{'numFound'=>1,'startOffset'=>5,'endOffset'=>14,'suggestion'=>['ultrasharp']},'collation','dell ultrasharp']}}| 204 | end 205 | 206 | def mock_response_with_spellcheck_extended 207 | %|{'responseHeader'=>{'status'=>0,'QTime'=>8,'params'=>{'spellcheck'=>'true','spellcheck.collate'=>'true','wt'=>'ruby','spellcheck.extendedResults'=>'true','q'=>'hell ultrashar'}},'response'=>{'numFound'=>0,'start'=>0,'docs'=>[]},'spellcheck'=>{'suggestions'=>['hell',{'numFound'=>1,'startOffset'=>0,'endOffset'=>4,'origFreq'=>0,'suggestion'=>[{'word'=>'dell','freq'=>1}]},'ultrashar',{'numFound'=>1,'startOffset'=>5,'endOffset'=>14,'origFreq'=>0,'suggestion'=>[{'word'=>'ultrasharp','freq'=>1}]},'correctlySpelled',false,'collation','dell ultrasharp']}}| 208 | end 209 | 210 | def mock_response_with_spellcheck_same_frequency 211 | %|{'responseHeader'=>{'status'=>0,'QTime'=>8,'params'=>{'spellcheck'=>'true','spellcheck.collate'=>'true','wt'=>'ruby','spellcheck.extendedResults'=>'true','q'=>'hell ultrashar'}},'response'=>{'numFound'=>0,'start'=>0,'docs'=>[]},'spellcheck'=>{'suggestions'=>['hell',{'numFound'=>1,'startOffset'=>0,'endOffset'=>4,'origFreq'=>1,'suggestion'=>[{'word'=>'dell','freq'=>1}]},'ultrashard',{'numFound'=>1,'startOffset'=>5,'endOffset'=>14,'origFreq'=>1,'suggestion'=>[{'word'=>'ultrasharp','freq'=>1}]},'correctlySpelled',false,'collation','dell ultrasharp']}}| 212 | end 213 | 214 | # it can be the case that extended results are off and collation is on 215 | def mock_response_with_spellcheck_collation 216 | %|{'responseHeader'=>{'status'=>0,'QTime'=>3,'params'=>{'spellspellcheck.build'=>'true','spellcheck'=>'true','q'=>'hell','spellcheck.q'=>'hell ultrashar','wt'=>'ruby','spellcheck.collate'=>'true'}},'response'=>{'numFound'=>0,'start'=>0,'docs'=>[]},'spellcheck'=>{'suggestions'=>['hell',{'numFound'=>1,'startOffset'=>0,'endOffset'=>4,'suggestion'=>['dell']},'ultrashar',{'numFound'=>1,'startOffset'=>5,'endOffset'=>14,'suggestion'=>['ultrasharp']},'collation','dell ultrasharp']}}| 217 | end 218 | 219 | def mock_response_with_more_like_this 220 | %({'responseHeader'=>{'status'=>0,'QTime'=>8,'params'=>{'facet'=>'false','mlt.mindf'=>'1','mlt.fl'=>'subject_t','fl'=>'id','mlt.count'=>'3','mlt.mintf'=>'0','mlt'=>'true','q.alt'=>'*:*','qt'=>'search','wt'=>'ruby'}},'response'=>{'numFound'=>30,'start'=>0,'docs'=>[{'id'=>'00282214'},{'id'=>'00282371'},{'id'=>'00313831'},{'id'=>'00314247'},{'id'=>'43037890'},{'id'=>'53029833'},{'id'=>'77826928'},{'id'=>'78908283'},{'id'=>'79930185'},{'id'=>'85910001'}]},'moreLikeThis'=>{'00282214'=>{'numFound'=>0,'start'=>0,'docs'=>[]},'00282371'=>{'numFound'=>0,'start'=>0,'docs'=>[]},'00313831'=>{'numFound'=>1,'start'=>0,'docs'=>[{'id'=>'96933325'}]},'00314247'=>{'numFound'=>3,'start'=>0,'docs'=>[{'id'=>'2008543486'},{'id'=>'96933325'},{'id'=>'2009373513'}]},'43037890'=>{'numFound'=>0,'start'=>0,'docs'=>[]},'53029833'=>{'numFound'=>0,'start'=>0,'docs'=>[]},'77826928'=>{'numFound'=>1,'start'=>0,'docs'=>[{'id'=>'94120425'}]},'78908283'=>{'numFound'=>0,'start'=>0,'docs'=>[]},'79930185'=>{'numFound'=>2,'start'=>0,'docs'=>[{'id'=>'94120425'},{'id'=>'2007020969'}]},'85910001'=>{'numFound'=>0,'start'=>0,'docs'=>[]}}}) 221 | end 222 | end 223 | -------------------------------------------------------------------------------- /ansr_blacklight/spec/lib/relation/faceting_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | require 'spec_helper' 3 | 4 | # check the methods that do solr requests. Note that we are not testing if 5 | # solr gives "correct" responses, as that's out of scope (it's a part of 6 | # testing the solr code itself). We *are* testing if blacklight code sends 7 | # queries to solr such that it gets appropriate results. When a user does a search, 8 | # do we get data back from solr (i.e. did we properly configure blacklight code 9 | # to talk with solr and get results)? when we do a document request, does 10 | # blacklight code get a single document returned?) 11 | # 12 | describe Ansr::Blacklight do 13 | 14 | class TestTable < Ansr::Arel::BigTable 15 | def name 16 | 'outside' 17 | end 18 | 19 | def [](val) 20 | key = (Arel::Attributes::Attribute === val) ? val.name.to_sym : val.to_sym 21 | key == :configured ? Ansr::Arel::ConfiguredField.new(nil, key, {:property => 'test', :escape => 'tes"t'}) : super(val) 22 | end 23 | 24 | def fields 25 | [:id] 26 | end 27 | 28 | end 29 | 30 | before do 31 | Object.const_set('FacetModel', Class.new(TestModel)) 32 | FacetModel.solr = stub_solr 33 | FacetModel.configure do |config| 34 | config[:table_class] = TestTable 35 | end 36 | end 37 | 38 | after do 39 | Object.send(:remove_const, :FacetModel) 40 | end 41 | 42 | subject { 43 | Ansr::Blacklight::Arel::Visitors::ToNoSql.new(TestTable.new(FacetModel)).query_builder 44 | } 45 | 46 | let(:blacklight_solr) { subject.solr } 47 | let(:copy_of_catalog_config) { ::CatalogController.blacklight_config.deep_copy } 48 | let(:blacklight_config) { copy_of_catalog_config } 49 | 50 | before(:each) do 51 | @all_docs_query = '' 52 | @no_docs_query = 'zzzzzzzzzzzz' 53 | @single_word_query = 'include' 54 | @mult_word_query = 'tibetan history' 55 | # f[format][]=Book&f[language_facet][]=English 56 | @single_facet = {:format=>'Book'} 57 | @multi_facets = {:format=>'Book', :language_facet=>'Tibetan'} 58 | @bad_facet = {:format=>'666'} 59 | @subject_search_params = {:commit=>"search", :search_field=>"subject", :action=>"index", :"controller"=>"catalog", :"rows"=>"10", :"q"=>"wome"} 60 | @abc_resp = {'response' =>{ 'docs' => [id: 'abc']}}.to_json 61 | @no_docs_resp = {'response' =>{'docs' => []}}.to_json 62 | end 63 | 64 | def subject_search_params(rel) 65 | rel.limit(10).where!(q: 'wome') 66 | end 67 | 68 | describe "http_method configuration", :integration => true do 69 | subject { FacetModel } 70 | 71 | it "should send a post request to solr and get a response back" do 72 | subject.method = :post 73 | rel = subject.where(:q => @all_docs_query) 74 | subject.solr= stub_solr(JSON.parse(@abc_resp).inspect) 75 | expect(rel.length).to be >= 1 76 | end 77 | end 78 | 79 | # SPECS for actual search parameter generation 80 | describe "solr_search_params" do 81 | 82 | subject { FacetModel } 83 | 84 | it "allows customization of solr_search_params_logic" do 85 | # Normally you'd include a new module into (eg) your CatalogController 86 | # but a sub-class defininig it directly is simpler for test. 87 | subject.stub(:add_foo_to_solr_params) do |rel| 88 | rel.wt!("TESTING") 89 | end 90 | 91 | subject.solr_search_params_logic += [:add_foo_to_solr_params] 92 | 93 | expect(subject.build_default_scope.wt_value).to eql("TESTING") 94 | end 95 | 96 | describe "for an empty string search" do 97 | subject { Ansr::Blacklight::Arel::Visitors::ToNoSql.new(FacetModel.table) } 98 | it "should return empty string q in solr parameters" do 99 | rel = TestModel.where(q: "") 100 | solr_params = subject.accept(rel.build_arel.ast) 101 | expect(solr_params[:q]).to eq "" 102 | expect(solr_params["spellcheck.q"]).to eq "" 103 | end 104 | end 105 | 106 | describe "for request params also passed in as argument" do 107 | subject { Ansr::Blacklight::Arel::Visitors::ToNoSql.new(FacetModel.table) } 108 | it "should only have one value for the key 'q' regardless if a symbol or string" do 109 | rel = FacetModel.where(q: "some query").where('q' => 'another value') 110 | solr_params = subject.accept(rel.build_arel.ast) 111 | expect(solr_params[:q]).to eq 'another value' 112 | expect(solr_params['q']).to eq 'another value' 113 | end 114 | end 115 | 116 | 117 | describe "for one facet, no query" do 118 | subject { Ansr::Blacklight::Arel::Visitors::ToNoSql.new(FacetModel.table) } 119 | it "should have proper solr parameters" do 120 | rel = FacetModel.filter(@single_facet) 121 | solr_params = subject.accept(rel.build_arel.ast) 122 | 123 | expect(solr_params[:q]).to be_blank 124 | expect(solr_params["spellcheck.q"]).to be_blank 125 | 126 | @single_facet.each_value do |value| 127 | expect(solr_params[:fq]).to include("{!raw f=#{@single_facet.keys[0]}}#{value}") 128 | end 129 | end 130 | end 131 | 132 | describe "with Multi Facets, No Query" do 133 | subject { Ansr::Blacklight::Arel::Visitors::ToNoSql.new(FacetModel.table) } 134 | it 'should have fq set properly' do 135 | rel = FacetModel.filter(@multi_facets) 136 | solr_params = subject.accept(rel.build_arel.ast) 137 | 138 | @multi_facets.each_pair do |facet_field, value_list| 139 | value_list ||= [] 140 | value_list = [value_list] unless value_list.respond_to? :each 141 | value_list.each do |value| 142 | expect(solr_params[:fq]).to include("{!raw f=#{facet_field}}#{value}" ) 143 | end 144 | end 145 | 146 | end 147 | end 148 | 149 | describe "with Multi Facets, Multi Word Query" do 150 | subject { Ansr::Blacklight::Arel::Visitors::ToNoSql.new(FacetModel.table) } 151 | it 'should have fq and q set properly' do 152 | rel = FacetModel.filter(@multi_facets).where(q: @mult_word_query) 153 | solr_params = subject.accept(rel.build_arel.ast) 154 | 155 | @multi_facets.each_pair do |facet_field, value_list| 156 | value_list ||= [] 157 | value_list = [value_list] unless value_list.respond_to? :each 158 | value_list.each do |value| 159 | expect(solr_params[:fq]).to include("{!raw f=#{facet_field}}#{value}" ) 160 | end 161 | end 162 | expect(solr_params[:q]).to eq @mult_word_query 163 | end 164 | end 165 | 166 | describe "filter_value_to_fq_string" do 167 | let(:table) { FacetModel.table } 168 | subject { Ansr::Blacklight::Arel::Visitors::QueryBuilder.new(table) } 169 | it "should use the raw handler for strings" do 170 | expect(subject.send(:filter_value_to_fq_string, "facet_name", "my value")).to eq "{!raw f=facet_name}my value" 171 | end 172 | 173 | it "should pass booleans through" do 174 | expect(subject.send(:filter_value_to_fq_string, "facet_name", true)).to eq "facet_name:true" 175 | end 176 | 177 | it "should pass boolean-like strings through" do 178 | expect(subject.send(:filter_value_to_fq_string, "facet_name", "true")).to eq "facet_name:true" 179 | end 180 | 181 | it "should pass integers through" do 182 | expect(subject.send(:filter_value_to_fq_string, "facet_name", 1)).to eq "facet_name:1" 183 | end 184 | 185 | it "should pass integer-like strings through" do 186 | expect(subject.send(:filter_value_to_fq_string, "facet_name", "1")).to eq "facet_name:1" 187 | end 188 | 189 | it "should pass floats through" do 190 | expect(subject.send(:filter_value_to_fq_string, "facet_name", 1.11)).to eq "facet_name:1\\.11" 191 | end 192 | 193 | it "should pass floats through" do 194 | expect(subject.send(:filter_value_to_fq_string, "facet_name", "1.11")).to eq "facet_name:1\\.11" 195 | end 196 | 197 | it "should pass date-type fields through" do 198 | table.configure_fields do |config| 199 | config[:facet_name] = {date: true} 200 | end 201 | 202 | expect(subject.send(:filter_value_to_fq_string, "facet_name", "2012-01-01")).to eq "facet_name:2012\\-01\\-01" 203 | end 204 | 205 | it "should handle range requests" do 206 | expect(subject.send(:filter_value_to_fq_string, "facet_name", 1..5)).to eq "facet_name:[1 TO 5]" 207 | end 208 | 209 | it "should add tag local parameters" do 210 | table.configure_fields do |config| 211 | config[:facet_name] = {local: {tag: 'asdf'}} 212 | end 213 | 214 | expect(subject.send(:filter_value_to_fq_string, "facet_name", true)).to eq "{!tag=asdf}facet_name:true" 215 | expect(subject.send(:filter_value_to_fq_string, "facet_name", "my value")).to eq "{!raw f=facet_name tag=asdf}my value" 216 | end 217 | end 218 | 219 | describe "solr parameters for a field search from config (subject)" do 220 | let(:table) { FacetModel.table } 221 | subject { Ansr::Blacklight::Arel::Visitors::ToNoSql.new(table) } 222 | let(:rel) { FacetModel.build_default_scope.spawn } 223 | let(:blacklight_config) { copy_of_catalog_config } 224 | before do 225 | #@subject_search_params = {:commit=>"search", :search_field=>"subject", :action=>"index", :"controller"=>"catalog", :"rows"=>"10", :"q"=>"wome"} 226 | table.configure_fields do |config| 227 | hash = (config[:subject] ||= {local: {}, query: {}}) 228 | hash[:query][:'spellcheck.dictionary'] = 'subject' 229 | hash[:query][:qt] = 'search' 230 | hash[:local][:pf] = '$subject_pf' 231 | hash[:local][:qf] = '$subject_qf' 232 | end 233 | rel.where!(subject: 'wome') 234 | end 235 | after do 236 | table.configure_fields { |config| config.clear } 237 | end 238 | it "should look up qt from field definition" do 239 | solr_params = subject.accept(rel.build_arel.ast) 240 | expect(solr_params[:qt]).to eq "search" 241 | end 242 | it "should not include weird keys not in field definition" do 243 | solr_params = subject.accept(rel.build_arel.ast) 244 | solr_params.to_hash.tap do |h| 245 | expect(h[:phrase_filters]).to be_nil 246 | expect(h[:fq]).to be_nil 247 | expect(h[:commit]).to be_nil 248 | expect(h[:action]).to be_nil 249 | expect(h[:controller]).to be_nil 250 | end 251 | end 252 | it "should include proper 'q', possibly with LocalParams" do 253 | solr_params = subject.accept(rel.build_arel.ast) 254 | expect(solr_params[:q]).to match(/(\{[^}]+\})?wome/) 255 | end 256 | it "should include proper 'q' when LocalParams are used" do 257 | solr_params = subject.accept(rel.build_arel.ast) 258 | if solr_params[:q] =~ /\{[^}]+\}/ 259 | expect(solr_params[:q]).to match(/\{[^}]+\}wome/) 260 | end 261 | end 262 | it "should include spellcheck.q, without LocalParams" do 263 | solr_params = subject.accept(rel.build_arel.ast) 264 | expect(solr_params["spellcheck.q"]).to eq "wome" 265 | end 266 | 267 | it "should include spellcheck.dictionary from field def solr_parameters" do 268 | solr_params = subject.accept(rel.build_arel.ast) 269 | expect(solr_params[:"spellcheck.dictionary"]).to eq "subject" 270 | end 271 | it "should add on :solr_local_parameters using Solr LocalParams style" do 272 | solr_params = subject.accept(rel.build_arel.ast) 273 | 274 | #q == "{!pf=$subject_pf $qf=subject_qf} wome", make sure 275 | #the LocalParams are really there 276 | solr_params[:q] =~ /^\{!([^}]+)\}/ 277 | key_value_pairs = $1.split(" ") 278 | expect(key_value_pairs).to include("pf=$subject_pf") 279 | expect(key_value_pairs).to include("qf=$subject_qf") 280 | end 281 | end 282 | 283 | describe "overriding of qt parameter" do 284 | let(:table) { FacetModel.table } 285 | subject { Ansr::Blacklight::Arel::Visitors::ToNoSql.new(table) } 286 | let(:rel) { FacetModel.build_default_scope.as('overriden') } 287 | let(:blacklight_config) { copy_of_catalog_config } 288 | it "should return the correct overriden parameter" do 289 | solr_params = subject.accept(rel.build_arel.ast) 290 | expect(solr_params[:qt]).to eq "overriden" 291 | end 292 | end 293 | 294 | describe "converts a String fq into an Array" do 295 | let(:table) { FacetModel.table } 296 | subject { Ansr::Blacklight::Arel::Visitors::ToNoSql.new(table) } 297 | let(:rel) { FacetModel.build_default_scope } 298 | it "should return the correct overriden parameter" do 299 | solr_params = subject.accept(rel.facet('a string').build_arel.ast) 300 | expect(solr_params[:fq]).to be_a_kind_of Array 301 | end 302 | end 303 | 304 | describe "#add_solr_fields_to_query" do 305 | let(:table) { FacetModel.table } 306 | subject { Ansr::Blacklight::Arel::Visitors::ToNoSql.new(table) } 307 | let(:rel) { FacetModel.build_default_scope} 308 | before do 309 | FacetModel.table.configure_fields do |config| 310 | config[:an_index_field] = {select: { 'hl.alternativeField' => 'field_x'}} 311 | config[:a_show_field] = {select: { 'hl.alternativeField' => 'field_y'}} 312 | end 313 | end 314 | after do 315 | FacetModel.table.configure_fields.clear 316 | end 317 | 318 | it "should add any extra solr parameters from index and show fields" do 319 | solr_params = subject.accept(rel.select(:an_index_field, :a_show_field).build_arel.ast) 320 | expect(solr_params[:'f.an_index_field.hl.alternativeField']).to eq "field_x" 321 | expect(solr_params[:'f.a_show_field.hl.alternativeField']).to eq "field_y" 322 | end 323 | end 324 | 325 | describe "#add_facetting_to_solr" do 326 | 327 | let(:table) { FacetModel.table } 328 | subject { Ansr::Blacklight::Arel::Visitors::ToNoSql.new(table) } 329 | let(:rel) { 330 | FacetModel.build_default_scope.spawn 331 | .facet('test_field', :sort => 'count') 332 | .facet('some-query', :query => {'x' => {:fq => 'some:query'}} , :ex => 'xyz') 333 | .facet('some-pivot', :pivot => ['a','b'], :ex => 'xyz') 334 | .facet('some-field', mincount: 15 ) 335 | } 336 | 337 | it "should add sort parameters" do 338 | solr_params = subject.accept(rel.build_arel.ast) 339 | expect(solr_params[:facet]).to be_true 340 | 341 | expect(solr_params[:'facet.field']).to include(:'test_field') 342 | expect(solr_params[:'f.test_field.facet.sort']).to eq 'count' 343 | end 344 | 345 | it "should add facet exclusions" do 346 | solr_params = subject.accept(rel.build_arel.ast) 347 | expect(solr_params[:'facet.query']).to include('{!ex=xyz}some:query') 348 | expect(solr_params[:'facet.pivot']).to include('{!ex=xyz}a,b') 349 | end 350 | 351 | it "should add any additional solr_params" do 352 | solr_params = subject.accept(rel.build_arel.ast) 353 | expect(solr_params[:'f.some-field.facet.mincount']).to eq '15' 354 | end 355 | end 356 | 357 | describe "for :solr_local_parameters config" do 358 | let(:table) { FacetModel.table } 359 | subject { Ansr::Blacklight::Arel::Visitors::ToNoSql.new(table) } 360 | let(:rel) { FacetModel.build_default_scope.spawn } 361 | let(:result) { subject.accept rel.where(:custom_author_key => "query").build_arel.ast } 362 | before do 363 | #@subject_search_params = {:commit=>"search", :search_field=>"subject", :action=>"index", :"controller"=>"catalog", :"rows"=>"10", :"q"=>"wome"} 364 | table.configure_fields do |config| 365 | hash = (config[:custom_author_key] ||= {local: {}, query: {}, select: {}}) 366 | hash[:query][:'spellcheck.dictionary'] = 'subject' 367 | hash[:query][:qt] = 'author_qt' 368 | hash[:query][:qf] = 'someField^1000' 369 | hash[:query][:ps] = '2' 370 | hash[:local][:pf] = "you'll have \" to escape this" 371 | hash[:local][:pf2] = '$pf2_do_not_escape_or_quote' 372 | hash[:local][:qf] = '$author_qf' 373 | end 374 | rel.where!(subject: 'wome') 375 | end 376 | after do 377 | table.configure_fields { |config| config.clear } 378 | end 379 | 380 | it "should pass through ordinary params" do 381 | expect(result[:qt]).to eq "author_qt" 382 | expect(result[:ps]).to eq "2" 383 | expect(result[:qf]).to eq "someField^1000" 384 | end 385 | 386 | it "should include include local params with escaping" do 387 | expect(result[:q]).to include('qf=$author_qf') 388 | expect(result[:q]).to include('pf=\'you\\\'ll have \\" to escape this\'') 389 | expect(result[:q]).to include('pf2=$pf2_do_not_escape_or_quote') 390 | end 391 | end 392 | end 393 | 394 | describe "solr_facet_params" do 395 | before do 396 | @facet_field = 'format' 397 | @generated_solr_facet_params = subject.solr_facet_params(@facet_field) 398 | 399 | @sort_key = Blacklight::Solr::FacetPaginator.request_keys[:sort] 400 | @page_key = Blacklight::Solr::FacetPaginator.request_keys[:page] 401 | end 402 | 403 | let(:blacklight_config) do 404 | Blacklight::Configuration.new do |config| 405 | config.add_facet_fields_to_solr_request! 406 | config.add_facet_field 'format' 407 | config.add_facet_field 'format_ordered', :sort => :count 408 | config.add_facet_field 'format_limited', :limit => 5 409 | 410 | end 411 | end 412 | 413 | pending 'sets rows to 0' do 414 | expect(@generated_solr_facet_params[:rows]).to eq 0 415 | end 416 | pending 'sets facets requested to facet_field argument' do 417 | expect(@generated_solr_facet_params["facet.field".to_sym]).to eq @facet_field 418 | end 419 | pending 'defaults offset to 0' do 420 | expect(@generated_solr_facet_params[:"f.#{@facet_field}.facet.offset"]).to eq 0 421 | end 422 | pending 'uses offset manually set, and converts it to an integer' do 423 | solr_params = subject.solr_facet_params(@facet_field, @page_key => 2) 424 | expect(solr_params[:"f.#{@facet_field}.facet.offset"]).to eq 20 425 | end 426 | pending 'defaults limit to 20' do 427 | solr_params = subject.solr_facet_params(@facet_field) 428 | expect(solr_params[:"f.#{@facet_field}.facet.limit"]).to eq 21 429 | end 430 | 431 | describe 'if facet_list_limit is defined in controller' do 432 | before do 433 | subject.stub facet_list_limit: 1000 434 | end 435 | pending 'uses controller method for limit' do 436 | solr_params = subject.solr_facet_params(@facet_field) 437 | expect(solr_params[:"f.#{@facet_field}.facet.limit"]).to eq 1001 438 | end 439 | 440 | pending 'uses controller method for limit when a ordinary limit is set' do 441 | solr_params = subject.solr_facet_params(@facet_field) 442 | expect(solr_params[:"f.#{@facet_field}.facet.limit"]).to eq 1001 443 | end 444 | end 445 | 446 | pending 'uses the default sort' do 447 | solr_params = subject.solr_facet_params(@facet_field) 448 | expect(solr_params[:"f.#{@facet_field}.facet.sort"]).to be_blank 449 | end 450 | 451 | pending "uses the field-specific sort" do 452 | solr_params = subject.solr_facet_params('format_ordered') 453 | expect(solr_params[:"f.format_ordered.facet.sort"]).to eq :count 454 | end 455 | 456 | pending 'uses sort provided in the parameters' do 457 | solr_params = subject.solr_facet_params(@facet_field, @sort_key => "index") 458 | expect(solr_params[:"f.#{@facet_field}.facet.sort"]).to eq 'index' 459 | end 460 | pending "comes up with the same params as #solr_search_params to constrain context for facet list" do 461 | search_params = {:q => 'tibetan history', :f=> {:format=>'Book', :language_facet=>'Tibetan'}} 462 | solr_search_params = subject.solr_search_params( search_params ) 463 | solr_facet_params = subject.solr_facet_params('format', search_params) 464 | 465 | solr_search_params.each_pair do |key, value| 466 | # The specific params used for fetching the facet list we 467 | # don't care about. 468 | next if ['facets', "facet.field", 'rows', 'facet.limit', 'facet.offset', 'facet.sort'].include?(key) 469 | # Everything else should match 470 | expect(solr_facet_params[key]).to eq value 471 | end 472 | 473 | end 474 | end 475 | 476 | end --------------------------------------------------------------------------------