texticle 1.0.4.20101004123327 → 2.0.pre

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/CHANGELOG.rdoc CHANGED
@@ -1,3 +1,27 @@
1
+ === 2.0rc1
2
+
3
+ * Complete refactoring of Texticle
4
+
5
+ * For users:
6
+
7
+ * Texticle should only be used for its simplicity; if you need to deeply configure your text search, please give `gem install pg_search` a try.
8
+ * #search method is now included in all ActiveRecord models by default, and searches across a model's :string columns.
9
+ * #search_by_<column> dynamic methods are now available.
10
+ * #search can now be chained; Game.search_by_title("Street Fighter").search_by_system("PS3") works.
11
+ * #search now accepts a hash to specify columns to be searched, e.g. Game.search(:name => "Mario")
12
+ * No more access to #rank values for results (though they're still ordered by rank).
13
+ * No way to give different weights to different columns in this release.
14
+
15
+ * For devs:
16
+
17
+ * We now have actual tests to run against; this will make accepting pull requests much more enjoyable.
18
+
19
+ === HEAD (unreleased)
20
+
21
+ * 1 minor bugfix
22
+
23
+ * Multiple named indices are now supported.
24
+
1
25
  === 1.0.4 / 2010-08-19
2
26
 
3
27
  * 2 major enhancements
data/README.rdoc CHANGED
@@ -1,12 +1,11 @@
1
1
  = texticle
2
2
 
3
- * http://texticle.rubyforge.org/
3
+ * http://tenderlove.github.com/texticle
4
4
 
5
5
  == DESCRIPTION:
6
6
 
7
- Texticle exposes full text search capabilities from PostgreSQL, and allows
8
- you to declare full text indexes. Texticle will extend ActiveRecord with
9
- named_scope methods making searching easy and fun!
7
+ Texticle exposes full text search capabilities from PostgreSQL,
8
+ extending ActiveRecord with scopes making search easy and fun!
10
9
 
11
10
  == FEATURES/PROBLEMS:
12
11
 
@@ -14,82 +13,40 @@ named_scope methods making searching easy and fun!
14
13
 
15
14
  == SYNOPSIS:
16
15
 
17
- === Rails 3 Configuration
16
+ === Configuration
18
17
 
19
- In the project's Gemfile add
20
-
21
- gem 'texticle', '1.0.4'
22
-
23
- === Rails 2 Configuration
18
+ * Rails 3
24
19
 
25
- In environment.rb:
20
+ In the project's Gemfile add
26
21
 
27
- config.gem 'texticle'
22
+ gem 'texticle', '~> 2.0rc1', :require => 'texticle/rails'
28
23
 
29
- In your Rakefile:
24
+ * ActiveRecord outside of Rails 3
30
25
 
31
- require 'rubygems'
32
- require 'texticle/tasks'
26
+ require 'texticle'
27
+ ActiveRecord::Base.extend(Texticle)
33
28
 
34
29
  === Usage
35
30
 
36
- Declare your index in your model:
37
-
38
- class Product < ActiveRecord::Base
39
- index do
40
- name
41
- description
42
- end
43
- end
44
-
45
- Use the search method
46
-
47
- Product.search('hello world')
48
-
49
- Full text searches can be sped up by creating indexes. To create them, run:
50
-
51
- $ rake texticle:create_indexes
52
-
53
- Alternatively, you can create a migration that will build your indexes:
54
-
55
- $ rake texticle:migration
56
-
57
- == BETA: FUZZY SEARCH
58
-
59
- We're adding support for Postgres' optional trigram matching module, which allows for fuzzier text searches.
60
-
61
- Since it's an optional module, first step is to install it into the current database:
62
-
63
- $ rake texticle:install_trigram
64
-
65
- If you installed Postgres with its optional 'contrib' modules, you should be safe.
66
-
67
- Declare your indexes as you normally would:
68
-
69
- class Book < ActiveRecord::Base
70
- index { title }
71
- end
72
-
73
- Finally, say hello to #tsearch:
31
+ Your models now have access to the search method:
74
32
 
75
- Book.create :title => "_why's Poignant Guide to Ruby"
76
- Book.tsearch "_whys Poignnt Gd t Rb" # => [#<Book title: "_why's Poignant Guide to Ruby">]
77
-
78
- Trigram searching is still in beta, so expect the API to change and/or improve.
33
+ Game.search('Sonic') # will search through the model's :string columns
34
+ Game.search(:title => 'Mario')
35
+ Game.search_by_title('Street Fighter').search_by_system('PS3')
79
36
 
80
37
  == REQUIREMENTS:
81
38
 
82
- * Texticle may be used outside rails, but works best with rails.
39
+ * ActiveRecord
83
40
 
84
41
  == INSTALL:
85
42
 
