birddog 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +5 -0
- data/Gemfile +8 -0
- data/Rakefile +12 -0
- data/Readme.rdoc +67 -0
- data/birddog.gemspec +28 -0
- data/lib/birddog.rb +180 -0
- data/lib/birddog/boolean_expression.rb +16 -0
- data/lib/birddog/date_expression.rb +24 -0
- data/lib/birddog/field_conditions.rb +50 -0
- data/lib/birddog/numeric_expression.rb +35 -0
- data/lib/birddog/searchable.rb +16 -0
- data/lib/birddog/version.rb +3 -0
- data/specs/birddog_spec.rb +101 -0
- data/specs/complex_scope_spec.rb +52 -0
- data/specs/field_conditions_spec.rb +25 -0
- data/specs/spec_helper.rb +75 -0
- data/specs/support/minitest_matchers.rb +5 -0
- metadata +120 -0
data/Gemfile
ADDED
data/Rakefile
ADDED
data/Readme.rdoc
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
= Birddog
|
2
|
+
Birddog was birthed from our experience with Bloodhound (github.com/foca/bloodhound) Much of the code in Birddog is derived (lifted) from Bloodhound, but has been extended
|
3
|
+
to include aggregates/regexes/testing and is Rails 3 compatible.
|
4
|
+
|
5
|
+
Birddog and Bloodhound provide functionality to parse "natural language" into scopes for ActiveRecord. They provide the avenue to enable "users" to choose the constraints
|
6
|
+
of a scope based on the type of the scope parameters. (Kind of confusing, but super helpful for things like user defined rule based selection)
|
7
|
+
|
8
|
+
= Using Birddog
|
9
|
+
Install it
|
10
|
+
gem install birddog # => OR include it in your Gemfile
|
11
|
+
|
12
|
+
== Example
|
13
|
+
Given the following model definitions
|
14
|
+
|
15
|
+
class User < ActiveRecord::Base
|
16
|
+
include Birddog
|
17
|
+
|
18
|
+
has_many :products
|
19
|
+
|
20
|
+
birddog do |search|
|
21
|
+
search.field :first_name
|
22
|
+
search.alias_field :name, :first_name
|
23
|
+
search.field :last_name
|
24
|
+
search.field :total_product_value, :type => :decimal, :aggregate => "SUM(products.value) AS total_product_value"
|
25
|
+
search.field :insensitive_last_name, :attribute => "last_name", :case_sensitive => false
|
26
|
+
search.field :substringed_last_name, :attribute => "last_name", :match_substring => true
|
27
|
+
search.field :available_product, :type => :boolean,
|
28
|
+
:attribute => "products.available",
|
29
|
+
:include => :products
|
30
|
+
|
31
|
+
search.keyword :aggregate_user do
|
32
|
+
select(arel_table[:id]).
|
33
|
+
joins("LEFT OUTER JOIN products AS products ON (products.user_id = users.id)").
|
34
|
+
group(arel_table[:id])
|
35
|
+
end
|
36
|
+
|
37
|
+
search.text_search "first_name", "last_name", "products.name", :include => :products
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
class Product < ActiveRecord::Base
|
42
|
+
include Birddog
|
43
|
+
|
44
|
+
belongs_to :user
|
45
|
+
|
46
|
+
birddog do |search|
|
47
|
+
search.text_search "products.name", "products.value"
|
48
|
+
|
49
|
+
search.field :name, :regex => true, :wildcard => true
|
50
|
+
search.field :value, :type => :decimal
|
51
|
+
search.field :available, :type => :boolean
|
52
|
+
|
53
|
+
search.keyword :sort do |value|
|
54
|
+
{ :order => "#{search.fields[value.to_sym][:attribute]} DESC" }
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# The following will return all user ids with an aggregated "total_product_value" greater than 100
|
60
|
+
data_set = User.search("total_product_value:>100").search("aggregate_user")
|
61
|
+
|
62
|
+
You can now use birddog to query the models and run a single query chain to aggregate large datasets
|
63
|
+
|
64
|
+
There are many more aggregation and selection methods included, but this and the test suite should give you a good start.
|
65
|
+
|
66
|
+
= License
|
67
|
+
Birddog (and subsequently Bloodhound) are licensed under MIT license (Read lib/birddog.rb for full license)
|
data/birddog.gemspec
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "birddog/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "birddog"
|
7
|
+
s.version = Birddog::VERSION
|
8
|
+
s.authors = ["Brandon Dewitt"]
|
9
|
+
s.email = ["brandonsdewitt@gmail.com"]
|
10
|
+
s.homepage = ""
|
11
|
+
s.summary = %q{Birddog (derived from Bloodhound) is for natural language scope creation and user determined rule selection}
|
12
|
+
s.description = %q{Seeeeeeeee Readme}
|
13
|
+
|
14
|
+
s.rubyforge_project = "birddog"
|
15
|
+
|
16
|
+
s.files = `git ls-files`.split("\n")
|
17
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
18
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
19
|
+
s.require_paths = ["lib"]
|
20
|
+
|
21
|
+
# specify any dependencies here; for example:
|
22
|
+
s.add_development_dependency "minitest"
|
23
|
+
s.add_development_dependency "rake"
|
24
|
+
s.add_development_dependency "sqlite3-ruby"
|
25
|
+
|
26
|
+
s.add_runtime_dependency "chronic"
|
27
|
+
s.add_runtime_dependency "activerecord"
|
28
|
+
end
|
data/lib/birddog.rb
ADDED
@@ -0,0 +1,180 @@
|
|
1
|
+
# (The MIT License)
|
2
|
+
#
|
3
|
+
# Copyright (c) 2010 Nicolas Sanguinetti, http://nicolassanguinetti.info
|
4
|
+
# Copyright (c) 2012 Brandon Dewitt, http://abrandoned.com
|
5
|
+
#
|
6
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'),
|
7
|
+
# to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
8
|
+
# and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
9
|
+
#
|
10
|
+
# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
11
|
+
#
|
12
|
+
# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
13
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
14
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
15
|
+
# IN THE SOFTWARE.
|
16
|
+
|
17
|
+
require "chronic"
|
18
|
+
require "active_record"
|
19
|
+
|
20
|
+
require "birddog/version"
|
21
|
+
require "birddog/field_conditions"
|
22
|
+
require "birddog/boolean_expression"
|
23
|
+
require "birddog/date_expression"
|
24
|
+
require "birddog/numeric_expression"
|
25
|
+
require "birddog/searchable"
|
26
|
+
|
27
|
+
module Birddog
|
28
|
+
|
29
|
+
def self.included(base)
|
30
|
+
base.extend(::Birddog::Searchable)
|
31
|
+
end
|
32
|
+
|
33
|
+
class Birddog
|
34
|
+
include ::Birddog::FieldConditions
|
35
|
+
|
36
|
+
attr_reader :fields
|
37
|
+
|
38
|
+
def initialize(model)
|
39
|
+
@model = model
|
40
|
+
@fields = {}
|
41
|
+
end
|
42
|
+
|
43
|
+
def field(name, options={}, &mapping)
|
44
|
+
@fields[name] = field = {
|
45
|
+
:attribute => options.fetch(:attribute, name),
|
46
|
+
:type => options.fetch(:type, :string),
|
47
|
+
:case_sensitive => options.fetch(:case_sensitive, true),
|
48
|
+
:match_substring => options.fetch(:match_substring, false),
|
49
|
+
:regex => options.fetch(:regex, false),
|
50
|
+
:wildcard => options.fetch(:wildcard, false),
|
51
|
+
:aggregate => options.fetch(:aggregate, false),
|
52
|
+
:options => options.except(:attribute, :type, :case_sensitive, :match_substring, :regex, :wildcard, :aggregate),
|
53
|
+
:mapping => mapping || lambda{ |v| v }
|
54
|
+
}
|
55
|
+
|
56
|
+
aggregate?(name) ? define_aggregate_field(name, field) : define_field(name, field)
|
57
|
+
end
|
58
|
+
|
59
|
+
def aggregate?(name)
|
60
|
+
@fields[name].fetch(:aggregate, false)
|
61
|
+
end
|
62
|
+
|
63
|
+
def alias_field(name, other_field)
|
64
|
+
@fields[name] = @fields[other_field]
|
65
|
+
aggregate?(name) ? define_aggregate_field(name, @fields[name]) : define_field(name, @fields[name])
|
66
|
+
end
|
67
|
+
|
68
|
+
def keyword(name, &block)
|
69
|
+
define_scope(name, &block)
|
70
|
+
end
|
71
|
+
|
72
|
+
def search(query)
|
73
|
+
tokens = tokenize(query)
|
74
|
+
tokens.inject(@model) do |model, (key,value)|
|
75
|
+
key, value = "text_search", key if value.nil?
|
76
|
+
scope_for(model, key, value)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def text_search(*fields)
|
81
|
+
options = fields.extract_options!
|
82
|
+
fields = fields.map { |f| "LOWER(#{f}) LIKE :value" }.join(" OR ")
|
83
|
+
|
84
|
+
define_scope "text_search" do |value|
|
85
|
+
options.merge(:conditions => [fields, { :value => "%#{value.downcase}%" }])
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
################## PRIVATES ####################
|
90
|
+
|
91
|
+
def conditional?(value)
|
92
|
+
value.index(/[<>=]/) != nil
|
93
|
+
end
|
94
|
+
private :conditional?
|
95
|
+
|
96
|
+
def define_aggregate_field(name, field)
|
97
|
+
field[:options].merge!(:select => field[:aggregate])
|
98
|
+
|
99
|
+
define_scope(name) do |value|
|
100
|
+
if conditional?(value)
|
101
|
+
field[:options].merge(:having => setup_conditions(field, value))
|
102
|
+
else
|
103
|
+
field[:options]
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
private :define_aggregate_field
|
108
|
+
|
109
|
+
def define_field(name, field)
|
110
|
+
define_scope(name) do |value|
|
111
|
+
field[:options].merge(:conditions => setup_conditions(field, value))
|
112
|
+
end
|
113
|
+
end
|
114
|
+
private :define_field
|
115
|
+
|
116
|
+
def scope_name_for(key)
|
117
|
+
"_birddog_scope_#{key}"
|
118
|
+
end
|
119
|
+
private :scope_name_for
|
120
|
+
|
121
|
+
def define_scope(name, &scope)
|
122
|
+
@model.send(:scope, scope_name_for(name), scope)
|
123
|
+
end
|
124
|
+
private :define_scope
|
125
|
+
|
126
|
+
def cast_value(value, type)
|
127
|
+
case type
|
128
|
+
when :boolean then
|
129
|
+
BooleanExpression.parse(value)
|
130
|
+
when :float, :decimal, :integer then
|
131
|
+
NumericExpression.new(value, type)
|
132
|
+
when :date then
|
133
|
+
DateExpression.new(value)
|
134
|
+
when :time, :datetime then
|
135
|
+
Chronic.parse(value)
|
136
|
+
else
|
137
|
+
value.strip
|
138
|
+
end
|
139
|
+
end
|
140
|
+
private :cast_value
|
141
|
+
|
142
|
+
def setup_conditions(field, value)
|
143
|
+
value = cast_value(value, field[:type])
|
144
|
+
value = field[:mapping].call(value)
|
145
|
+
|
146
|
+
case field[:type]
|
147
|
+
when :string then
|
148
|
+
conditions_for_string_search(field, value)
|
149
|
+
when :float, :decimal, :integer then
|
150
|
+
conditions_for_numeric(field, value)
|
151
|
+
when :date, :datetime, :time then
|
152
|
+
conditions_for_date(field, value)
|
153
|
+
else
|
154
|
+
{ field[:attribute] => value }
|
155
|
+
end
|
156
|
+
end
|
157
|
+
private :setup_conditions
|
158
|
+
|
159
|
+
def tokenize(query)
|
160
|
+
split_tokens = query.split(":")
|
161
|
+
split_tokens.each { |tok| tok.strip! }
|
162
|
+
|
163
|
+
[[split_tokens.shift, split_tokens.join(":")]]
|
164
|
+
end
|
165
|
+
private :tokenize
|
166
|
+
|
167
|
+
def scope_for(model, key, value)
|
168
|
+
scope_name = scope_name_for(key)
|
169
|
+
|
170
|
+
if model.respond_to?(scope_name)
|
171
|
+
model.send(scope_name, value)
|
172
|
+
else
|
173
|
+
model.scoped
|
174
|
+
end
|
175
|
+
end
|
176
|
+
private :scope_for
|
177
|
+
|
178
|
+
end
|
179
|
+
|
180
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Birddog
|
2
|
+
|
3
|
+
class DateExpression
|
4
|
+
attr_reader :value, :condition
|
5
|
+
|
6
|
+
def initialize(value)
|
7
|
+
# allow additional spaces to be entered between conditions and value
|
8
|
+
value.gsub!(/\s/, '')
|
9
|
+
parts = value.scan(/(?:[=<>]+|(?:[0-9]{1,2}\/[0-9]{1,2}\/[0-9]{4}))/)[0,2]
|
10
|
+
@value = Date.parse(Chronic.parse(parts.last).to_s)
|
11
|
+
@condition = sanitize_condition(parts.first)
|
12
|
+
end
|
13
|
+
|
14
|
+
def sanitize_condition(cond)
|
15
|
+
valid = %w(= == > < <= >= <>)
|
16
|
+
valid.include?(cond) ? cond.strip : "="
|
17
|
+
end
|
18
|
+
|
19
|
+
def to_s
|
20
|
+
@value.to_s
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module Birddog
|
2
|
+
|
3
|
+
module FieldConditions
|
4
|
+
|
5
|
+
def conditions_for_string_search(field, value)
|
6
|
+
search_con = " LIKE ? "
|
7
|
+
field_to_search = "lower(#{field[:attribute]})"
|
8
|
+
value_to_search = value.downcase
|
9
|
+
|
10
|
+
if field[:case_sensitive]
|
11
|
+
field_to_search = field[:attribute]
|
12
|
+
value_to_search = value
|
13
|
+
end
|
14
|
+
|
15
|
+
if field[:match_substring]
|
16
|
+
value_to_search = "%#{value_to_search}%"
|
17
|
+
end
|
18
|
+
|
19
|
+
if field[:regex] && regexed?(value)
|
20
|
+
# TODO check db driver to determine regex operator for DB (current is Postgres)
|
21
|
+
search_con = " ~ ? "
|
22
|
+
value_to_search = value[1..value.size-2]
|
23
|
+
field_to_search = field[:attribute]
|
24
|
+
elsif field[:wildcard] && value_to_search.include?("*")
|
25
|
+
value_to_search.gsub!(/[\*]/, "%")
|
26
|
+
end
|
27
|
+
|
28
|
+
[ "#{field_to_search} #{search_con} ", value_to_search ]
|
29
|
+
end
|
30
|
+
|
31
|
+
def conditions_for_date(field, value)
|
32
|
+
[ "#{field[:attribute]} #{value.condition} ? ", value.value.strftime("%Y-%m-%d")]
|
33
|
+
end
|
34
|
+
|
35
|
+
def conditions_for_numeric(field, value)
|
36
|
+
case value.condition
|
37
|
+
when "=" then
|
38
|
+
[ "ABS(#{field[:attribute]}) >= ? AND ABS(#{field[:attribute]}) < ?", value.to_f.abs.floor, (value.to_f.abs + 1).floor ]
|
39
|
+
else
|
40
|
+
[ "#{field[:attribute]} #{value.condition} ? ", value.to_f ]
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def regexed?(value)
|
45
|
+
(value[0].chr == '/' && value[-1].chr == '/')
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Birddog
|
2
|
+
|
3
|
+
class NumericExpression
|
4
|
+
|
5
|
+
attr_reader :condition
|
6
|
+
|
7
|
+
def initialize(value, type)
|
8
|
+
# allow additional spaces to be entered between conditions and value
|
9
|
+
value.gsub!(/\s/, '')
|
10
|
+
parts = value.scan(/(?:[=<>]+|(?:-?\d|\.)+)/)[0,2]
|
11
|
+
|
12
|
+
@value = Float(parts.last)
|
13
|
+
@value = @value.to_i if type == :integer
|
14
|
+
@condition = sanitize_condition(parts.first)
|
15
|
+
end
|
16
|
+
|
17
|
+
def sanitize_condition(cond)
|
18
|
+
valid = %w(= == > < <= >= <>)
|
19
|
+
valid.include?(cond) ? cond.strip : "="
|
20
|
+
end
|
21
|
+
|
22
|
+
def to_i
|
23
|
+
@value.to_i
|
24
|
+
end
|
25
|
+
|
26
|
+
def to_f
|
27
|
+
@value.to_f
|
28
|
+
end
|
29
|
+
|
30
|
+
def to_s
|
31
|
+
@value.to_s
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe Birddog::Birddog do ####################
|
4
|
+
before :each do
|
5
|
+
User.destroy_all
|
6
|
+
Product.destroy_all
|
7
|
+
@john = User.create(:first_name => "John", :last_name => "Doe")
|
8
|
+
@ducky = @john.products.create(:name => "Rubber Duck", :value => 10)
|
9
|
+
@tv = @john.products.create(:name => "TV", :value => 200)
|
10
|
+
end
|
11
|
+
|
12
|
+
it "finds a user by first_name" do
|
13
|
+
User.scopes_for_query("first_name:John").must_include(@john)
|
14
|
+
end
|
15
|
+
|
16
|
+
it "finds a user by first_name, using an alias" do
|
17
|
+
User.scopes_for_query("name:John").must_include(@john)
|
18
|
+
end
|
19
|
+
|
20
|
+
it "finds by text search" do
|
21
|
+
Product.scopes_for_query("duck rubber").must_include(@ducky)
|
22
|
+
end
|
23
|
+
|
24
|
+
# TODO figure out the cross platform way
|
25
|
+
# it "finds by regex search" do
|
26
|
+
# Product.scopes_for_query("name : /TV/").must_include(@tv)
|
27
|
+
# end
|
28
|
+
|
29
|
+
it "finds by wildcard character" do
|
30
|
+
Product.scopes_for_query("name : T*").must_include(@tv)
|
31
|
+
end
|
32
|
+
|
33
|
+
it "finds a user by last_name, but only in a case sensitive manner" do
|
34
|
+
User.scopes_for_query("last_name:Doe").must_include(@john)
|
35
|
+
User.scopes_for_query("last_name:Doe").size.must_equal(1)
|
36
|
+
end
|
37
|
+
|
38
|
+
it "can find using case insensitive search" do
|
39
|
+
User.scopes_for_query("insensitive_last_name:doe").must_include(@john)
|
40
|
+
User.scopes_for_query("insensitive_last_name:doe").size.must_equal(1)
|
41
|
+
end
|
42
|
+
|
43
|
+
it "can find matching substrings" do
|
44
|
+
User.scopes_for_query("substringed_last_name:oe").must_include(@john)
|
45
|
+
User.scopes_for_query("substringed_last_name:oe").size.must_equal(1)
|
46
|
+
end
|
47
|
+
|
48
|
+
it "can find using key:value pairs for attributes that define a type" do
|
49
|
+
User.scopes_for_query("available_product:yes").must_include(@john)
|
50
|
+
User.scopes_for_query("available_product:no").wont_include(@john)
|
51
|
+
end
|
52
|
+
|
53
|
+
it "allows defining arbitrary keywords to create scopes" do
|
54
|
+
@john.products.scopes_for_query("sort:name").all.must_equal([@tv, @ducky])
|
55
|
+
end
|
56
|
+
|
57
|
+
describe "numeric fields" do ########################
|
58
|
+
it "searches by equals" do
|
59
|
+
@john.products.scopes_for_query("value:=10").must_include(@ducky)
|
60
|
+
@john.products.scopes_for_query("value:=10").size.must_equal(1)
|
61
|
+
end
|
62
|
+
|
63
|
+
it "searches by equality without =" do
|
64
|
+
@john.products.scopes_for_query("value:10").must_include(@ducky)
|
65
|
+
@john.products.scopes_for_query("value:10").size.must_equal(1)
|
66
|
+
end
|
67
|
+
|
68
|
+
it "searches by greater than" do
|
69
|
+
@john.products.scopes_for_query("value:>100").must_include(@tv)
|
70
|
+
@john.products.scopes_for_query("value:>100").size.must_equal(1)
|
71
|
+
end
|
72
|
+
|
73
|
+
it "searches by less than" do
|
74
|
+
@john.products.scopes_for_query("value:<100").must_include(@ducky)
|
75
|
+
@john.products.scopes_for_query("value:<100").size.must_equal(1)
|
76
|
+
end
|
77
|
+
|
78
|
+
it "searches by =>" do
|
79
|
+
@john.products.scopes_for_query("value:>=200").must_include(@tv)
|
80
|
+
@john.products.scopes_for_query("value:>=200").size.must_equal(1)
|
81
|
+
end
|
82
|
+
|
83
|
+
it "searches on negatives" do
|
84
|
+
@john.products.scopes_for_query("value:>=-200").must_include(@tv)
|
85
|
+
@john.products.scopes_for_query("value:>=-200").must_include(@ducky)
|
86
|
+
@john.products.scopes_for_query("value:>=-200").size.must_equal(2)
|
87
|
+
end
|
88
|
+
|
89
|
+
describe "spacing" do ###########################
|
90
|
+
specify { @john.products.scopes_for_query("value : >= 200").must_include(@tv) }
|
91
|
+
specify { @john.products.scopes_for_query("value : >=200").must_include(@tv) }
|
92
|
+
specify { @john.products.scopes_for_query("value: >= 200 ").must_include(@tv) }
|
93
|
+
specify { @john.products.scopes_for_query("value :>= 200").must_include(@tv) }
|
94
|
+
specify { @john.products.scopes_for_query("value:>= 200").must_include(@tv) }
|
95
|
+
specify { @john.products.scopes_for_query(" value:>= 200").must_include(@tv) }
|
96
|
+
specify { @john.products.scopes_for_query("value: >=200").must_include(@tv) }
|
97
|
+
end
|
98
|
+
|
99
|
+
end
|
100
|
+
|
101
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe Birddog::Birddog do
|
4
|
+
before :each do
|
5
|
+
User.destroy_all
|
6
|
+
Product.destroy_all
|
7
|
+
@john = User.create(:first_name => "John", :last_name => "Doe")
|
8
|
+
@ducky = @john.products.create(:name => "Rubber Duck", :value => 10)
|
9
|
+
@tv = @john.products.create(:name => "TV", :value => 200)
|
10
|
+
@bike = @john.products.create(:name => "Bike", :value => 100)
|
11
|
+
end
|
12
|
+
|
13
|
+
describe "aggregates" do
|
14
|
+
it "doesn't include HAVING clause when no condition included" do
|
15
|
+
sql = User.scopes_for_query("aggregate_user").scopes_for_query("total_product_value").to_sql
|
16
|
+
sql.wont_match(/HAVING/i)
|
17
|
+
end
|
18
|
+
|
19
|
+
it "includes HAVING clause when condition included" do
|
20
|
+
sql = User.scopes_for_query("aggregate_user").scopes_for_query("total_product_value:>0").to_sql
|
21
|
+
sql.must_match(/HAVING/i)
|
22
|
+
end
|
23
|
+
|
24
|
+
it "calculates sums" do
|
25
|
+
sum = User.scopes_for_query("aggregate_user").
|
26
|
+
scopes_for_query("total_product_value:>0")
|
27
|
+
|
28
|
+
db_sum = User.first.products.inject(0.0) {|a,n| a + n.value }
|
29
|
+
sum.first.total_product_value.must_equal(db_sum)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
describe "chained scopes" do
|
34
|
+
it "chains numeric" do
|
35
|
+
products = Product.scopes_for_query("value: > 11").
|
36
|
+
scopes_for_query("value: < 101")
|
37
|
+
|
38
|
+
products.size.must_equal(1)
|
39
|
+
products.must_include(@bike)
|
40
|
+
end
|
41
|
+
|
42
|
+
it "chains numeric/wildcard" do
|
43
|
+
products = Product.scopes_for_query("value: > 11").
|
44
|
+
scopes_for_query("value: < 300").
|
45
|
+
scopes_for_query("name: T*")
|
46
|
+
|
47
|
+
products.size.must_equal(1)
|
48
|
+
products.must_include(@tv)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe ::Birddog::FieldConditions do ##############
|
4
|
+
class RegexTestClass
|
5
|
+
extend ::Birddog::FieldConditions
|
6
|
+
end
|
7
|
+
|
8
|
+
["/regex/",
|
9
|
+
"/man on a buffalo//",
|
10
|
+
" /straight up mauled/",
|
11
|
+
"/by a cougar/ ",
|
12
|
+
" /buffalo/ "].each do |reg|
|
13
|
+
it "identifies #{reg} as regex" do
|
14
|
+
RegexTestClass.regexed?(reg.strip).must_equal(true)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
["regex/",
|
19
|
+
"/man on a buffalo",
|
20
|
+
" /straight up mauled/ and stuff"].each do |reg|
|
21
|
+
it "doesnt identify #{reg} as regex" do
|
22
|
+
RegexTestClass.regexed?(reg.strip).must_equal(false)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'bundler'
|
3
|
+
Bundler.require(:default, :development, :test)
|
4
|
+
|
5
|
+
require 'minitest/spec'
|
6
|
+
require 'minitest/autorun'
|
7
|
+
require 'minitest/pride'
|
8
|
+
require 'support/minitest_matchers'
|
9
|
+
|
10
|
+
ActiveRecord::Base.establish_connection(
|
11
|
+
:adapter => "sqlite3",
|
12
|
+
:database => "specs/test.db"
|
13
|
+
)
|
14
|
+
|
15
|
+
ActiveRecord::Base.connection.tables.each do |table|
|
16
|
+
ActiveRecord::Base.connection.drop_table(table)
|
17
|
+
end
|
18
|
+
|
19
|
+
ActiveRecord::Schema.define(:version => 1) do
|
20
|
+
create_table :users do |t|
|
21
|
+
t.string :first_name
|
22
|
+
t.string :last_name
|
23
|
+
end
|
24
|
+
|
25
|
+
create_table :products do |t|
|
26
|
+
t.string :name
|
27
|
+
t.integer :value
|
28
|
+
t.boolean :available, :default => true
|
29
|
+
t.belongs_to :user
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
class User < ActiveRecord::Base
|
34
|
+
include Birddog
|
35
|
+
|
36
|
+
has_many :products
|
37
|
+
|
38
|
+
birddog do |search|
|
39
|
+
search.field :first_name
|
40
|
+
search.alias_field :name, :first_name
|
41
|
+
search.field :last_name
|
42
|
+
search.field :total_product_value, :type => :decimal, :aggregate => "SUM(products.value) AS total_product_value"
|
43
|
+
search.field :insensitive_last_name, :attribute => "last_name", :case_sensitive => false
|
44
|
+
search.field :substringed_last_name, :attribute => "last_name", :match_substring => true
|
45
|
+
search.field :available_product, :type => :boolean,
|
46
|
+
:attribute => "products.available",
|
47
|
+
:include => :products
|
48
|
+
|
49
|
+
search.keyword :aggregate_user do
|
50
|
+
select(arel_table[:id]).
|
51
|
+
joins("LEFT OUTER JOIN products AS products ON (products.user_id = users.id)").
|
52
|
+
group(arel_table[:id])
|
53
|
+
end
|
54
|
+
|
55
|
+
search.text_search "first_name", "last_name", "products.name", :include => :products
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
class Product < ActiveRecord::Base
|
60
|
+
include Birddog
|
61
|
+
|
62
|
+
belongs_to :user
|
63
|
+
|
64
|
+
birddog do |search|
|
65
|
+
search.text_search "products.name", "products.value"
|
66
|
+
|
67
|
+
search.field :name, :regex => true, :wildcard => true
|
68
|
+
search.field :value, :type => :decimal
|
69
|
+
search.field :available, :type => :boolean
|
70
|
+
|
71
|
+
search.keyword :sort do |value|
|
72
|
+
{ :order => "#{search.fields[value.to_sym][:attribute]} DESC" }
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
metadata
ADDED
@@ -0,0 +1,120 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: birddog
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Brandon Dewitt
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-01-31 00:00:00.000000000Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: minitest
|
16
|
+
requirement: &12927140 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :development
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: *12927140
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: rake
|
27
|
+
requirement: &12926220 !ruby/object:Gem::Requirement
|
28
|
+
none: false
|
29
|
+
requirements:
|
30
|
+
- - ! '>='
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '0'
|
33
|
+
type: :development
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: *12926220
|
36
|
+
- !ruby/object:Gem::Dependency
|
37
|
+
name: sqlite3-ruby
|
38
|
+
requirement: &12867540 !ruby/object:Gem::Requirement
|
39
|
+
none: false
|
40
|
+
requirements:
|
41
|
+
- - ! '>='
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: '0'
|
44
|
+
type: :development
|
45
|
+
prerelease: false
|
46
|
+
version_requirements: *12867540
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: chronic
|
49
|
+
requirement: &12866680 !ruby/object:Gem::Requirement
|
50
|
+
none: false
|
51
|
+
requirements:
|
52
|
+
- - ! '>='
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
type: :runtime
|
56
|
+
prerelease: false
|
57
|
+
version_requirements: *12866680
|
58
|
+
- !ruby/object:Gem::Dependency
|
59
|
+
name: activerecord
|
60
|
+
requirement: &12866120 !ruby/object:Gem::Requirement
|
61
|
+
none: false
|
62
|
+
requirements:
|
63
|
+
- - ! '>='
|
64
|
+
- !ruby/object:Gem::Version
|
65
|
+
version: '0'
|
66
|
+
type: :runtime
|
67
|
+
prerelease: false
|
68
|
+
version_requirements: *12866120
|
69
|
+
description: Seeeeeeeee Readme
|
70
|
+
email:
|
71
|
+
- brandonsdewitt@gmail.com
|
72
|
+
executables: []
|
73
|
+
extensions: []
|
74
|
+
extra_rdoc_files: []
|
75
|
+
files:
|
76
|
+
- .gitignore
|
77
|
+
- Gemfile
|
78
|
+
- Rakefile
|
79
|
+
- Readme.rdoc
|
80
|
+
- birddog.gemspec
|
81
|
+
- lib/birddog.rb
|
82
|
+
- lib/birddog/boolean_expression.rb
|
83
|
+
- lib/birddog/date_expression.rb
|
84
|
+
- lib/birddog/field_conditions.rb
|
85
|
+
- lib/birddog/numeric_expression.rb
|
86
|
+
- lib/birddog/searchable.rb
|
87
|
+
- lib/birddog/version.rb
|
88
|
+
- specs/birddog_spec.rb
|
89
|
+
- specs/complex_scope_spec.rb
|
90
|
+
- specs/field_conditions_spec.rb
|
91
|
+
- specs/spec_helper.rb
|
92
|
+
- specs/support/minitest_matchers.rb
|
93
|
+
- specs/test.db
|
94
|
+
homepage: ''
|
95
|
+
licenses: []
|
96
|
+
post_install_message:
|
97
|
+
rdoc_options: []
|
98
|
+
require_paths:
|
99
|
+
- lib
|
100
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
101
|
+
none: false
|
102
|
+
requirements:
|
103
|
+
- - ! '>='
|
104
|
+
- !ruby/object:Gem::Version
|
105
|
+
version: '0'
|
106
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
107
|
+
none: false
|
108
|
+
requirements:
|
109
|
+
- - ! '>='
|
110
|
+
- !ruby/object:Gem::Version
|
111
|
+
version: '0'
|
112
|
+
requirements: []
|
113
|
+
rubyforge_project: birddog
|
114
|
+
rubygems_version: 1.8.10
|
115
|
+
signing_key:
|
116
|
+
specification_version: 3
|
117
|
+
summary: Birddog (derived from Bloodhound) is for natural language scope creation
|
118
|
+
and user determined rule selection
|
119
|
+
test_files: []
|
120
|
+
has_rdoc:
|