Skip to content

Commit

Permalink
Merge branch 'master' into fts_indices
Browse files Browse the repository at this point in the history
Conflicts:
	lib/texticle.rb
	lib/texticle/tasks.rb
  • Loading branch information
Ben Hamill committed Jul 7, 2012
2 parents a0239d1 + cff5df4 commit 0b7c130
Show file tree
Hide file tree
Showing 14 changed files with 237 additions and 110 deletions.
19 changes: 19 additions & 0 deletions CHANGELOG.rdoc
@@ -1,3 +1,22 @@
== 2.0.3

* 1 new feature

* Allow searching through relations. Model.join(:relation).search(:relation => {:column => "query"})
works, and reduces the need for multi-model tables. Huge thanks to Ben Hamill for the pull request.
* Allow searching through all model columns irrespective of the column's type; we cast all columns to text
in the search query. Performance may degrade when searching through anything but a string column.

* 2 bugfixes

* Fix exceptions when adding Texticle to a table-less model.
* Column names in a search query are now scoped to the current table.

* 1 dev improvement

* Running `rake` from the project root will setup the test environment by creating a test database
and running the necessary migrations. `rake` can also be used to run all the project tests.

== 2.0.2

* 1 bugfix
Expand Down
6 changes: 3 additions & 3 deletions README.rdoc
Expand Up @@ -39,15 +39,15 @@ Your models now have access to the search method:
Game.search_by_title_or_system('Final Fantasy, 'PS3')

=== Creating Indexes for Super Speed
You can have postgresql use an index for the full-text search. To declare a full-text index, in a
You can have Postgresql use an index for the full-text search. To declare a full-text index, in a
migration add code like the following:

execute "
create index on email_logs using gin(to_tsvector('english', subject));
create index on email_logs using gin(to_tsvector('english', email_address));"

In the above example, the table email_logs has two text columns that we search against, subject and email_address.
You will need to add an index for every text/string column in your table, or else postgresql will revert to a
In the above example, the table email_logs has two text columns that we search against, subject and email_address.
You will need to add an index for every text/string column you query against, or else Postgresql will revert to a
full table scan instead of using the indexes.


Expand Down
57 changes: 46 additions & 11 deletions Rakefile
Expand Up @@ -8,17 +8,7 @@ require 'benchmark'
$LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__) + '/spec')

task :default do
config = File.open(File.expand_path(File.dirname(__FILE__) + '/spec/config.yml')).read
if config.match /<username>/
print "Would you like to create and configure the test database? y/n "
continue = STDIN.getc
exit 0 unless continue == "Y" || continue == "y"
sh "createdb texticle"
File.open(File.expand_path(File.dirname(__FILE__) + '/spec/config.yml'), "w") do |writable_config|
writable_config << config.sub(/<username>/, `whoami`.chomp)
end
Rake::Task["db:migrate"].invoke
end
Rake::Task["db:setup"].invoke
Rake::Task["test"].invoke
end

Expand All @@ -29,6 +19,41 @@ task :test do
end

namespace :db do
desc 'Create and configure the test database'
task :setup do
spec_directory = "#{File.expand_path(File.dirname(__FILE__))}/spec"

STDOUT.puts "Detecting database configuration..."

if File.exists?("#{spec_directory}/config.yml")
STDOUT.puts "Configuration detected. Skipping confguration."
else
STDOUT.puts "Would you like to create and configure the test database? y/N"
continue = STDIN.gets.chomp

unless continue =~ /^[y]$/i
STDOUT.puts "Done."
exit 0
end

STDOUT.puts "Creating database..."
`createdb texticle`

STDOUT.puts "Writing configuration file..."

config_example = File.read("#{spec_directory}/config.yml.example")

File.open("#{spec_directory}/config.yml", "w") do |config|
config << config_example.sub(/<username>/, `whoami`.chomp)
end

STDOUT.puts "Running migrations..."
Rake::Task["db:migrate"].invoke

STDOUT.puts 'Done.'
end
end

desc 'Run migrations for test database'
task :migrate do
require 'spec_helper'
Expand All @@ -39,18 +64,28 @@ namespace :db do
table.text :description
end
create_table :web_comics do |table|

table.string :name
table.string :author
table.text :review
table.integer :id
end

create_table :characters do |table|
table.string :name
table.string :description
table.integer :web_comic_id
end
end
end

desc 'Drop tables from test database'
task :drop do
require 'spec_helper'
ActiveRecord::Migration.instance_eval do
drop_table :games
drop_table :web_comics
drop_table :characters
end
end
end
39 changes: 27 additions & 12 deletions lib/texticle.rb
@@ -1,12 +1,19 @@
require 'active_record'

module Texticle
VERSION = '2.0.3'

def self.version
VERSION
end

def self.searchable_language
'english'
end

def search(query = "", exclusive = true)
language = connection.quote(searchable_language)
@similarities = []
@conditions = []

unless query.is_a?(Hash)
exclusive = false
Expand All @@ -15,20 +22,12 @@ def search(query = "", exclusive = true)
end
end

similarities = []
conditions = []