86
- * sudo gem install texticle
43
+ * gem install texticle
87
44
 
88
45
  == LICENSE:
89
46
 
90
47
  (The MIT License)
91
48
 
92
- Copyright (c) 2009 Aaron Patterson
49
+ Copyright (c) 2011 Aaron Patterson
93
50
 
94
51
  Permission is hereby granted, free of charge, to any person obtaining
95
52
  a copy of this software and associated documentation files (the
data/Rakefile CHANGED
@@ -1,14 +1,20 @@
1
- # -*- ruby -*-
2
-
3
1
  require 'rubygems'
4
- require 'hoe'
5
2
 
6
- Hoe.plugin :gemspec
7
- Hoe.spec 'texticle' do
8
- developer('Aaron Patterson', 'aaronp@rubyforge.org')
9
- self.readme_file = 'README.rdoc'
10
- self.history_file = 'CHANGELOG.rdoc'
11
- self.extra_rdoc_files = FileList['*.rdoc']
12
- end
3
+ require 'rake'
4
+ require 'pg'
5
+ require 'active_record'
6
+ require 'benchmark'
13
7
 
14
- # vim: syntax=Ruby
8
+ require File.expand_path(File.dirname(__FILE__) + '/spec/spec_helper')
9
+
10
+ namespace :db do
11
+ desc 'Run migrations for test database'
12
+ task :migrate do
13
+ ActiveRecord::Migration.instance_eval do
14
+ create_table :games do |table|
15
+ table.string :system
16
+ table.string :title
17
+ end
18
+ end
19
+ end
20
+ end
@@ -1,12 +1,8 @@
1
1
  # Module used to conform to Rails 3 plugin API
2
- require File.expand_path( File.dirname(__FILE__) + '/../texticle')
2
+ require File.expand_path(File.dirname(__FILE__) + '/../texticle')
3
3
 
4
4
  module Texticle
5
5
  class Railtie < Rails::Railtie
6
- rake_tasks do
7
- load File.dirname(__FILE__) + '/tasks.rb'
8
- end
9
-
10
6
  initializer "texticle.configure_rails_initialization" do
11
7
  ActiveRecord::Base.extend(Texticle)
12
8
  end
data/lib/texticle.rb CHANGED
@@ -1,113 +1,86 @@
1
- require 'texticle/full_text_index'
2
- require 'texticle/railtie' if defined?(Rails) and Rails::VERSION::MAJOR > 2
3
-
4
- ####
5
- # Texticle exposes full text search capabilities from PostgreSQL, and allows
6
- # you to declare full text indexes. Texticle will extend ActiveRecord with
7
- # named_scope methods making searching easy and fun!
8
- #
9
- # Texticle.index is automatically added to ActiveRecord::Base.
10
- #
11
- # To declare an index on a model, just use the index method:
12
- #
13
- # class Product < ActiveRecord::Base
14
- # index do
15
- # name
16
- # description
17
- # end
18
- # end
19
- #
20
- # This will allow you to do full text search on the name and description
21
- # columns for the Product model. It defines a named_scope method called
22
- # "search", so you can take advantage of the search like this:
23
- #
24
- # Product.search('foo bar')
25
- #
26
- # Indexes may also be named. For example:
27
- #
28
- # class Product < ActiveRecord::Base
29
- # index 'author' do
30
- # name
31
- # author
32
- # end
33
- # end
34
- #
35
- # A named index will add a named_scope with the index name prefixed by
36
- # "search". In order to take advantage of the "author" index, just call:
37
- #
38
- # Product.search_author('foo bar')
39
- #
40
- # Finally, column names can be ranked. The ranks are A, B, C, and D. This
41
- # lets us declare that matches in the "name" column are more important
42
- # than matches in the "description" column:
43
- #
44
- # class Product < ActiveRecord::Base
45
- # index do
46
- # name 'A'
47
- # description 'B'
48
- # end
49
- # end
1
+ require 'active_record'
2
+
50
3
  module Texticle
51
- # The version of Texticle you are using.
52
- VERSION = '1.0.4' unless defined?(Texticle::VERSION)
53
-
54
- # A list of full text indexes
55
- attr_accessor :full_text_indexes
56
-
57
- ###
58
- # Create an index with +name+ using +dictionary+
59
- def index name = nil, dictionary = 'english', &block
60
- search_name = ['search', name].compact.join('_')
61
-
62
- scope_lamba = lambda { |term|
63
- # Let's extract the individual terms to allow for quoted and wildcard terms.
64
- term = term.scan(/"([^"]+)"|(\S+)/).flatten.compact.map do |lex|
65
- lex =~ /(.+)\*\s*$/ ? "'#{$1}':*" : "'#{lex}'"
66
- end.join(' & ')
67
-
68
- {
69
- :select => "#{table_name}.*, ts_rank_cd((#{full_text_indexes.first.to_s}),
70
- to_tsquery(#{connection.quote(term)})) as rank",
71
- :conditions =>
72
- ["#{full_text_indexes.first.to_s} @@ to_tsquery(?)", term],
73
- :order => 'rank DESC'
74
- }
75
- }
76
-
77
- # tsearch, i.e. trigram search
78
- trigram_scope_lambda = lambda { |term|
79
- term = "'#{term.gsub("'", "''")}'"
80
-
81
- similarities = full_text_indexes.first.index_columns.values.flatten.inject([]) do |array, index|
82
- array << "similarity(#{index}, #{term})"
83
- end.join(" + ")
84
-
85
- conditions = full_text_indexes.first.index_columns.values.flatten.inject([]) do |array, index|
86
- array << "(#{index} % #{term})"
87
- end.join(" OR ")
88
-
89
- {
90
- :select => "#{table_name}.*, #{similarities} as rank",
91
- :conditions => conditions,
92
- :order => 'rank DESC'
93
- }
94
- }
95
-
96
- class_eval do
97
- # Trying to avoid the deprecation warning when using :named_scope
98
- # that Rails 3 emits. Can't use #respond_to?(:scope) since scope
99
- # is a protected method in Rails 2, and thus still returns true.
100
- if self.respond_to?(:scope) and not protected_methods.include?('scope')
101
- scope search_name.to_sym, scope_lamba
102
- scope ('t' + search_name).to_sym, trigram_scope_lambda
103
- elsif self.respond_to? :named_scope
104
- named_scope search_name.to_sym, scope_lamba
105
- named_scope ('t' + search_name).to_sym, trigram_scope_lambda
4
+
5
+ def search(query = {})
6
+ language = connection.quote('english')
7
+
8
+ exclusive = true
9
+
10
+ unless query.is_a?(Hash)
11
+ exclusive = false
12
+ query = columns.select {|column| column.type == :string }.map(&:name).inject({}) do |terms, column|
13
+ terms.merge column => query.to_s
14
+ end
15
+ end
16
+
17
+ similarities = []
18
+ conditions = []
19
+
20
+ query.each do |column, search_term|
21
+ column = connection.quote_column_name(column)
22
+ search_term = connection.quote normalize(Helper.normalize(search_term))
23
+ similarities << "ts_rank(to_tsvector(#{quoted_table_name}.#{column}), to_tsquery(#{search_term}))"
24
+ conditions << "to_tsvector(#{language}, #{column}) @@ to_tsquery(#{search_term})"
25
+ end
26
+
27
+ rank = connection.quote_column_name('rank' + rand.to_s)
28
+
29
+ select("#{quoted_table_name}.*, #{similarities.join(" + ")} AS #{rank}").
30
+ where(conditions.join(exclusive ? " AND " : " OR ")).
31
+ order("#{rank} DESC")
32
+ end
33
+
34
+ def method_missing(method, *search_terms)
35
+ if Helper.dynamic_search_method?(method, self.columns)
36
+ columns = Helper.dynamic_search_columns(method)
37
+ metaclass = class << self; self; end
38
+ metaclass.__send__(:define_method, method) do |*args|
39
+ query = columns.inject({}) do |query, column|
40
+ query.merge column => args.shift
41
+ end
42
+ search(query)
106
43
  end
44
+ __send__(method, *search_terms)
45
+ else
46
+ super
107
47
  end
48
+ end
49
+
50
+ def respond_to?(method)
51
+ Helper.dynamic_search_method?(method, self.columns) ? true : super
52
+ end
53
+
54
+ private
108
55
 
109
- index_name = [table_name, name, 'fts_idx'].compact.join('_')
110
- (self.full_text_indexes ||= []) <<
111
- FullTextIndex.new(index_name, dictionary, self, &block)
56
+ def normalize(query)
57
+ query
112
58
  end
59
+
60
+ module Helper
61
+ class << self
62
+ def normalize(query)
63
+ query.to_s.gsub(' ', '\\\\ ')
64
+ end
65
+
66
+ def dynamic_search_columns(method)
67
+ if match = method.to_s.match(/search_by_(?<columns>[_a-zA-Z]\w*)/)
68
+ match[:columns].split('_and_')
69
+ else
70
+ []
71
+ end
72
+ end
73
+
74
+ def dynamic_search_method?(method, class_columns)
75
+ string_columns = class_columns.select {|column| column.type == :string }.map(&:name)
76
+ columns = dynamic_search_columns(method)
77
+ unless columns.empty?
78
+ columns.all? {|column| string_columns.include?(column) }
79
+ else
80
+ false
81
+ end
82
+ end
83
+ end
84
+ end
85
+
113
86
  end
data/spec/config.yml ADDED
@@ -0,0 +1,4 @@
1
+ database: texticle
2
+ username: <username>
3
+ pool: 5
4
+ timeout: 5000
@@ -0,0 +1,9 @@
1
+ $LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__) + '/../lib')
2
+
3
+ require 'yaml'
4
+ require 'texticle'
5
+ require 'shoulda'
6
+ require 'ruby-debug'
7
+
8
+ config = YAML.load_file File.expand_path(File.dirname(__FILE__) + '/config.yml')
9
+ ActiveRecord::Base.establish_connection config.merge(:adapter => :postgresql)
@@ -0,0 +1,119 @@
1
+ # coding: utf-8
2
+ require 'spec_helper'
3
+
4
+ class Game < ActiveRecord::Base
5
+ # string :system
6
+ # string :title
7
+
8
+ def to_s
9
+ "#{system}: #{title}"
10
+ end
11
+ end
12
+
13
+ class TexticleTest < Test::Unit::TestCase
14
+
15
+ context "after extending an ActiveRecord::Base subclass" do
16
+ setup do
17
+ Game.extend(Texticle)
18
+ @zelda = Game.create :system => "NES", :title => "Legend of Zelda"
19
+ @mario = Game.create :system => "NES", :title => "Super Mario Bros."
20
+ @sonic = Game.create :system => "Genesis", :title => "Sonic the Hedgehog"
21
+ @dkong = Game.create :system => "SNES", :title => "Diddy's Kong Quest"
22
+ @megam = Game.create :system => nil, :title => "Mega Man"
23
+ @sfnes = Game.create :system => "SNES", :title => "Street Fighter 2"
24
+ @sfgen = Game.create :system => "Genesis", :title => "Street Fighter 2"
25
+ @takun = Game.create :system => "Saturn", :title => "Magical Tarurūto-kun"
26
+ end
27
+
28
+ teardown do
29
+ Game.delete_all
30
+ end
31
+
32
+ should "define a #search method" do
33
+ assert Game.respond_to?(:search)
34
+ end
35
+
36
+ context "when searching with a String argument" do
37
+ should "search across all :string columns if no indexes have been specified" do
38
+ assert_equal @mario, Game.search("Mario").first
39
+ assert_equal 1, Game.search("Mario").count
40
+
41
+ assert (Game.search("NES") && [@mario, @zelda]) == [@mario, @zelda]
42
+ assert_equal 2, Game.search("NES").count
43
+ end
44
+
45
+ should "work if the query contains an apostrophe" do
46
+ assert_equal @dkong, Game.search("Diddy's").first
47
+ assert_equal 1, Game.search("Diddy's").count
48
+ end
49
+
50
+ should "work if the query contains whitespace" do
51
+ assert_equal @megam, Game.search("Mega Man").first
52
+ end
53
+
54
+ should "work if the query contains an accent" do
55
+ assert_equal @takun, Game.search("Tarurūto-kun").first
56
+ end
57
+
58
+ should "search across records with NULL values" do
59
+ assert_equal @megam, Game.search("Mega").first
60
+ end
61
+
62
+ should "scope consecutively" do
63
+ assert_equal @sfgen, Game.search("Genesis").search("Street Fighter").first
64
+ end
65
+ end
66
+
67
+ context "when searching with a Hash argument" do
68
+ should "search across the given columns" do
69
+ assert Game.search(:title => "NES").empty?
70
+ assert Game.search(:system => "Mario").empty?
71
+ puts Game.search(:system => "NES", :title => "Sonic").to_a
72
+ assert Game.search(:system => "NES", :title => "Sonic").empty?
73
+
74
+ assert_equal @mario, Game.search(:title => "Mario").first
75
+ assert_equal 1, Game.search(:title => "Mario").count
76
+
77
+ assert_equal 2, Game.search(:system => "NES").count
78
+
79
+ assert_equal @zelda, Game.search(:system => "NES", :title => "Zelda").first
80
+ assert_equal @megam, Game.search(:title => "Mega").first
81
+ end
82
+
83
+ should "scope consecutively" do
84
+ assert_equal @sfgen, Game.search(:system => "Genesis").search(:title => "Street Fighter").first
85
+ end
86
+ end
87
+
88
+ context "when using dynamic search methods" do
89
+ should "generate methods for each :string column" do
90
+ assert_equal @mario, Game.search_by_title("Mario").first
91
+ assert_equal @takun, Game.search_by_system("Saturn").first
92
+ end
93
+
94
+ should "generate methods for any combination of :string columns" do
95
+ assert_equal @mario, Game.search_by_title_and_system("Mario", "NES").first
96
+ assert_equal @sonic, Game.search_by_system_and_title("Genesis", "Sonic").first
97
+ assert_equal @mario, Game.search_by_title_and_title("Mario", "Mario").first
98
+ end
99
+
100
+ should "scope consecutively" do
101
+ assert_equal @sfgen, Game.search_by_system("Genesis").search_by_title("Street Fighter").first
102
+ end
103
+
104
+ should "not generate methods for non-:string columns" do
105
+ assert_raise(NoMethodError) { Game.search_by_id }
106
+ end
107
+
108
+ should "work with #respond_to?" do
109
+ assert Game.respond_to?(:search_by_system)
110
+ assert Game.respond_to?(:search_by_title)
111
+ assert Game.respond_to?(:search_by_system_and_title)
112
+ assert Game.respond_to?(:search_by_title_and_title_and_title)
113
+
114
+ assert !Game.respond_to?(:search_by_id)
115
+ end
116
+ end
117
+ end
118
+
119
+ end
metadata CHANGED
@@ -1,62 +1,56 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: texticle
3
3
  version: !ruby/object:Gem::Version
