birddog 0.0.1
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/.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:
|