texticle 1.0.4.20101004123327 → 2.0.pre
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG.rdoc +24 -0
- data/README.rdoc +17 -60
- data/Rakefile +17 -11
- data/lib/texticle/{railtie.rb → rails.rb} +1 -5
- data/lib/texticle.rb +80 -107
- data/spec/config.yml +4 -0
- data/spec/spec_helper.rb +9 -0
- data/spec/texticle_spec.rb +119 -0
- metadata +32 -48
- data/.autotest +0 -23
- data/lib/texticle/full_text_index.rb +0 -58
- data/lib/texticle/tasks.rb +0 -90
- data/rails/init.rb +0 -3
- data/test/helper.rb +0 -44
- data/test/test_full_text_index.rb +0 -74
- data/test/test_texticle.rb +0 -76
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://
|
3
|
+
* http://tenderlove.github.com/texticle
|
4
4
|
|
5
5
|
== DESCRIPTION:
|
6
6
|
|
7
|
-
Texticle exposes full text search capabilities from PostgreSQL,
|
8
|
-
|
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
|
-
===
|
16
|
+
=== Configuration
|
18
17
|
|
19
|
-
|
20
|
-
|
21
|
-
gem 'texticle', '1.0.4'
|
22
|
-
|
23
|
-
=== Rails 2 Configuration
|
18
|
+
* Rails 3
|
24
19
|
|
25
|
-
In
|
20
|
+
In the project's Gemfile add
|
26
21
|
|
27
|
-
|
22
|
+
gem 'texticle', '~> 2.0rc1', :require => 'texticle/rails'
|
28
23
|
|
29
|
-
|
24
|
+
* ActiveRecord outside of Rails 3
|
30
25
|
|
31
|
-
|
32
|
-
|
26
|
+
require 'texticle'
|
27
|
+
ActiveRecord::Base.extend(Texticle)
|
33
28
|
|
34
29
|
=== Usage
|
35
30
|
|
36
|
-
|
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
|
-
|
76
|
-
|
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
|
-
*
|
39
|
+
* ActiveRecord
|
83
40
|
|
84
41
|
== INSTALL:
|
85
42
|
|
86
|
-
*
|
43
|
+
* gem install texticle
|
87
44
|
|
88
45
|
== LICENSE:
|
89
46
|
|
90
47
|
(The MIT License)
|
91
48
|
|
92
|
-
Copyright (c)
|
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
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
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
|
-
|
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(
|
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 '
|
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
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
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
|
-
|
110
|
-
|
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
data/spec/spec_helper.rb
ADDED
@@ -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
|
-
|
5
|
-
|
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:
|
20
|
-
default_executable:
|
14
|
+
date: 2011-06-14 00:00:00 Z
|
21
15
|
dependencies:
|
22
16
|
- !ruby/object:Gem::Dependency
|
23
|
-
name:
|
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
|
-
|
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:
|
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
|
-
|
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,
|
56
|
-
|
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
|
-
-
|
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/
|
76
|
-
-
|
77
|
-
-
|
78
|
-
-
|
79
|
-
|
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.
|
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
|
99
|
+
summary: Texticle exposes full text search capabilities from PostgreSQL
|
117
100
|
test_files:
|
118
|
-
-
|
119
|
-
-
|
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
|
data/lib/texticle/tasks.rb
DELETED
@@ -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
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
|
data/test/test_texticle.rb
DELETED
@@ -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
|