4
- hash: 40202008246577
5
- prerelease: false
6
- segments:
7
- - 1
8
- - 0
9
- - 4
10
- - 20101004123327
11
- version: 1.0.4.20101004123327
4
+ prerelease: 4
5
+ version: 2.0.pre
12
6
  platform: ruby
13
7
  authors:
8
+ - ecin
14
9
  - Aaron Patterson
15
10
  autorequire:
16
11
  bindir: bin
17
12
  cert_chain: []
18
13
 
19
- date: 2010-10-04 00:00:00 -04:00
20
- default_executable:
14
+ date: 2011-06-14 00:00:00 Z
21
15
  dependencies:
22
16
  - !ruby/object:Gem::Dependency
23
- name: rubyforge
17
+ name: pg
24
18
  prerelease: false
25
19
  requirement: &id001 !ruby/object:Gem::Requirement
26
20
  none: false
27
21
  requirements:
28
22
  - - ">="
29
23
  - !ruby/object:Gem::Version
30
- hash: 7
31
- segments:
32
- - 2
33
- - 0
34
- - 4
35
- version: 2.0.4
24
+ version: 0.11.0
36
25
  type: :development
37
26
  version_requirements: *id001
38
27
  - !ruby/object:Gem::Dependency
39
- name: hoe
28
+ name: shoulda
40
29
  prerelease: false
