Skip to content
Open
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
165 changes: 115 additions & 50 deletions lib/smart_search.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,40 @@ def self.included(base)
module ClassMethods
# Enable SmartSearch for the current ActiveRecord model.
def smart_search(options = {:on => [], :split => false})
if table_exists?
# Check if search_tags exists
if !is_smart_search?

cattr_accessor :condition_default, :group_default, :tags, :order_default, :enable_similarity, :default_template_path

# BETA!
cattr_accessor :split_searchable_fields
self.split_searchable_fields = options[:split]

send :include, InstanceMethods
self.send(:after_commit, :create_search_tags, :if => :update_search_tags?) unless options[:auto] == false
self.send(:after_destroy, :clear_search_tags)
self.enable_similarity ||= true
begin
if table_exists?
if SmartSearch::Config.search_models.index(self.name).nil?
SmartSearch::Config.search_models << self.name
end
# Check if search_tags exists
if !is_smart_search?

cattr_accessor :condition_default, :group_default, :tags, :order_default, :enable_similarity, :default_template_path

# BETA!
cattr_accessor :split_searchable_fields
self.split_searchable_fields = options[:split]

send :include, InstanceMethods
self.send(:after_commit, :create_search_tags, :if => :update_search_tags?) unless options[:auto] == false
self.send(:after_destroy, :clear_search_tags)
if options[:disable_similarity].present?
self.enable_similarity = false
else
self.enable_similarity = true
end

attr_accessor :query_score, :dont_update_search_tags
attr_accessor :query_score, :dont_update_search_tags

self.tags = options[:on] || []
elsif is_smart_search? && Rails.env.production?
# Allow re-adding attributes for search
logger.info("Re-Adding search data on #{self.name}: #{options[:on].inspect}".yellow)
self.tags += options[:on]
self.tags = options[:on] || []
elsif is_smart_search?
# Allow re-adding attributes for search
logger.info("Re-Adding search data on #{self.name}: #{options[:on].inspect}".yellow)
self.tags += options[:on]
end
end
rescue ActiveRecord::NoDatabaseError => e
puts "Could not set up smart_search due to missing database"
end
end

Expand All @@ -49,40 +60,54 @@ def is_smart_search?
end

# Serach database for given search tags
def find_by_tags(tags = "", options = {})
def find_by_tags(tags = "", options = {}, &block)

tags = store_history_and_get_sanitized_search_tags(tags)
tags = map_similarity_tags(tags)

# Load ranking from Search tags
result_ids = []
result_scores = {}
base_select = "#{self.quoted_table_name}.* #{SmartSearch::Config.order_by_score ? ', SUM(boost) AS score' : ''}"

results = SmartSearchTag.select("entry_id, sum(boost) as score").group(:entry_id).where(table_name: self.table_name)
join_query = "LEFT JOIN #{SmartSearchTag.quoted_table_name}
ON #{SmartSearchTag.quoted_table_name}.#{ActiveRecord::Base.connection.quote_column_name('table_name')} = '#{self.table_name}'
AND #{SmartSearchTag.quoted_table_name}.#{ActiveRecord::Base.connection.quote_column_name('entry_id')} = #{self.quoted_table_name}.#{self.primary_key}"

query = case ActiveRecord::Base.connection.adapter_name
when 'PostgreSQL'
"select entry_id, sum(boost) as score
from smart_search_tags where #{ActiveRecord::Base.connection.quote_column_name('table_name')}= '#{self.table_name}'
group by entry_id
HAVING (#{tags.join(' AND ')})
order by score DESC"
else
"select entry_id, sum(boost) as score, #{adapater_based_group_method}(search_tags) as grouped_tags
from smart_search_tags where #{ActiveRecord::Base.connection.quote_column_name('table_name')}= '#{self.table_name}' and
(#{tags.join(' AND ')}) group by entry_id order by score DESC"
end
group_clause = "#{self.quoted_table_name}.#{self.primary_key}, #{SmartSearchTag.column_names.map {|c| SmartSearchTag.quoted_table_name + '.' + c }.join(", ") }"

order_clause = if self.order_default
"#{self.order_default}"
elsif SmartSearch::Config.order_by_score
"score DESC"
end

results = case ActiveRecord::Base.connection.adapter_name
when 'PostgreSQL'

self.select(base_select)
.joins(join_query)
.group(group_clause)
.having("#{tags.join(' AND ')}")
else
self.select(base_select)
.joins(join_query)
.group(group_clause)
.having("#{tags.join(' AND ')}")
end

if options[:distinct] == true
results = results.distinct
end

SmartSearchTag.connection.select_all(query).each do |r|
result_ids << r["entry_id"].to_i
result_scores[r["entry_id"].to_i] = r['score'].to_f
if block_given?
results = yield(results)
end

results = self.where(self.primary_key => result_ids)
results = results.offset(options[:offset]) if options[:offset]
results = results.limit(options[:per_page]) if options[:per_page]

return results
end

def find_by_splitted_tags(search_fields = {})
def find_by_splitted_tags(search_fields = {}, &block)
sanitized_search_fields = {}
search_fields.each do |field, tags|
next if tags.blank?
Expand All @@ -100,14 +125,18 @@ def find_by_splitted_tags(search_fields = {})
end

result_ids = eval(result_list.map(&:to_s).join(" & "))
self.where(self.primary_key => result_ids)
results = self.where(self.primary_key => result_ids)

if block_given?
results = yield(results)
end
end

# Private Query Helper Methods
private
def store_history_and_get_sanitized_search_tags(orig_tags)
orig_tags = orig_tags.join(" ") if orig_tags.is_a?(Array)
sanitized_tags = orig_tags.gsub(/[\(\)\[\]\'\"\*\%\|\&]/, '').split(/[\ -]/).select {|t| !t.blank?}
sanitized_tags = orig_tags.gsub(/[\(\)\[\]\'\"\*\%\|\&\+\.\$\?]/, ' ').split(/[\ -]/).select {|t| !t.blank?}

# Save Data for similarity analysis
if sanitized_tags.join(' ').size > 3
Expand All @@ -131,7 +160,7 @@ def map_similarity_tags(tags)
when 'PostgreSQL'
"string_agg(search_tags, ' ') ~* '#{similars}'"
else
"search_tags REGEXP '#{similars}'"
"search_tags REGEXP '#{similars}'"
end
end

Expand Down Expand Up @@ -187,23 +216,33 @@ def update_search_tags?
end

# create search tags for this very record based on the attributes defined in ':on' option passed to the 'Class.smart_search' method
def create_search_tags
def create_search_tags(options = {})
# storing tags must never fail the systems
begin

self.get_calculated_tags_list!
self.clear_search_tags

data = []

(self.class.split_searchable_fields ? @calculated_tags_list : get_merged_calculated_tags).each do |t|
if !t[:search_tags].blank? && t[:search_tags].size > 1
begin
SmartSearchTag.create(t.merge!(:table_name => self.class.table_name, :entry_id => self.id, :search_tags => t[:search_tags].strip.split(" ").uniq.join(" ")))
rescue Exception => e

if options[:export]
data << t.merge!(:table_name => self.class.table_name, :entry_id => self.id, :search_tags => t[:search_tags].strip.split(" ").uniq.join(" "))
else
begin
SmartSearchTag.create(t.merge!(:table_name => self.class.table_name, :entry_id => self.id, :search_tags => t[:search_tags].strip.split(" ").uniq.join(" ")))
rescue Exception => e

end
end
end
end

if options[:export]
return data
end

rescue Exception => e
Rails.logger.error "SMART SEARCH FAILED TO TO STORE SEARCH TAGS #{self.class.name} #{self.id}"
Rails.logger.error e.message
Expand Down Expand Up @@ -268,9 +307,11 @@ class Config

cattr_accessor :search_models
cattr_accessor :public_models
cattr_accessor :order_by_score

self.search_models = []
self.public_models = []
self.order_by_score = true

def self.get_search_models
self.search_models.map {|m| m.constantize}
Expand All @@ -279,6 +320,30 @@ def self.get_search_models
def self.get_public_models
self.public_models.map {|m| m.constantize}
end

def self.rebuild_index
puts "Rebuilding Search index..."

SmartSearch::Config.search_models.each do |name|
puts "... #{name}"
end

ActiveRecord::Base.logger = nil

model_bar = ProgressBar.create(:title => "Building models", :total => SmartSearch::Config.search_models.size, format: "%t: (%c/%C) |%W| %f")

SmartSearch::Config.get_search_models.each do |model|
puts model.tags.join("\n")
entry_bar = ProgressBar.create(:title => model.name, :total => model.all.size, format: "%t: (%c/%C) |%W| %f")
model.all.each do |entry|
entry.create_search_tags
entry_bar.increment
end

model_bar.increment
puts "\n\n"
end
end
end


Expand Down
3 changes: 1 addition & 2 deletions lib/smart_search/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@ module SmartSearch
class Engine < Rails::Engine

engine_name 'smart_search'

isolate_namespace SmartSearch
require "friendly_extensions"
require "amatch"

end
end
Expand Down
10 changes: 2 additions & 8 deletions lib/smart_similarity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,6 @@ class SmartSimilarity < ActiveRecord::Base
#== Konstanten
# Defines the min. result of word simililarity check
SIMILARITY_FACTOR = 0.81
# Defines first simililarity check method
SIMILARITY_METHOD_1 = :jarowinkler
# Defines first simililarity check method
SIMILARITY_METHOD_2 = :levenshtein

# An average of both results will generated and compered with 'SIMILARITY_FACTOR'

# Limit Number of similar words
SIMILARITY_LIMIT = 5
Expand Down Expand Up @@ -100,8 +94,8 @@ def self.similars(word, options = {})

# Return match score for two words bases und the two defined similarity methods
def self.match_words(word1, word2)
x1 = word1.downcase.send("#{SIMILARITY_METHOD_1}_similar", word2.downcase)
x2 = word1.downcase.send("#{SIMILARITY_METHOD_2}_similar", word2.downcase)
x1 = DidYouMean::JaroWinkler.distance(word1.downcase, word2.downcase)
x2 = DidYouMean::Levenshtein.distance(word1.downcase, word2.downcase)
return (x1+x2)/2.0
end

Expand Down
5 changes: 5 additions & 0 deletions lib/tasks/smart_search.rake
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ namespace :smart_search do
require File.expand_path("../../smart_similarity", __FILE__)
SmartSimilarity.load_from_query_history
end

desc "Rebuild search index for all avialble models"
task :rebuild_index => :environment do
SmartSearch::Config.rebuild_index
end
end


6 changes: 1 addition & 5 deletions smart_search.gemspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Gem::Specification.new do |s|
s.name = 'smart_search'
s.version = '1.0.2'
s.version = '1.2.2'
s.summary = "Simple, easy to use search MySQL based search for ActiveRecord"
s.description = "SmartSearch adds full-text search functions to ActiveRecord running with MySQL, including search for similiar words. Its fast, simple, and works with almost zero-config!"
s.authors = ["Florian Eck"]
Expand All @@ -9,10 +9,6 @@ Gem::Specification.new do |s|
s.test_files = Dir.glob("test/**/*")
s.homepage = 'https://github.com/florianeck/smart_search'

s.add_dependency "rails", ">= 4.0.4"
s.add_dependency "amatch"
s.add_dependency "friendly_extensions"
s.add_dependency "unicode-emoji"
s.add_dependency "ruby-progressbar"
s.add_dependency "parallel"
end