diff --git a/CHANGELOG.rdoc b/CHANGELOG.rdoc index 32c3196..db9d239 100644 --- a/CHANGELOG.rdoc +++ b/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 diff --git a/README.rdoc b/README.rdoc index 491f1a0..2cdad79 100644 --- a/README.rdoc +++ b/README.rdoc @@ -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. diff --git a/Rakefile b/Rakefile index b908217..825f70e 100644 --- a/Rakefile +++ b/Rakefile @@ -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 // - 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(//, `whoami`.chomp) - end - Rake::Task["db:migrate"].invoke - end + Rake::Task["db:setup"].invoke Rake::Task["test"].invoke end @@ -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(//, `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' @@ -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 diff --git a/lib/texticle.rb b/lib/texticle.rb index 4e6fae8..c7108c2 100644 --- a/lib/texticle.rb +++ b/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 @@ -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 @@ -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 diff --git a/lib/texticle/tasks.rb b/lib/texticle/tasks.rb index 86b16ab..b78fda9 100644 --- a/lib/texticle/tasks.rb +++ b/lib/texticle/tasks.rb @@ -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 diff --git a/spec/.gitignore b/spec/.gitignore new file mode 100644 index 0000000..1d3ed4c --- /dev/null +++ b/spec/.gitignore @@ -0,0 +1 @@ +config.yml diff --git a/spec/config.yml b/spec/config.yml.example similarity index 100% rename from spec/config.yml rename to spec/config.yml.example diff --git a/spec/fixtures/character.rb b/spec/fixtures/character.rb new file mode 100644 index 0000000..a485fcd --- /dev/null +++ b/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 diff --git a/spec/fixtures/game.rb b/spec/fixtures/game.rb new file mode 100644 index 0000000..4d39ed8 --- /dev/null +++ b/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 diff --git a/spec/fixtures/webcomic.rb b/spec/fixtures/webcomic.rb new file mode 100644 index 0000000..93d19d7 --- /dev/null +++ b/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 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 54b365f..5e09e44 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -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) diff --git a/spec/texticle/searchable_spec.rb b/spec/texticle/searchable_spec.rb index 774ce9a..0fd3b15 100644 --- a/spec/texticle/searchable_spec.rb +++ b/spec/texticle/searchable_spec.rb @@ -1,16 +1,11 @@ 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" @@ -18,9 +13,10 @@ class SearchableTest < Test::Unit::TestCase 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 @@ -67,5 +63,4 @@ class SearchableTest < Test::Unit::TestCase end end end - end diff --git a/spec/texticle_spec.rb b/spec/texticle_spec.rb index c0e9c1f..c5a6e29 100644 --- a/spec/texticle_spec.rb +++ b/spec/texticle_spec.rb @@ -1,24 +1,14 @@ # coding: utf-8 require 'spec_helper' - -class Game < ActiveRecord::Base - # string :system - # string :title - # text :description - - def to_s - "#{system}: #{title} (#{description})" - end -end - -class NotThere < ActiveRecord::Base; end +require 'fixtures/webcomic' +require 'fixtures/character' +require 'fixtures/game' class TexticleTest < Test::Unit::TestCase - context "after extending ActiveRecord::Base" do - setup do - ActiveRecord::Base.extend(Texticle) - end + # before(:all) + ActiveRecord::Base.extend(Texticle) + class NotThere < ActiveRecord::Base; end should "not break #respond_to?" do assert_nothing_raised do @@ -51,11 +41,45 @@ class TexticleTest < Test::Unit::TestCase assert_match error.message, /undefined method `random'/ end end + + context "when finding models based on searching a related model" do + setup do + @qc = WebComic.create :name => "Questionable Content", :author => "Jeph Jaques" + @jw = WebComic.create :name => "Johnny Wander", :author => "Ananth & Yuko" + @pa = WebComic.create :name => "Penny Arcade", :author => "Tycho & Gabe" + + @gabe = @pa.characters.create :name => 'Gabe', :description => 'the simple one' + @tycho = @pa.characters.create :name => 'Tycho', :description => 'the wordy one' + @div = @pa.characters.create :name => 'Div', :description => 'a crude divx player with anger management issues' + + @martin = @qc.characters.create :name => 'Martin', :description => 'the insecure protagonist' + @faye = @qc.characters.create :name => 'Faye', :description => 'a sarcastic barrista with anger management issues' + @pintsize = @qc.characters.create :name => 'Pintsize', :description => 'a crude AnthroPC' + + @ananth = @jw.characters.create :name => 'Ananth', :description => 'Stubble! What is under that hat?!?' + @yuko = @jw.characters.create :name => 'Yuko', :description => 'So... small. Carl Sagan haircut.' + @john = @jw.characters.create :name => 'John', :description => 'Tall. Anger issues?' + @cricket = @jw.characters.create :name => 'Cricket', :description => 'Chirrup!' + end + + teardown do + WebComic.delete_all + Character.delete_all + end + + should "look in the related model with nested searching syntax" do + assert_equal [@jw], WebComic.joins(:characters).search(:characters => {:description => 'tall'}) + assert_equal [@pa, @jw, @qc].sort, WebComic.joins(:characters).search(:characters => {:description => 'anger'}).sort + assert_equal [@pa, @qc].sort, WebComic.joins(:characters).search(:characters => {:description => 'crude'}).sort + end + end end context "after extending an ActiveRecord::Base subclass" do + # before(:all) + class ::GameFail < Game; end + setup do - Game.extend(Texticle) @zelda = Game.create :system => "NES", :title => "Legend of Zelda", :description => "A Link to the Past." @mario = Game.create :system => "NES", :title => "Super Mario Bros.", :description => "The original platformer." @sonic = Game.create :system => "Genesis", :title => "Sonic the Hedgehog", :description => "Spiky." @@ -69,16 +93,14 @@ class TexticleTest < Test::Unit::TestCase teardown do Game.delete_all end - - should "not break respond_to? when connection is unavailable" do - class GameFail < Game - end + should "not break respond_to? when connection is unavailable" do GameFail.establish_connection({:adapter => :postgresql, :database =>'unavailable', :username=>'bad', :pool=>5, :timeout=>5000}) rescue nil + assert_nothing_raised do GameFail.respond_to?(:search) end - + end should "define a #search method" do @@ -190,17 +212,15 @@ class GameFail < Game end context "when setting a custom search language" do + def Game.searchable_language + 'spanish' + end + setup do - def Game.searchable_language - 'spanish' - end Game.create :system => "PS3", :title => "Harry Potter & the Deathly Hallows" end teardown do - def Game.searchable_language - 'english' - end Game.delete_all end @@ -208,5 +228,4 @@ def Game.searchable_language assert_not_empty Game.search_by_title("harry") end end - end diff --git a/texticle.gemspec b/texticle.gemspec index b41a64b..32c186a 100644 --- a/texticle.gemspec +++ b/texticle.gemspec @@ -1,49 +1,45 @@ # -*- encoding: utf-8 -*- +require File.expand_path('../lib/texticle', __FILE__) + Gem::Specification.new do |s| - s.name = %q{texticle} - s.version = "2.0.2" - - s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= - s.authors = ["ecin", "Aaron Patterson"] - s.date = %q{2011-08-30} - s.description = %q{Texticle exposes full text search capabilities from PostgreSQL, extending - ActiveRecord with scopes making search easy and fun!} - s.email = ["ecin@copypastel.com"] - s.extra_rdoc_files = ["Manifest.txt", "CHANGELOG.rdoc", "README.rdoc"] - s.files = ["CHANGELOG.rdoc", "Manifest.txt", "README.rdoc", "Rakefile", "lib/texticle.rb", "lib/texticle/searchable.rb", - "lib/texticle/rails.rb", "spec/spec_helper.rb", "spec/texticle_spec.rb", "spec/texticle/searchable_spec.rb", "spec/config.yml"] - s.homepage = %q{http://tenderlove.github.com/texticle} - s.rdoc_options = ["--main", "README.rdoc"] - s.require_paths = ["lib"] - s.rubyforge_project = %q{texticle} - s.rubygems_version = %q{1.7.2} - s.summary = %q{Texticle exposes full text search capabilities from PostgreSQL} - s.test_files = ["spec/spec_helper.rb", "spec/texticle_spec.rb", "spec/config.yml"] - - if s.respond_to? :specification_version then - current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION - s.specification_version = 3 - - if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then - s.add_development_dependency(%q, ["~> 0.11.0"]) - s.add_development_dependency(%q, ["~> 2.11.3"]) - s.add_development_dependency(%q, ["~> 0.8.0"]) - s.add_development_dependency(%q, ["~> 0.11.6"]) - - s.add_dependency(%q, ["~> 3.0"]) - else - s.add_dependency(%q, ["~> 0.11.0"]) - s.add_dependency(%q, ["~> 2.11.3"]) - s.add_dependency(%q, ["~> 0.8.0"]) - s.add_dependency(%q, ["~> 0.11.6"]) - s.add_dependency(%q, ["~> 3.0"]) - end - else - s.add_dependency(%q, ["~> 0.11.0"]) - s.add_dependency(%q, ["~> 2.11.3"]) - s.add_dependency(%q, ["~> 0.8.0"]) - s.add_dependency(%q, ["~> 0.11.6"]) - s.add_dependency(%q, ["~> 3.0"]) - end + s.name = 'texticle' + s.version = Texticle.version + + s.summary = 'Texticle exposes full text search capabilities from PostgreSQL' + s.description = 'Texticle exposes full text search capabilities from PostgreSQL, extending + ActiveRecord with scopes making search easy and fun!' + + s.license = 'MIT' + s.authors = ['Ben Hamill', 'ecin', 'Aaron Patterson'] + s.email = ['git-commits@benhamill.com', 'ecin@copypastel.com'] + s.homepage = 'http://texticle.github.com/texticle' + + s.files = [ + 'CHANGELOG.rdoc', + 'Manifest.txt', + 'README.rdoc', + 'Rakefile', + 'lib/texticle.rb', + 'lib/texticle/searchable.rb', + 'lib/texticle/rails.rb', + 'spec/spec_helper.rb', + 'spec/texticle_spec.rb', + 'spec/texticle/searchable_spec.rb', + 'spec/config.yml' + ] + s.executables = [] + s.test_files = ['spec/spec_helper.rb', 'spec/texticle_spec.rb', 'spec/config.yml'] + s.require_paths = ['lib'] + + s.extra_rdoc_files = ['Manifest.txt', 'CHANGELOG.rdoc', 'README.rdoc'] + s.rdoc_options = ['--main', 'README.rdoc'] + + + + s.add_development_dependency('pg', '~> 0.11.0') + s.add_development_dependency('shoulda', '~> 2.11.3') + s.add_development_dependency('rake', '~> 0.9.0') + + s.add_dependency('activerecord', '~> 3.0') end