query.each do |column, search_term|
column = connection.quote_column_name(column)
search_term = connection.quote normalize(Helper.normalize(search_term))
similarities << "ts_rank(to_tsvector(#{language}, #{quoted_table_name}.#{column}::text), to_tsquery(#{language}, #{search_term}::text))"
conditions << "to_tsvector(#{language}, #{quoted_table_name}.#{column}::text) @@ to_tsquery(#{language}, #{search_term}::text)"
end
parse_query_hash(query)

rank = connection.quote_column_name('rank' + rand.to_s)

select("#{quoted_table_name + '.*,' if scoped.select_values.empty?} #{similarities.join(" + ")} AS #{rank}").
where(conditions.join(exclusive ? " AND " : " OR ")).
select("#{quoted_table_name + '.*,' if scoped.select_values.empty?} #{@similarities.join(" + ")} AS #{rank}").
where(@conditions.join(exclusive ? " AND " : " OR ")).
order("#{rank} DESC")
end

Expand Down Expand Up @@ -61,6 +60,22 @@ def respond_to?(method, include_private = false)

private

def parse_query_hash(query, table_name = quoted_table_name)
language = connection.quote(searchable_language)
table_name = connection.quote_table_name(table_name)

query.each do |column_or_table, search_term|
if search_term.is_a?(Hash)
parse_query_hash(search_term, column_or_table)
else
column = connection.quote_column_name(column_or_table)
search_term = connection.quote normalize(Helper.normalize(search_term))
@similarities << "ts_rank(to_tsvector(#{language}, #{table_name}.#{column}::text), to_tsquery(#{language}, #{search_term}::text))"
@conditions << "to_tsvector(#{language}, #{table_name}.#{column}::text) @@ to_tsquery(#{language}, #{search_term}::text)"
end
end
end

def normalize(query)
query
end
Expand Down
21 changes: 21 additions & 0 deletions lib/texticle/tasks.rb
Expand Up @@ -7,4 +7,25 @@
raise 'A model name is required' unless args[:model_name]
Texticle::FullTextIndexer.new.generate_migration(args[:model_name])
end

desc "Install trigram text search module"
task :install_trigram => [:environment] do
share_dir = `pg_config --sharedir`.chomp

raise RuntimeError, "Cannot find Postgres's shared directory." unless $?.success?

trigram = "#{share_dir}/contrib/pg_trgm.sql"

unless system("ls #{trigram}")
raise RuntimeError, 'cannot find trigram module; was it compiled and installed?'
end

db_name = ActiveRecord::Base.connection.current_database

unless system("psql -d #{db_name} -f #{trigram}")
raise RuntimeError, "`psql -d #{db_name} -f #{trigram}` cannot complete successfully"
end

puts "Trigram text search module successfully installed into '#{db_name}' database."
end
end
1 change: 1 addition & 0 deletions spec/.gitignore
@@ -0,0 +1 @@
config.yml
File renamed without changes.
9 changes: 9 additions & 0 deletions spec/fixtures/character.rb
@@ -0,0 +1,9 @@
require 'active_record'

class Character < ActiveRecord::Base
# string :name
# string :description
# integer :web_comic_id

belongs_to :web_comic
end
9 changes: 9 additions & 0 deletions spec/fixtures/game.rb
@@ -0,0 +1,9 @@
require 'active_record'

class Game < ActiveRecord::Base
# string :system
# string :title
# text :description
end

class GameFail < Game; end
9 changes: 9 additions & 0 deletions spec/fixtures/webcomic.rb
@@ -0,0 +1,9 @@
require 'active_record'

class WebComic < ActiveRecord::Base
# string :name
# string :author
# integer :id

has_many :characters
end
1 change: 0 additions & 1 deletion spec/spec_helper.rb
Expand Up @@ -3,7 +3,6 @@
require 'yaml'
require 'texticle'
require 'shoulda'
require 'ruby-debug'

config = YAML.load_file File.expand_path(File.dirname(__FILE__) + '/config.yml')
ActiveRecord::Base.establish_connection config.merge(:adapter => :postgresql)
13 changes: 4 additions & 9 deletions spec/texticle/searchable_spec.rb
@@ -1,26 +1,22 @@
require 'spec_helper'
require 'fixtures/webcomic'
require 'texticle/searchable'

class WebComic < ActiveRecord::Base
# string :name
# string :author
end

class SearchableTest < Test::Unit::TestCase

context "when extending an ActiveRecord::Base subclass" do
setup do
@qcont = WebComic.create :name => "Questionable Content", :author => "Jeff Jaques"
@qcont = WebComic.create :name => "Questionable Content", :author => "Jeph Jaques"
@jhony = WebComic.create :name => "Johnny Wander", :author => "Ananth & Yuko"
@ddeeg = WebComic.create :name => "Dominic Deegan", :author => "Mookie"
@penny = WebComic.create :name => "Penny Arcade", :author => "Tycho & Gabe"
end

teardown do
WebComic.delete_all
#Object.send(:remove_const, :WebComic) if defined?(WebComic)
end

context "with no paramters" do
context "with no parameters" do
setup do
WebComic.extend Searchable
end
Expand Down Expand Up @@ -67,5 +63,4 @@ class SearchableTest < Test::Unit::TestCase
end
end
end

end

0 comments on commit 0b7c130

Please sign in to comment.