minidusen 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,25 @@
1
+ module Minidusen
2
+ class Token
3
+
4
+ attr_reader :field, :value, :exclude
5
+
6
+ def initialize(options)
7
+ @value = options.fetch(:value)
8
+ @exclude = options.fetch(:exclude)
9
+ @field = options.fetch(:field).to_s
10
+ end
11
+
12
+ def to_s
13
+ value
14
+ end
15
+
16
+ def text?
17
+ field == 'text'
18
+ end
19
+
20
+ def exclude?
21
+ exclude
22
+ end
23
+
24
+ end
25
+ end
@@ -0,0 +1,55 @@
1
+ module Minidusen
2
+ module Util
3
+ extend self
4
+
5
+ def postgres?(scope)
6
+ adapter_name = scope.connection.class.name
7
+ adapter_name =~ /postgres/i
8
+ end
9
+
10
+ def like_expression(phrase)
11
+ "%#{escape_for_like_query(phrase)}%"
12
+ end
13
+
14
+ def ilike_operator(scope)
15
+ if postgres?(scope)
16
+ 'ILIKE'
17
+ else
18
+ 'LIKE'
19
+ end
20
+ end
21
+
22
+ def regexp_operator(scope)
23
+ if postgres?(scope)
24
+ '~'
25
+ else
26
+ 'REGEXP'
27
+ end
28
+ end
29
+
30
+ def escape_with_backslash(phrase, characters)
31
+ characters << '\\'
32
+ pattern = /[#{characters.collect(&Regexp.method(:quote)).join('')}]/
33
+ # debugger
34
+ phrase.gsub(pattern) do |match|
35
+ "\\#{match}"
36
+ end
37
+ end
38
+
39
+ def escape_for_like_query(phrase)
40
+ # phrase.gsub("%", "\\%").gsub("_", "\\_")
41
+ escape_with_backslash(phrase, ['%', '_'])
42
+ end
43
+
44
+ def qualify_column_name(model, column_name)
45
+ column_name = column_name.to_s
46
+ unless column_name.include?('.')
47
+ quoted_table_name = model.connection.quote_table_name(model.table_name)
48
+ quoted_column_name = model.connection.quote_column_name(column_name)
49
+ column_name = "#{quoted_table_name}.#{quoted_column_name}"
50
+ end
51
+ column_name
52
+ end
53
+
54
+ end
55
+ end
@@ -0,0 +1,3 @@
1
+ module Minidusen
2
+ VERSION = '0.7.0'
3
+ end
data/minidusen.gemspec ADDED
@@ -0,0 +1,23 @@
1
+ $:.push File.expand_path("../lib", __FILE__)
2
+ require "minidusen/version"
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = 'minidusen'
6
+ s.version = Minidusen::VERSION
7
+ s.authors = ["Henning Koch"]
8
+ s.email = 'henning.koch@makandra.de'
9
+ s.homepage = 'https://github.com/makandra/minidusen'
10
+ s.summary = 'Low-tech search for ActiveRecord with MySQL or PostgreSQL'
11
+ s.description = s.summary
12
+ s.license = 'MIT'
13
+
14
+ s.files = `git ls-files`.split("\n").reject { |path| File.lstat(path).symlink? }
15
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n").reject { |path| File.lstat(path).symlink? }
16
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
17
+ s.require_paths = ["lib"]
18
+
19
+ s.add_dependency('activesupport', '>=3.2')
20
+ s.add_dependency('activerecord', '>=3.2')
21
+ s.add_dependency('edge_rider', '>=0.2.5')
22
+
23
+ end
@@ -0,0 +1,45 @@
1
+ describe ActiveRecord::Base do
2
+
3
+ describe '.where_like' do
4
+
5
+ it 'matches a record if a word appears in any of the given columns' do
6
+ match1 = User.create!(:name => 'word', :city => 'XXXX')
7
+ match2 = User.create!(:name => 'XXXX', :city => 'word')
8
+ no_match = User.create!(:name => 'XXXX', :city => 'XXXX')
9
+ User.where_like([:name, :city] => 'word').to_a.should =~ [match1, match2]
10
+ end
11
+
12
+ it 'matches a record if it contains all the given words' do
13
+ match1 = User.create!(:city => 'word1 word2')
14
+ match2 = User.create!(:city => 'word2 word1')
15
+ no_match = User.create!(:city => 'word1')
16
+ User.where_like(:city => ['word1', 'word2']).to_a.should =~ [match1, match2]
17
+ end
18
+
19
+ describe 'with :negate option' do
20
+
21
+ it 'rejects a record if a word appears in any of the given columns' do
22
+ no_match1 = User.create!(:name => 'word', :city => 'XXXX')
23
+ no_match2 = User.create!(:name => 'XXXX', :city => 'word')
24
+ match = User.create!(:name => 'XXXX', :city => 'XXXX')
25
+ User.where_like({ [:name, :city] => 'word' }, :negate => true).to_a.should =~ [match]
26
+ end
27
+
28
+ it 'rejects a record if it matches at least one of the given words' do
29
+ no_match1 = User.create!(:city => 'word1')
30
+ no_match2 = User.create!(:city => 'word2')
31
+ match = User.create!(:city => 'word3')
32
+ User.where_like({ :city => ['word1', 'word2'] }, :negate => true).to_a.should =~ [match]
33
+ end
34
+
35
+ it "doesn't match NULL values" do
36
+ no_match = User.create!(:city => nil)
37
+ match = User.create!(:city => 'word3')
38
+ User.where_like({ :city => ['word1'] }, :negate => true).to_a.should =~ [match]
39
+ end
40
+
41
+ end
42
+
43
+ end
44
+
45
+ end
@@ -0,0 +1,196 @@
1
+ describe Minidusen::Filter do
2
+
3
+ let :user_filter do
4
+ UserFilter.new
5
+ end
6
+
7
+ let :recipe_filter do
8
+ RecipeFilter.new
9
+ end
10
+
11
+ describe '#filter' do
12
+
13
+ it 'should find records by given words' do
14
+ match = User.create!(:name => 'Abraham')
15
+ no_match = User.create!(:name => 'Elizabath')
16
+ user_filter.filter(User, 'Abraham').to_a.should == [match]
17
+ end
18
+
19
+ it 'should make a case-insensitive search' do
20
+ match = User.create!(:name => 'Abraham')
21
+ no_match = User.create!(:name => 'Elizabath')
22
+ user_filter.filter(User, 'aBrAhAm').to_a.should == [match]
23
+ end
24
+
25
+ it 'should not find stale text after fields were updated (bugfix)' do
26
+ match = User.create!(:name => 'Abraham')
27
+ no_match = User.create!(:name => 'Elizabath')
28
+ match.update_attributes!(:name => 'Johnny')
29
+ user_filter.filter(User, 'Abraham').to_a.should be_empty
30
+ user_filter.filter(User, 'Johnny').to_a.should == [match]
31
+ end
32
+
33
+ it 'should AND multiple words' do
34
+ match = User.create!(:name => 'Abraham Lincoln')
35
+ no_match = User.create!(:name => 'Abraham')
36
+ user_filter.filter(User, 'Abraham Lincoln').to_a.should == [match]
37
+ end
38
+
39
+ it 'should find records by phrases' do
40
+ match = User.create!(:name => 'Abraham Lincoln')
41
+ no_match = User.create!(:name => 'Abraham John Lincoln')
42
+ user_filter.filter(User, '"Abraham Lincoln"').to_a.should == [match]
43
+ end
44
+
45
+ it 'should find records by qualified fields' do
46
+ match = User.create!(:name => 'foo@bar.com', :email => 'foo@bar.com')
47
+ no_match = User.create!(:name => 'foo@bar.com', :email => 'bam@baz.com')
48
+ user_filter.filter(User, 'email:foo@bar.com').to_a.should == [match]
49
+ end
50
+
51
+ it 'should find no records if a nonexistent qualifier is used' do
52
+ User.create!(:name => 'someuser', :email => 'foo@bar.com')
53
+ user_filter.filter(User, 'nonexistent_qualifier:someuser email:foo@bar.com').to_a.should == []
54
+ end
55
+
56
+ it 'should allow phrases as values for qualified field queries' do
57
+ match = User.create!(:name => 'Foo Bar', :city => 'Foo Bar')
58
+ no_match = User.create!(:name => 'Foo Bar', :city => 'Bar Foo')
59
+ user_filter.filter(User, 'city:"Foo Bar"').to_a.should == [match]
60
+ end
61
+
62
+ it 'should allow to mix multiple types of tokens in a single query' do
63
+ match = User.create!(:name => 'Abraham', :city => 'Foohausen')
64
+ no_match = User.create!(:name => 'Abraham', :city => 'Barhausen')
65
+ user_filter.filter(User, 'Foo city:Foohausen').to_a.should == [match]
66
+ end
67
+
68
+ it 'should not find records from another model' do
69
+ match = User.create!(:name => 'Abraham')
70
+ Recipe.create!(:name => 'Abraham')
71
+ user_filter.filter(User, 'Abraham').to_a.should == [match]
72
+ end
73
+
74
+ it 'should find words where one letter is separated from other letters by a period' do
75
+ match = User.create!(:name => 'E.ONNNEN')
76
+ user_filter.filter(User, 'E.ONNNEN').to_a.should == [match]
77
+ end
78
+
79
+ it 'should find words where one letter is separated from other letters by a semicolon' do
80
+ match = User.create!(:name => 'E;ONNNEN')
81
+ user_filter.filter(User, 'E;ONNNEN')
82
+ user_filter.filter(User, 'E;ONNNEN').to_a.should == [match]
83
+ end
84
+
85
+ it 'should distinguish between "Baden" and "Baden-Baden" (bugfix)' do
86
+ match = User.create!(:city => 'Baden-Baden')
87
+ no_match = User.create!(:city => 'Baden')
88
+ user_filter.filter(User, 'Baden-Baden').to_a.should == [match]
89
+ end
90
+
91
+ it 'should handle umlauts and special characters' do
92
+ match = User.create!(:city => 'púlvérìsätëûr')
93
+ user_filter.filter(User, 'púlvérìsätëûr').to_a.should == [match]
94
+ end
95
+
96
+ context 'with excludes' do
97
+
98
+ it 'should exclude words with prefix - (minus)' do
99
+ match = User.create!(:name => 'Sunny Flower')
100
+ no_match = User.create!(:name => 'Sunny Power')
101
+ no_match2 = User.create!(:name => 'Absolutly no match')
102
+ user_filter.filter(User, 'Sunny -Power').to_a.should == [match]
103
+ end
104
+
105
+ it 'should exclude phrases with prefix - (minus)' do
106
+ match = User.create!(:name => 'Buch Tastatur Schreibtisch')
107
+ no_match = User.create!(:name => 'Buch Schreibtisch Tastatur')
108
+ no_match2 = User.create!(:name => 'Absolutly no match')
109
+ user_filter.filter(User, 'Buch -"Schreibtisch Tastatur"').to_a.should == [match]
110
+ end
111
+
112
+ it 'should exclude qualified fields with prefix - (minus)' do
113
+ match = User.create!(:name => 'Abraham', :city => 'Foohausen')
114
+ no_match = User.create!(:name => 'Abraham', :city => 'Barhausen')
115
+ no_match2 = User.create!(:name => 'Absolutly no match')
116
+ user_filter.filter(User, 'Abraham -city:Barhausen').to_a.should == [match]
117
+ end
118
+
119
+ it 'should work if the query only contains excluded words' do
120
+ match = User.create!(:name => 'Sunny Flower')
121
+ no_match = User.create!(:name => 'Sunny Power')
122
+ user_filter.filter(User, '-Power').to_a.should == [match]
123
+ end
124
+
125
+ it 'should work if the query only contains excluded phrases' do
126
+ match = User.create!(:name => 'Buch Tastatur Schreibtisch')
127
+ no_match = User.create!(:name => 'Buch Schreibtisch Tastatur')
128
+ user_filter.filter(User, '-"Schreibtisch Tastatur"').to_a.should == [match]
129
+ end
130
+
131
+ it 'should work if the query only contains excluded qualified fields' do
132
+ match = User.create!(:name => 'Abraham', :city => 'Foohausen')
133
+ no_match = User.create!(:name => 'Abraham', :city => 'Barhausen')
134
+ user_filter.filter(User, '-city:Barhausen').to_a.should == [match]
135
+ end
136
+
137
+ it 'respects an existing scope chain when there are only excluded tokens (bugfix)' do
138
+ match = User.create!(:name => 'Abraham', :city => 'Foohausen')
139
+ no_match = User.create!(:name => 'Abraham', :city => 'Barhausen')
140
+ also_no_match = User.create!(:name => 'Bebraham', :city => 'Foohausen')
141
+ user_scope = User.scoped(:conditions => { :name => 'Abraham' })
142
+ user_filter.filter(user_scope, '-Barhausen').to_a.should == [match]
143
+ end
144
+
145
+ it 'should work if there are fields contained in the search that are NULL' do
146
+ match = User.create!(:name => 'Sunny Flower', :city => nil, :email => nil)
147
+ no_match = User.create!(:name => 'Sunny Power', :city => nil, :email => nil)
148
+ no_match2 = User.create!(:name => 'Absolutly no match')
149
+ user_filter.filter(User, 'Sunny -Power').to_a.should == [match]
150
+ end
151
+
152
+ it 'should work if search_by contains a join (bugfix)' do
153
+ category1 = Recipe::Category.create!(:name => 'Rice')
154
+ category2 = Recipe::Category.create!(:name => 'Barbecue')
155
+ match = Recipe.create!(:name => 'Martini Chicken', :category => category1)
156
+ no_match = Recipe.create!(:name => 'Barbecue Chicken', :category => category2)
157
+ recipe_filter.filter(Recipe, 'Chicken -category:Barbecue').to_a.should == [match]
158
+ end
159
+
160
+ it 'should work when search_by uses SQL-Regexes which need to be "and"ed together by syntax#build_exclude_scope (bugfix)' do
161
+ match = User.create!(:name => 'Sunny Flower', :city => "Flower")
162
+ no_match = User.create!(:name => 'Sunny Power', :city => "Power")
163
+ user_filter.filter(User, '-name_and_city_regex:Power').to_a.should == [match]
164
+ end
165
+
166
+ end
167
+
168
+ context 'when the given query is blank' do
169
+
170
+ it 'returns all records' do
171
+ match = User.create!
172
+ user_filter.filter(User, '').scoped.to_a.should == [match]
173
+ end
174
+
175
+ it 'respects an existing scope chain' do
176
+ match = User.create!(:name => 'Abraham')
177
+ no_match = User.create!(:name => 'Elizabath')
178
+ scope = User.scoped(:conditions => { :name => 'Abraham' })
179
+ user_filter.filter(scope, '').scoped.to_a.should == [match]
180
+ end
181
+
182
+ end
183
+
184
+ end
185
+
186
+ describe '#minidusen_syntax' do
187
+
188
+ it "should return the model's syntax definition" do
189
+ syntax = UserFilter.send(:minidusen_syntax)
190
+ syntax.should be_a(Minidusen::Syntax)
191
+ syntax.fields.keys.should =~ ['text', 'email', 'city', 'role', 'name_and_city_regex']
192
+ end
193
+
194
+ end
195
+
196
+ end
@@ -0,0 +1,22 @@
1
+ describe Minidusen::Parser do
2
+
3
+ describe '.parse' do
4
+
5
+ it 'should parse field tokens first, because they usually give maximum filtering at little cost' do
6
+ query = Minidusen::Parser.parse('word1 field1:field1-value word2 field2:field2-value')
7
+ query.collect(&:value).should == ['field1-value', 'field2-value', 'word1', 'word2']
8
+ end
9
+
10
+ it 'should not consider the dash to be a word boundary' do
11
+ query = Minidusen::Parser.parse('Baden-Baden')
12
+ query.collect(&:value).should == ['Baden-Baden']
13
+ end
14
+
15
+ it 'should parse umlauts and accents' do
16
+ query = Minidusen::Parser.parse('field:åöÙÔøüéíÁ "ÄüÊçñÆ ððÿáÒÉ" pulvérisateur pędzić')
17
+ query.collect(&:value).should == ['åöÙÔøüéíÁ', 'ÄüÊçñÆ ððÿáÒÉ', 'pulvérisateur', 'pędzić']
18
+ end
19
+
20
+ end
21
+
22
+ end
@@ -0,0 +1,18 @@
1
+ describe Minidusen::Query do
2
+
3
+ describe '#condensed' do
4
+
5
+ it 'should return a version of the query where all text tokens have been collapsed into a single token with an Array value' do
6
+ query = Minidusen::Parser.parse('field:value foo bar baz')
7
+ query.tokens.size.should == 4
8
+ condensed_query = query.condensed
9
+ condensed_query.tokens.size.should == 2
10
+ condensed_query[0].field.should == 'field'
11
+ condensed_query[0].value.should == 'value'
12
+ condensed_query[1].field.should == 'text'
13
+ condensed_query[1].value.should == ['foo', 'bar', 'baz']
14
+ end
15
+
16
+ end
17
+
18
+ end
@@ -0,0 +1,3 @@
1
+ describe Minidusen::Util do
2
+
3
+ end
@@ -0,0 +1,29 @@
1
+ $: << File.join(File.dirname(__FILE__), "/../../lib" )
2
+
3
+ require 'minidusen'
4
+ require 'byebug'
5
+
6
+ ActiveRecord::Base.default_timezone = :local
7
+
8
+ Dir["#{File.dirname(__FILE__)}/support/*.rb"].sort.each {|f| require f}
9
+ Dir["#{File.dirname(__FILE__)}/shared_examples/*.rb"].sort.each {|f| require f}
10
+
11
+
12
+ RSpec.configure do |config|
13
+
14
+ config.expect_with(:rspec) { |c| c.syntax = [:should, :expect] }
15
+
16
+ config.around do |example|
17
+ if example.metadata.fetch(:rollback, true)
18
+ ActiveRecord::Base.transaction do
19
+ begin
20
+ example.run
21
+ ensure
22
+ raise ActiveRecord::Rollback
23
+ end
24
+ end
25
+ else
26
+ example.run
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,50 @@
1
+ require 'yaml'
2
+
3
+ database_config_file = ENV['TRAVIS'] ? 'database.travis.yml' : 'database.yml'
4
+ database_config_file = File.join(File.dirname(__FILE__), database_config_file)
5
+ File.exists?(database_config_file) or raise "Missing database configuration file: #{database_config_file}"
6
+
7
+ database_config = YAML.load_file(database_config_file)
8
+
9
+ connection_config = {}
10
+
11
+ case ENV['BUNDLE_GEMFILE']
12
+ when /pg/, /postgres/
13
+ connection_config = database_config['postgresql'].merge(adapter: 'postgresql')
14
+ when /mysql2/
15
+ connection_config = database_config['mysql'].merge(adapter: 'mysql2', encoding: 'utf8')
16
+ else
17
+ raise "Unknown database type in Gemfile suffix: #{ENV['BUNDLE_GEMFILE']}"
18
+ end
19
+
20
+ ActiveRecord::Base.establish_connection(connection_config)
21
+
22
+
23
+ connection = ::ActiveRecord::Base.connection
24
+ connection.tables.each do |table|
25
+ connection.drop_table table
26
+ end
27
+
28
+ ActiveRecord::Migration.class_eval do
29
+
30
+ create_table :users do |t|
31
+ t.string :name
32
+ t.string :email
33
+ t.string :city
34
+ end
35
+
36
+ create_table :recipes do |t|
37
+ t.string :name
38
+ t.integer :category_id
39
+ end
40
+
41
+ create_table :recipe_ingredients do |t|
42
+ t.string :name
43
+ t.integer :recipe_id
44
+ end
45
+
46
+ create_table :recipe_categories do |t|
47
+ t.string :name
48
+ end
49
+
50
+ end