41
30
  requirement: &id002 !ruby/object:Gem::Requirement
42
31
  none: false
43
32
  requirements:
44
33
  - - ">="
45
34
  - !ruby/object:Gem::Version
46
- hash: 19
47
- segments:
48
- - 2
49
- - 6
50
- - 2
51
- version: 2.6.2
35
+ version: 2.11.3
52
36
  type: :development
53
37
  version_requirements: *id002
38
+ - !ruby/object:Gem::Dependency
39
+ name: activerecord
40
+ prerelease: false
41
+ requirement: &id003 !ruby/object:Gem::Requirement
42
+ none: false
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 3.0.0
47
+ type: :runtime
48
+ version_requirements: *id003
54
49
  description: |-
55
- Texticle exposes full text search capabilities from PostgreSQL, and allows
56
- you to declare full text indexes. Texticle will extend ActiveRecord with
57
- named_scope methods making searching easy and fun!
50
+ Texticle exposes full text search capabilities from PostgreSQL, extending
51
+ ActiveRecord with scopes making search easy and fun!
58
52
  email:
59
- - aaronp@rubyforge.org
53
+ - ecin@copypastel.com
60
54
  executables: []
61
55
 
62
56
  extensions: []
@@ -66,21 +60,16 @@ extra_rdoc_files:
66
60
  - CHANGELOG.rdoc
