Created
March 25, 2020 12:36
-
-
Save jasper-lyons/d06e1d378d5ad66c27ef57a9531235cb to your computer and use it in GitHub Desktop.
A rough implementation of a ruby interface to generate solr queries using ruby code.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
module Search | |
@@searchable_attributes = [] | |
def searchable(&block) | |
block.call(@@searchable_attributes) | |
end | |
def searchable_attributes | |
@@searchable_attributes | |
end | |
def search(type: LuceneQuery, &block) | |
type.new(self).tap do |instance| | |
instance.instance_eval(&block) | |
end | |
end | |
module Parser | |
def self.included(base) | |
base.extend(ClassMethods) | |
end | |
module ClassMethods | |
@@operators = {} | |
def operator(symbol, &block) | |
@@operators[symbol] = block | |
end | |
def operators | |
@@operators | |
end | |
end | |
def operators | |
self.class.operators | |
end | |
def initialize(model) | |
@model = model | |
end | |
def parse(&block) | |
instance_eval(&block) | |
end | |
def method_missing(name, *args, **hargs) | |
if @model.searchable_attributes.include?(name) | |
Attribute.new(self, name) | |
else | |
ExpressionNode.new(self, name, *args, **hargs) | |
end | |
end | |
class ExpressionNode | |
def initialize(parser, operator, *params, **hparams) | |
@parser = parser | |
@operator = operator | |
@params = params | |
@hparams = hparams | |
end | |
def call(*args, **hargs) | |
ExpressionNode.new(@parser, :call, self, *args, **hargs) | |
end | |
def ==(other) | |
ExpressionNode.new(@parser, :equals, self, other) | |
end | |
def >(other) | |
ExpressionNode.new(@parser, :less_than, self, other) | |
end | |
def <(other) | |
ExpressionNode.new(@parser, :more_than, self, other) | |
end | |
def method_missing(operator, *args, **hargs) | |
ExpressionNode.new(@parser, operator, self, *args, **hargs) | |
end | |
def to_s | |
# and params that are also expression nodes might need wrapping! | |
if @parser.operators[@operator] | |
@parser.operators[@operator].call(*@params, **@hparams).to_s | |
elsif !@params.empty? || !@hparams.empty? | |
ExpressionNode.new(@parser, :call, @operator, *@params, **@hparams).to_s | |
else | |
@operator.to_s | |
end | |
end | |
alias to_str to_s | |
def to_hash | |
nil | |
end | |
def to_ary | |
nil | |
end | |
end | |
class Attribute < ExpressionNode | |
def initialize(parser, name) | |
@parser = parser | |
@name = name | |
end | |
def to_s | |
@name.to_s | |
end | |
alias to_str to_s | |
end | |
end | |
class StandardParser | |
include Parser | |
operator(:all) { "*:*" } | |
operator(:wrap) { |term| "(#{ term })" } | |
operator(:equals) { |a, b| "#{ a }:#{ b }" } | |
operator(:and) { |a, b| "#{ a } AND #{ b }" } | |
operator(:not) { |term| "!#{ term }" } | |
operator(:or) { |a, b| "#{ a } OR #{ b }" } | |
operator(:has_field) { |field| "#{field}:[* TO *]" } | |
operator(:between) { |a, b| "#{ a }:[#{ b.first } TO #{ b.last }]" } | |
operator(:more_than) { |a, b| "#{ a }:[#{ b } TO *]" } | |
operator(:less_than) { |a, b| "#{ a }:[* TO #{ b }]" } | |
operator(:boost) { |term, factor| "#{ term }^#{ factor }" } | |
operator(:constant_score) { |clause, score| "#{ clause }^=#{ score }" } | |
operator(:require) { |term| "+#{ term }" } | |
operator(:prohibit) { |term| "-#{ term }" } | |
operator(:filter) { |term| "filter(#{ term })" } | |
end | |
class FunctionParser | |
include Parser | |
operator(:call) { |function, *args, **hargs| "#{ function }(#{ args.join(',') })" } | |
FieldList = Struct.new(:fields) do | |
def to_s | |
fields.join(',') | |
end | |
end | |
end | |
class SortParser < FunctionParser | |
operator(:asc) { |term| "#{ term } asc" } | |
operator(:desc) { |term| "#{ term } desc" } | |
def parse(&block) | |
FieldList.new(super) | |
end | |
end | |
class FilterParser < StandardParser | |
end | |
class FieldListParser < FunctionParser | |
operator(:transform) do |factory, **hargs| | |
"[#{ [factory, *hargs.to_a.map { |pair| pair.join('=') }].join(' ') }]" | |
end | |
operator(:rename) { |a, b| "#{ b }:#{ a }" } | |
operator(:as) { |a, b| "#{ b }:#{ a }" } | |
def parse(&block) | |
FieldList.new(super) | |
end | |
end | |
class ExplainOtherParser < StandardParser | |
end | |
class FacetQueryParser < StandardParser | |
end | |
module QueryInterface | |
def self.included(base) | |
base.extend(ClassMethods) | |
end | |
module ClassMethods | |
def param(name, symbol, parser: nil, multiple: false, &body) | |
if multiple | |
define_method(:"has_#{name}?") do | |
@params.select(&:first).include?(symbol.to_sym) | |
end | |
end | |
return define_method(name, &body) if body | |
define_method(name) do |value=nil, &block| | |
return if multiple && send(:"has_#{name}?") | |
if value | |
@params.push([symbol, value]) | |
value | |
else | |
expression = parser.new(@model).parse(&block) | |
@params.push([symbol, expression]) | |
expression | |
end | |
end | |
end | |
end | |
def initialize(model) | |
@model = model | |
@params = [] | |
end | |
def params | |
@params | |
end | |
end | |
class TopLevelQuery | |
include QueryInterface | |
param('parser', :defType) | |
param('sort', :sort, parser: Search::SortParser) | |
param('start', :start) | |
param('rows', :rows) | |
param('with', :fq, parser: Search::FilterParser, multiple: true) | |
param('fields', :fl, parser: Search::FieldListParser) | |
param('debug', :debug, multiple: true) | |
param('explain', :explainOther, parser: Search::ExplainOtherParser) | |
param('finish_within', :timeAllowed) | |
param('terminate_early', :segmentTerminateEarly) | |
param('omit_header', :omit_header) | |
param('response_format', :wt) | |
param('cache', :cache) | |
param('log_params', :logParamsList) | |
param('echo_params', :echoParams) | |
def query_string | |
params.map { |param| param.join('=') }.join('&') | |
end | |
end | |
class LuceneQuery < TopLevelQuery | |
param('query', :q, parser: Search::StandardParser) | |
param('query_op', :'q.op') | |
param('default_searchable_field', :df) | |
param('split_on_whitespace', :sow) | |
param('facet', :facet, multiple: true) do |&block| | |
@params.push([:facet, true]) unless has_facet? | |
@params.push([:'facet.query', Search::FacetQueryParser.new(@model).parse(&block)]) | |
end | |
end | |
end | |
class Model | |
extend Search | |
searchable do |attributes| | |
attributes << :type | |
attributes << :status | |
attributes << :created_at | |
attributes << :completed_action_plan | |
attributes << :case_plan_approved | |
end | |
end | |
from = 'a' | |
to = 'b' | |
search = Model.search do | |
query { all } | |
with { type == Model } | |
with { status == 'open' } | |
with { created_at.between(from..to) } | |
facet { | |
completed_action_plan == true). | |
and(case_plan_approved == true) | |
} | |
sort { [score.asc, price.desc, div(popularity, price).desc] } | |
fields { [score, price, created_at.as(start_date), product(price, popularity), transform(explain, style: nl)] } | |
end | |
puts search.query_string |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment