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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions lib/parade_db.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,15 @@

module ParadeDB
class FacetQueryError < ArgumentError; end
class UnsupportedAdapterError < ArgumentError; end

module_function

def ensure_postgresql_adapter!(connection, context:)
adapter_name = connection.adapter_name.to_s
return if adapter_name.downcase.include?("postgres")

raise UnsupportedAdapterError,
"#{context} only supports PostgreSQL. Current adapter: #{adapter_name.inspect}"
end
end
25 changes: 10 additions & 15 deletions lib/parade_db/arel.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require "active_record"

require_relative "arel/nodes"
require_relative "arel/visitor"
require_relative "arel/builder"
Expand All @@ -8,26 +10,19 @@ module ParadeDB
module Arel
# Convenience helper to render any ParadeDB Arel node to SQL.
def self.to_sql(node, connection = nil)
Visitor.new(connection).accept(node)
conn = connection || ::ActiveRecord::Base.connection
ParadeDB.ensure_postgresql_adapter!(conn, context: "ParadeDB::Arel.to_sql")
Visitor.install!

collector = ::Arel::Collectors::SQLString.new
conn.visitor.accept(node, collector).value
end

# Helper to wrap raw SQL without quoting.
def self.sql(raw)
Nodes::SqlLiteral.new(raw)
::Arel.sql(raw)
end
end
end

# Extend Arel nodes to be compatible with ActiveRecord::Relation.where()
module Arel
module Predications
# Allow ParadeDB nodes to be used directly in where() clauses
end
end

# Make ParadeDB nodes respond to to_sql for ActiveRecord compatibility
ParadeDB::Arel::Nodes::Node.class_eval do
def to_sql(connection = nil)
ParadeDB::Arel.to_sql(self, connection)
end
end
ParadeDB::Arel::Visitor.install!
93 changes: 59 additions & 34 deletions lib/parade_db/arel/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,94 +13,119 @@ def [](column)
column_node(column)
end

# Binary operators
def match(column, *terms, boost: nil)
right = join_terms(terms)
build_predicate(Nodes::Match, column, right, boost: boost)
rhs = quoted_value(join_terms(terms))
rhs = Nodes::BoostCast.new(rhs, quoted_value(boost)) unless boost.nil?
infix("&&&", column_node(column), rhs)
end

def match_any(column, *terms)
right = join_terms(terms)
Nodes::MatchAny.new(column_node(column), value_node(right))
infix("|||", column_node(column), quoted_value(join_terms(terms)))
end

def full_text(column, expression)
rhs =
case expression
when Nodes::Node
expression
else
Nodes::SqlLiteral.new(expression)
end
Nodes::FullText.new(column_node(column), rhs)
rhs = expression.is_a?(::Arel::Nodes::Node) ? expression : ::Arel.sql(expression.to_s)
infix("@@@", column_node(column), rhs)
end

def phrase(column, text, slop: nil)
rhs = value_node(text)
rhs = Nodes::Slop.new(rhs, slop) if slop
Nodes::Phrase.new(column_node(column), rhs)
rhs = quoted_value(text)
rhs = Nodes::SlopCast.new(rhs, quoted_value(slop)) unless slop.nil?
infix("###", column_node(column), rhs)
end

def fuzzy(column, term, distance: 1, prefix: nil, boost: nil)
Nodes::Fuzzy.new(column_node(column), value_node(term), distance: distance, prefix: prefix, boost: boost)
rhs = Nodes::FuzzyCast.new(quoted_value(term), quoted_value(distance), prefix: prefix)
rhs = Nodes::BoostCast.new(rhs, quoted_value(boost)) unless boost.nil?
infix("===", column_node(column), rhs)
end

def term(column, term, boost: nil)
build_predicate(Nodes::Term, column, term, boost: boost)
rhs = quoted_value(term)
rhs = Nodes::BoostCast.new(rhs, quoted_value(boost)) unless boost.nil?
infix("===", column_node(column), rhs)
end

def regex(column, pattern)
Nodes::Regex.new(column_node(column), value_node(pattern))
rhs = ::Arel::Nodes::NamedFunction.new("pdb.regex", [quoted_value(pattern)])
infix("@@@", column_node(column), rhs)
end

def near(column, left_term, right_term, distance: 1)
Nodes::Near.new(column_node(column), terms: [left_term, right_term], distance: distance)
near_chain = infix("##", infix("##", quoted_value(left_term), quoted_value(distance)), quoted_value(right_term))
infix("@@@", column_node(column), ::Arel::Nodes::Grouping.new(near_chain))
end

def phrase_prefix(column, *terms)
Nodes::PhrasePrefix.new(column_node(column), terms.flatten)
array = Nodes::ArrayLiteral.new(terms.flatten.compact.map { |term| quoted_value(term) })
rhs = ::Arel::Nodes::NamedFunction.new("pdb.phrase_prefix", [array])
infix("@@@", column_node(column), rhs)
end

def parse(column, query, lenient: nil)
rhs = Nodes::ParseNode.new(quoted_value(query), lenient: lenient)
infix("@@@", column_node(column), rhs)
end

def match_all(column)
rhs = ::Arel::Nodes::NamedFunction.new("pdb.all", [])
infix("@@@", column_node(column), rhs)
end