67
61
  - README.rdoc
68
62
  files:
69
- - .autotest
70
63
  - CHANGELOG.rdoc
71
64
  - Manifest.txt
72
65
  - README.rdoc
73
66
  - Rakefile
74
67
  - lib/texticle.rb
75
- - lib/texticle/full_text_index.rb
76
- - lib/texticle/railtie.rb
77
- - lib/texticle/tasks.rb
78
- - rails/init.rb
79
- - test/helper.rb
80
- - test/test_full_text_index.rb
81
- - test/test_texticle.rb
82
- has_rdoc: true
83
- homepage: http://texticle.rubyforge.org/
68
+ - lib/texticle/rails.rb
69
+ - spec/spec_helper.rb
70
+ - spec/texticle_spec.rb
71
+ - spec/config.yml
72
+ homepage: http://tenderlove.github.com/texticle
84
73
  licenses: []
85
74
 
86
75
  post_install_message:
@@ -94,26 +83,21 @@ required_ruby_version: !ruby/object:Gem::Requirement
94
83
  requirements:
95
84
  - - ">="
96
85
  - !ruby/object:Gem::Version
97
- hash: 3
98
- segments:
99
- - 0
100
86
  version: "0"
101
87
  required_rubygems_version: !ruby/object:Gem::Requirement
