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 +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
|