def more_like_this(column, key, fields: nil)
Nodes::MoreLikeThis.new(column_node(column), value_node(key), fields: fields&.map { |f| value_node(f) })
args = [quoted_value(key)]
unless fields.nil?
field_values = fields.map { |field| quoted_value(field) }
args << Nodes::ArrayLiteral.new(field_values)
end

rhs = ::Arel::Nodes::NamedFunction.new("pdb.more_like_this", args)
infix("@@@", column_node(column), rhs)
end

# Functions
def score(key)
Nodes::Score.new("score", column_node(key))
::Arel::Nodes::NamedFunction.new("pdb.score", [column_node(key)])
end

def snippet(column, *args)
Nodes::Snippet.new("snippet", column_node(column), *args)
call_args = [column_node(column)] + args.map { |arg| quoted_value(arg) }
::Arel::Nodes::NamedFunction.new("pdb.snippet", call_args)
end

def agg(json)
Nodes::Agg.new("agg", json)
::Arel::Nodes::NamedFunction.new("pdb.agg", [quoted_value(json)])
end

private

def build_predicate(klass, column, value, boost: nil)
rhs = value_node(value)
rhs = Nodes::Boost.new(rhs, boost) if boost
klass.new(column_node(column), rhs)
def infix(operator, left, right)
::Arel::Nodes::InfixOperation.new(operator, left, right)
end

def column_node(column)
case column
when Nodes::Attribute then column
when ::Arel::Attributes::Attribute, ::Arel::Nodes::Node
column
else
Nodes::Attribute.new(column, table: table)
if arel_table
arel_table[column.to_sym]
else
::Arel::Nodes::SqlLiteral.new(::ActiveRecord::Base.connection.quote_column_name(column.to_s))
end
end
end

def value_node(val)
val.is_a?(Nodes::Node) ? val : Nodes::Value.new(val)
def quoted_value(value)
::Arel::Nodes.build_quoted(value)
end

def join_terms(terms)
terms.flatten.compact.map(&:to_s).join(" ")
end

def arel_table
@arel_table ||= table ? ::Arel::Table.new(table.to_s) : nil
end
end
end
end
131 changes: 22 additions & 109 deletions lib/parade_db/arel/nodes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,135 +3,48 @@
module ParadeDB
module Arel
module Nodes
class Node
def and(other)
And.new(self, other)
end

def or(other)
Or.new(self, other)
end

def not
Not.new(self)
end
end

# Basic node shapes
class Binary < Node
attr_reader :left, :right

def initialize(left, right)
@left = left
@right = right
end
end

class Unary < Node
attr_reader :expr
class BoostCast < ::Arel::Nodes::Node
attr_reader :expr, :factor

def initialize(expr)
def initialize(expr, factor)
@expr = expr
@factor = factor
end
end

class Function < Node
attr_reader :name, :args

def initialize(name, *args)
@name = name
@args = args
end
end

# Boolean nodes
class And < Binary; end
class Or < Binary; end
class Not < Unary; end

# Leaf/utility nodes
class SqlLiteral < Node
attr_reader :value

def initialize(value)
@value = value
end
end

class Attribute < Node
attr_reader :name, :table

def initialize(name, table: nil)
@name = name
@table = table
end
end

class Value < Node
attr_reader :value
class SlopCast < ::Arel::Nodes::Node
attr_reader :expr, :distance

def initialize(value)
@value = value
def initialize(expr, distance)
@expr = expr
@distance = distance
end
end

# ParadeDB-specific AST nodes
class Match < Binary; end # left &&& right
class MatchAny < Binary; end # left ||| right
class Phrase < Binary; end # left ### right
class Term < Binary; end # left === right (exact term)
class Fuzzy < Binary
attr_reader :distance, :prefix, :boost
class FuzzyCast < ::Arel::Nodes::Node
attr_reader :expr, :distance, :prefix

def initialize(left, right, distance:, prefix: nil, boost: nil)
super(left, right)
def initialize(expr, distance, prefix: nil)
@expr = expr
@distance = distance
@prefix = prefix
@boost = boost
end
end
class FullText < Binary; end # left @@@ right (generic)
class Regex < Binary; end # left @@@ pdb.regex(pattern)
class Near < Binary
attr_reader :distance

def initialize(left, terms:, distance:)
raise ArgumentError, "terms must be 2 items" unless terms.is_a?(Array) && terms.size == 2
super(left, terms)
@distance = distance
end
end
class PhrasePrefix < Binary; end # left @@@ pdb.phrase_prefix(...)
class MoreLikeThis < Binary
attr_reader :fields

def initialize(left, key, fields: nil)
super(left, key)
@fields = fields
end
end

# Projections / functions
class Score < Function; end # pdb.score(key)
class Snippet < Function; end # pdb.snippet(column, ...)
class Agg < Function; end # pdb.agg(json)
class ArrayLiteral < ::Arel::Nodes::Node
attr_reader :values

# Modifiers
class Boost < Unary
attr_reader :factor

def initialize(expr, factor)
super(expr)
@factor = factor
def initialize(values)
@values = Array(values)
end
end

class Slop < Unary
attr_reader :distance
class ParseNode < ::Arel::Nodes::Node
attr_reader :query, :lenient

def initialize(expr, distance)
super(expr)
@distance = distance
def initialize(query, lenient: nil)
@query = query
@lenient = lenient
end
end
end
Expand Down
Loading
Loading