102
88
  none: false
103
89
  requirements:
104
90
  - - ">="
105
91
  - !ruby/object:Gem::Version
106
- hash: 3
107
- segments:
108
- - 0
109
92
  version: "0"
110
93
  requirements: []
111
94
 
112
95
  rubyforge_project: texticle
113
- rubygems_version: 1.3.7
96
+ rubygems_version: 1.7.2
114
97
  signing_key:
115
98
  specification_version: 3
116
- summary: Texticle exposes full text search capabilities from PostgreSQL, and allows you to declare full text indexes
99
+ summary: Texticle exposes full text search capabilities from PostgreSQL
117
100
  test_files:
118
- - test/test_full_text_index.rb
119
- - test/test_texticle.rb
101
+ - spec/spec_helper.rb
102
+ - spec/texticle_spec.rb
103
+ - spec/config.yml
data/.autotest DELETED
@@ -1,23 +0,0 @@
1
- # -*- ruby -*-
2
-
3
- require 'autotest/restart'
4
-
5
- # Autotest.add_hook :initialize do |at|
6
- # at.extra_files << "../some/external/dependency.rb"
7
- #
8
- # at.libs << ":../some/external"
9
- #
10
- # at.add_exception 'vendor'
11
- #
12
- # at.add_mapping(/dependency.rb/) do |f, _|
13
- # at.files_matching(/test_.*rb$/)
14
- # end
15
- #
16
- # %w(TestA TestB).each do |klass|
17
- # at.extra_class_map[klass] = "test/test_misc.rb"
18
- # end
19
- # end
20
-
21
- # Autotest.add_hook :run_command do |at|
22
- # system "rake build"
23
- # end
@@ -1,58 +0,0 @@
1
- module Texticle
2
- class FullTextIndex # :nodoc:
3
- attr_accessor :index_columns
4
-
5
- def initialize name, dictionary, model_class, &block
6
- @name = name
7
- @dictionary = dictionary
8
- @model_class = model_class
9
- @index_columns = {}
10
- @string = nil
11
- instance_eval(&block)
12
- end
13
-
14
- def self.find_constant_of(filename)
15
- File.basename(filename, '.rb').pluralize.classify.constantize
16
- end
17
-
18
- def create
19
- @model_class.connection.execute create_sql
20
- end
21
-
22
- def destroy
23
- @model_class.connection.execute destroy_sql
24
- end
25
-
26
- def create_sql
27
- <<-eosql.chomp
28
- CREATE index #{@name}
29
- ON #{@model_class.table_name}
30
- USING gin((#{to_s}))
31
- eosql
32
- end
33
-
34
- def destroy_sql
35
- "DROP index IF EXISTS #{@name}"
36
- end
37
-
38
- def to_s
39
- return @string if @string
40
- vectors = []
41
- @index_columns.sort_by { |k,v| k }.each do |weight, columns|
42
- c = columns.map { |x| "coalesce(\"#{@model_class.table_name}\".\"#{x}\", '')" }
43
- if weight == 'none'
44
- vectors << "to_tsvector('#{@dictionary}', #{c.join(" || ' ' || ")})"
45
- else
46
- vectors <<
47
- "setweight(to_tsvector('#{@dictionary}', #{c.join(" || ' ' || ")}), '#{weight}')"
48
- end
49
- end
50
- @string = vectors.join(" || ' ' || ")
51
- end
52
-
53
- def method_missing name, *args
54
- weight = args.shift || 'none'
55
- (index_columns[weight] ||= []) << name.to_s
56
- end
57
- end
58
- end
@@ -1,90 +0,0 @@
1
- require 'rake'
2
- require 'texticle'
3
-
4
- namespace :texticle do
5
- desc "Create full text index migration"
6
- task :migration => :environment do
7
- now = Time.now.utc
8
- filename = "#{now.strftime('%Y%m%d%H%M%S')}_full_text_search_#{now.to_i}.rb"
9
- File.open(Rails.root + 'db' + 'migrate' + filename, 'wb') do |fh|
10
- up_sql_statements = []
11
- dn_sql_statements = []
12
-
13
- Dir[Rails.root + 'app' + 'models' + '*.rb'].each do |f|
14
- klass = Texticle::FullTextIndex.find_constant_of(f)
15
- if klass.respond_to?(:full_text_indexes)
16
- (klass.full_text_indexes || []).each do |fti|
17
- up_sql_statements << fti.destroy_sql
18
- up_sql_statements << fti.create_sql
19
- dn_sql_statements << fti.destroy_sql
20
- end
21
- end
22
- end
23
-
24
- fh.puts "class FullTextSearch#{now.to_i} < ActiveRecord::Migration"
25
- fh.puts " def self.up"
26
- insert_sql_statements_into_migration_file(up_sql_statements, fh)
27
- fh.puts " end"
28
- fh.puts ""
29
-
30
- fh.puts " def self.down"
31
- insert_sql_statements_into_migration_file(dn_sql_statements, fh)
32
- fh.puts " end"
33
- fh.puts "end"
34
- end
35
- end
36
-
37
- desc "Create full text indexes"
38
- task :create_indexes => ['texticle:destroy_indexes'] do
39
- Dir[Rails.root + 'app' + 'models' + '*.rb'].each do |f|
40
- klass = Texticle::FullTextIndex.find_constant_of(f)
41
- if klass.respond_to?(:full_text_indexes)
42
- (klass.full_text_indexes || []).each do |fti|
43
- begin
44
- fti.create
45
- rescue ActiveRecord::StatementInvalid => e
46
- warn "WARNING: Couldn't create index for #{klass.to_s}, skipping..."
47
- end
48
- end
49
- end
50
- end
51
- end
52
-
53
- desc "Destroy full text indexes"
54
- task :destroy_indexes => [:environment] do
55
- Dir[Rails.root + 'app' + 'models' + '*.rb'].each do |f|
56
- klass = Texticle::FullTextIndex.find_constant_of(f)
57
- if klass.respond_to?(:full_text_indexes)
58
- (klass.full_text_indexes || []).each do |fti|
59
- fti.destroy
60
- end
61
- end
62
- end
63
- end
64
-
65
- desc "Install trigram text search module"
66
- task :install_trigram => [:environment] do
67
- share_dir = `pg_config --sharedir`.chomp
68
- raise RuntimeError, 'cannot find Postgres\' shared directory' unless $?.exitstatus.zero?
69
- trigram = "#{share_dir}/contrib/pg_trgm.sql"
70
- unless system("ls #{trigram}")
71
- raise RuntimeError, 'cannot find trigram module; was it compiled and installed?'
72
- end
73
- db_name = ActiveRecord::Base.connection.current_database
74
- unless system("psql -d #{db_name} -f #{trigram}")
75
- raise RuntimeError, "`psql -d #{db_name} -f #{trigram}` cannot complete successfully"
76
- end
77
- puts "Trigram text search module successfully installed into '#{db_name}' database."
78
- end
79
-
80
- def insert_sql_statements_into_migration_file statements, fh
81
- statements.each do |statement|
82
- fh.puts <<-eostmt
83
- execute(<<-'eosql'.strip)
84
- #{statement}
85
- eosql
86
- eostmt
87
- end
88
- end
89
- end
90
-
data/rails/init.rb DELETED
@@ -1,3 +0,0 @@
1
- require 'texticle'
2
-
3
- ActiveRecord::Base.extend(Texticle)
data/test/helper.rb DELETED
@@ -1,44 +0,0 @@
1
- require "test/unit"
2
- require "texticle"
3
-
4
- class TexticleTestCase < Test::Unit::TestCase
5
- unless RUBY_VERSION >= '1.9'
6
- undef :default_test
7
- end
8
-
9
- def setup
10
- warn "#{name}" if ENV['TESTOPTS'] == '-v'
11
- end
12
-
13
- def fake_model
14
- Class.new do
15
- @connected = false
16
- @executed = []
17
- @named_scopes = []
18
-
19
- class << self
20
- attr_accessor :connected, :executed, :named_scopes
21
-
22
- def connection
23
- @connected = true
24
- self
25
- end
26
-
27
- def execute sql
28
- @executed << sql
29
- end
30
-
31
- def table_name; 'fake_model'; end
32
-
33
- def named_scope *args
34
- @named_scopes << args
35
- end
36
- alias :scope :named_scope
37
-
38
- def quote thing
39
- "'#{thing}'"
40
- end
41
- end
42
- end
43
- end
44
- end
@@ -1,74 +0,0 @@
1
- require 'helper'
2
-
3
- class TestFullTextIndex < TexticleTestCase
4
- def setup
5
- super
6
- @fm = fake_model
7
- end
8
-
9
- def test_initialize
10
- fti = Texticle::FullTextIndex.new('ft_index', 'english', @fm) do
11
- name
12
- value 'A'
13
- end
14
- assert_equal 'name', fti.index_columns['none'].first
15
- assert_equal 'value', fti.index_columns['A'].first
16
- end
17
-
18
- def test_destroy
19
- fti = Texticle::FullTextIndex.new('ft_index', 'english', @fm) do
20
- name
21
- value 'A'
22
- end
23
- fti.destroy
24
- assert @fm.connected
25
- assert_equal 1, @fm.executed.length
26
- executed = @fm.executed.first
27
- assert_match "DROP index IF EXISTS #{fti.instance_variable_get(:@name)}", executed
28
- end
29
-
30
- def test_create
31
- fti = Texticle::FullTextIndex.new('ft_index', 'english', @fm) do
32
- name
33
- value 'A'
34
- end
35
- fti.create
36
- assert @fm.connected
37
- assert_equal 1, @fm.executed.length
38
- executed = @fm.executed.first
39
- assert_match fti.to_s, executed
40
- assert_match "CREATE index #{fti.instance_variable_get(:@name)}", executed
41
- assert_match "ON #{@fm.table_name}", executed
42
- end
43
-
44
- def test_to_s_no_weight
45
- fti = Texticle::FullTextIndex.new('ft_index', 'english', @fm) do
46
- name
47
- end
48
- assert_equal "to_tsvector('english', coalesce(\"#{@fm.table_name}\".\"name\", ''))", fti.to_s
49
- end
50
-
51
- def test_to_s_A_weight
52
- fti = Texticle::FullTextIndex.new('ft_index', 'english', @fm) do
53
- name 'A'
54
- end
55
- assert_equal "setweight(to_tsvector('english', coalesce(\"#{@fm.table_name}\".\"name\", '')), 'A')", fti.to_s
56
- end
57
-
58
- def test_to_s_multi_weight
59
- fti = Texticle::FullTextIndex.new('ft_index', 'english', @fm) do
60
- name 'A'
61
- value 'A'
62
- description 'B'
63
- end
64
- assert_equal "setweight(to_tsvector('english', coalesce(\"#{@fm.table_name}\".\"name\", '') || ' ' || coalesce(\"#{@fm.table_name}\".\"value\", '')), 'A') || ' ' || setweight(to_tsvector('english', coalesce(\"#{@fm.table_name}\".\"description\", '')), 'B')", fti.to_s
65
- end
66
-
67
- def test_mixed_weight
68
- fti = Texticle::FullTextIndex.new('ft_index', 'english', @fm) do
69
- name
70
- value 'A'
71
- end
72
- assert_equal "setweight(to_tsvector('english', coalesce(\"#{@fm.table_name}\".\"value\", '')), 'A') || ' ' || to_tsvector('english', coalesce(\"#{@fm.table_name}\".\"name\", ''))", fti.to_s
73
- end
74
- end
@@ -1,76 +0,0 @@
1
- require 'helper'
2
-
3
- class TestTexticle < TexticleTestCase
4
- def test_index_method
5
- x = fake_model
6
- x.class_eval do
7
- extend Texticle
8
- index do
9
- name
10
- end
11
- end
12
- assert_equal 1, x.full_text_indexes.length
13
- # One named_scope for search, another for trigram search
14
- assert_equal 2, x.named_scopes.length
15
-
16
- x.full_text_indexes.first.create
17
- assert_match "#{x.table_name}_fts_idx", x.executed.first
18
- assert_equal :search, x.named_scopes.first.first
19
- assert_equal :tsearch, x.named_scopes[1].first
20
- end
21
-
22
- def test_named_index
23
- x = fake_model
24
- x.class_eval do
25
- extend Texticle
26
- index('awesome') do
27
- name
28
- end
29
- end
30
- assert_equal 1, x.full_text_indexes.length
31
- assert_equal 2, x.named_scopes.length
32
-
33
- x.full_text_indexes.first.create
34
- assert_match "#{x.table_name}_awesome_fts_idx", x.executed.first
35
- assert_equal :search_awesome, x.named_scopes.first.first
36
- assert_equal :tsearch_awesome, x.named_scopes[1].first
37
- end
38
-
39
- def test_named_scope_select
40
- x = fake_model
41
- x.class_eval do
42
- extend Texticle
43
- index('awesome') do
44
- name
45
- end
46
- end
47
- ns = x.named_scopes.first[1].call('foo')
48
- assert_match(/^#{x.table_name}\.\*/, ns[:select])
49
- end
50
-
51
- def test_double_quoted_queries
52
- x = fake_model
53
- x.class_eval do
54
- extend Texticle
55
- index('awesome') do
56
- name
57
- end
58
- end
59
-
60
- ns = x.named_scopes.first[1].call('foo bar "foo bar"')
61
- assert_match(/'foo' & 'bar' & 'foo bar'/, ns[:select])
62
- end
63
-
64
- def test_wildcard_queries
65
- x = fake_model
66
- x.class_eval do
67
- extend Texticle
68
- index('awesome') do
69
- name
70
- end
71
- end
72
-
73
- ns = x.named_scopes.first[1].call('foo bar*')
74
- assert_match(/'foo' & 'bar:*'/, ns[:select])
75
- end
76
- end