cloudsearchable 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/.rspec +2 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +119 -0
- data/Rakefile +11 -0
- data/cloudsearchable.gemspec +50 -0
- data/lib/cloudsearchable.rb +206 -0
- data/lib/cloudsearchable/cloud_search.rb +41 -0
- data/lib/cloudsearchable/domain.rb +159 -0
- data/lib/cloudsearchable/field.rb +56 -0
- data/lib/cloudsearchable/query_chain.rb +218 -0
- data/lib/cloudsearchable/version.rb +3 -0
- data/spec/cloudsearchable/cloud_search_spec.rb +45 -0
- data/spec/cloudsearchable/cloudsearchable_spec.rb +71 -0
- data/spec/cloudsearchable/domain_spec.rb +158 -0
- data/spec/cloudsearchable/field_spec.rb +30 -0
- data/spec/cloudsearchable/query_chain_spec.rb +305 -0
- data/spec/spec_helper.rb +10 -0
- data/spec/test_classes/cloud_searchable_test_class.rb +42 -0
- metadata +153 -0
data/.rspec
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2013 Lane LaRue
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,119 @@
|
|
1
|
+
# Cloudsearchable
|
2
|
+
An ActiveRecord-style ORM query interface for AWS Cloud Search.
|
3
|
+
|
4
|
+
## Installation
|
5
|
+
Add to your Gemfile: gem 'cloudsearchable'. Run bundle or: gem install cloudsearchable.
|
6
|
+
|
7
|
+
## Usage
|
8
|
+
### 1. Mix Cloudsearchable into your class
|
9
|
+
```ruby
|
10
|
+
class Customer
|
11
|
+
include Cloudsearchable
|
12
|
+
|
13
|
+
attr_accessor :id, :customer, :name, :lock_version
|
14
|
+
|
15
|
+
# This is the default index. You probably only need one.
|
16
|
+
index_in_cloudsearch do |idx|
|
17
|
+
literal :id, :searchable => true
|
18
|
+
end
|
19
|
+
|
20
|
+
# A named index.
|
21
|
+
index_in_cloudsearch :test_index do |idx|
|
22
|
+
|
23
|
+
# Fetch the customer_id field from customer
|
24
|
+
literal :customer_id, :returnable => true, :searchable => true, :source => Proc.new { customer }
|
25
|
+
|
26
|
+
# Map the 'name' Ruby attribute to a field called 'test_name'
|
27
|
+
text :test_name, :returnable => false, :searchable => true, :source => :name
|
28
|
+
|
29
|
+
# uint fields can be used in result ranking functions
|
30
|
+
uint :helpfulness, :returnable => true, :searchable => false do; 1234 end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
```
|
34
|
+
### 2. Index some objects
|
35
|
+
```ruby
|
36
|
+
c = Customer.new
|
37
|
+
c.add_to_indexes
|
38
|
+
c.update_indexes
|
39
|
+
c.remove_from_indexes
|
40
|
+
```
|
41
|
+
### 3. Start querying
|
42
|
+
```ruby
|
43
|
+
Customer.search.where(customer_id: 12345)
|
44
|
+
Customer.search.where(customer_id: 12345).order('-helpfulness') # ordering
|
45
|
+
Customer.search.where(customer_id: 12345).limit(10) # limit, default 100000
|
46
|
+
Customer.search.where(customer_id: 12345).offset(100) # offset
|
47
|
+
Customer.search.where(customer_id: 12345).found_count # count
|
48
|
+
|
49
|
+
Customer.search.where(customer_id: 12345).where(helpfulness: 42) # query chain
|
50
|
+
Customer.search.where(customer_id: 12345, helpfulness: 42) # query chain from hash
|
51
|
+
Customer.search.where(:category, :any, ["big", "small"]) # multiple values
|
52
|
+
Customer.search.where(:customer_id, :!=, 1234) # "not equal to" operator
|
53
|
+
Customer.search.text('test') # text search
|
54
|
+
Customer.search.text('test').where(:featured, :==, 'f') # text search with other fields
|
55
|
+
|
56
|
+
Customer.search.where(:helpfulness, :within_range, 0..123) # uint range query, string range works too
|
57
|
+
Customer.search.where(:helpfulness, :>, 123) # uint greather than
|
58
|
+
Customer.search.where(:helpfulness, :>=, 123) # uint greather than or equal to
|
59
|
+
Customer.search.where(:helpfulness, :<, 123) # uint less than
|
60
|
+
Customer.search.where(:helpfulness, :<=, 123) # uint less than or equal to
|
61
|
+
```
|
62
|
+
These queries return a Cloudsearchable::Query, calling .to_a or .found_count will fetch the results
|
63
|
+
```ruby
|
64
|
+
Customer.search.where(customer_id: 12345).each |customer|
|
65
|
+
p "#{customer.class}: #{customer.name}"
|
66
|
+
end
|
67
|
+
# Customer: foo
|
68
|
+
# Customer: bar
|
69
|
+
```
|
70
|
+
### Configuration
|
71
|
+
```ruby
|
72
|
+
# config\initializers\cloudsearchable_config.rb
|
73
|
+
|
74
|
+
require 'cloudsearchable'
|
75
|
+
|
76
|
+
Cloudsearchable.configure do |config|
|
77
|
+
config.domain_prefix = "dev-lane-"
|
78
|
+
end
|
79
|
+
```
|
80
|
+
Supported Options
|
81
|
+
* domain_prefix - A name prefix string for your domains in CloudSearch. Defaults to Rails.env, or "" if Rails is undefined.
|
82
|
+
* config.fatal_warnings - raises WarningInQueryResult exception on warning. Defaults to false.
|
83
|
+
* config.logger - a custom logger, defaults to Rails if defined.
|
84
|
+
|
85
|
+
### Other Features
|
86
|
+
|
87
|
+
Cloudsearchable provides access the underlying AWS client objects, such as '''CloudSearch.client''' and '''class.cloudsearch_domains'''. For example here is how to drop domains associated with Customer class:
|
88
|
+
|
89
|
+
```ruby
|
90
|
+
client = CloudSearch.client
|
91
|
+
Customer.cloudsearch_domains.each do |key, domain|
|
92
|
+
domain_name = domain.name
|
93
|
+
puts "...dropping #{domain_name}"
|
94
|
+
client.delete_domain(:domain_name => domain_name)
|
95
|
+
end
|
96
|
+
```
|
97
|
+
|
98
|
+
See spec tests and source code for more information.
|
99
|
+
|
100
|
+
## TODO:
|
101
|
+
- use ActiveSupport instrumentation: http://edgeguides.rubyonrails.org/active_support_instrumentation.html#creating-custom-events
|
102
|
+
|
103
|
+
## Credits
|
104
|
+
|
105
|
+
* [Logan Bowers](https://github.com/loganb)
|
106
|
+
* [Peter Abrahamsen](https://github.com/rainhead)
|
107
|
+
* [Lane LaRue](https://github.com/luxx)
|
108
|
+
* [Philip White](https://github.com/philipmw)
|
109
|
+
|
110
|
+
MIT License
|
111
|
+
|
112
|
+
## Contributing
|
113
|
+
|
114
|
+
1. Fork it
|
115
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
116
|
+
3. Run the tests (`rake spec`)
|
117
|
+
4. Commit your changes (`git commit -am 'Add some feature'`)
|
118
|
+
5. Push to the branch (`git push origin my-new-feature`)
|
119
|
+
6. Create new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
require 'rspec/core/rake_task'
|
2
|
+
RSpec::Core::RakeTask.new('spec')
|
3
|
+
|
4
|
+
# make spec test the default task
|
5
|
+
task :default => :spec
|
6
|
+
|
7
|
+
require 'yard'
|
8
|
+
YARD::Rake::YardocTask.new do |t|
|
9
|
+
t.files = ['lib/**/*.rb', "README", "LICENSE"] # optional
|
10
|
+
t.options = ['-m', 'markdown'] # optional
|
11
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'cloudsearchable/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "cloudsearchable"
|
8
|
+
spec.version = Cloudsearchable::VERSION
|
9
|
+
spec.authors = ["Lane LaRue"]
|
10
|
+
spec.email = ["llarue@amazon.com"]
|
11
|
+
spec.description = %q{ActiveRecord-like query interface for AWS Cloud Search}
|
12
|
+
spec.summary = %q{ActiveRecord-like query interface for AWS Cloud Search}
|
13
|
+
spec.homepage = ""
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
# generated with `git ls-files`.split($/)
|
17
|
+
spec.files = [
|
18
|
+
".rspec",
|
19
|
+
"Gemfile",
|
20
|
+
"LICENSE.txt",
|
21
|
+
"README.md",
|
22
|
+
"Rakefile",
|
23
|
+
"cloudsearchable.gemspec",
|
24
|
+
"lib/cloudsearchable.rb",
|
25
|
+
"lib/cloudsearchable/cloud_search.rb",
|
26
|
+
"lib/cloudsearchable/domain.rb",
|
27
|
+
"lib/cloudsearchable/field.rb",
|
28
|
+
"lib/cloudsearchable/query_chain.rb",
|
29
|
+
"lib/cloudsearchable/version.rb",
|
30
|
+
"spec/cloudsearchable/cloud_search_spec.rb",
|
31
|
+
"spec/cloudsearchable/cloudsearchable_spec.rb",
|
32
|
+
"spec/cloudsearchable/domain_spec.rb",
|
33
|
+
"spec/cloudsearchable/field_spec.rb",
|
34
|
+
"spec/cloudsearchable/query_chain_spec.rb",
|
35
|
+
"spec/spec_helper.rb",
|
36
|
+
"spec/test_classes/cloud_searchable_test_class.rb"
|
37
|
+
]
|
38
|
+
|
39
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
40
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
41
|
+
spec.require_paths = ["lib"]
|
42
|
+
|
43
|
+
spec.add_development_dependency "bundler", "~> 1.3"
|
44
|
+
spec.add_development_dependency "rake"
|
45
|
+
spec.add_dependency 'aws-sdk'
|
46
|
+
|
47
|
+
# testing dependencies
|
48
|
+
spec.add_development_dependency "rspec"
|
49
|
+
spec.add_development_dependency "activemodel"
|
50
|
+
end
|
@@ -0,0 +1,206 @@
|
|
1
|
+
require 'cloudsearchable/version'
|
2
|
+
|
3
|
+
require 'cloudsearchable/domain'
|
4
|
+
require 'cloudsearchable/field'
|
5
|
+
require 'cloudsearchable/query_chain'
|
6
|
+
require 'cloudsearchable/cloud_search'
|
7
|
+
require 'cloudsearchable/config'
|
8
|
+
|
9
|
+
require 'active_support/inflector'
|
10
|
+
require 'active_support/core_ext/string'
|
11
|
+
|
12
|
+
module Cloudsearchable
|
13
|
+
def self.configure
|
14
|
+
block_given? ? yield(Cloudsearchable::Config) : Cloudsearchable::Config
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.config
|
18
|
+
configure
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.logger
|
22
|
+
Cloudsearchable::Config.logger
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.included(base)
|
26
|
+
base.extend ClassMethods
|
27
|
+
end
|
28
|
+
|
29
|
+
def cloudsearch_domains= *args
|
30
|
+
self.class.cloudsearch_domains = args
|
31
|
+
end
|
32
|
+
|
33
|
+
def cloudsearch_domains
|
34
|
+
self.class.cloudsearch_domains
|
35
|
+
end
|
36
|
+
|
37
|
+
def update_indexes
|
38
|
+
if destroyed?
|
39
|
+
remove_from_indexes
|
40
|
+
else
|
41
|
+
add_to_indexes
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def add_to_indexes
|
46
|
+
cloudsearch_domains.map do |name, domain|
|
47
|
+
domain.post_record(self, id, lock_version)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def remove_from_indexes
|
52
|
+
cloudsearch_domains.map do |name, domain|
|
53
|
+
domain.delete_record(id, lock_version)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
protected
|
58
|
+
|
59
|
+
class DSL
|
60
|
+
attr_reader :domain, :base
|
61
|
+
|
62
|
+
def initialize domain, base
|
63
|
+
@domain = domain
|
64
|
+
@base = base
|
65
|
+
end
|
66
|
+
|
67
|
+
def uint name, options = {}, &block
|
68
|
+
field name, :uint, options, &block
|
69
|
+
end
|
70
|
+
|
71
|
+
def text name, options = {}, &block
|
72
|
+
field name, :text, options, &block
|
73
|
+
end
|
74
|
+
|
75
|
+
def literal name, options = {}, &block
|
76
|
+
field name, :literal, options, &block
|
77
|
+
end
|
78
|
+
|
79
|
+
def field name, type, options = {}, &block
|
80
|
+
# This block is executed in the context of the record
|
81
|
+
if block_given?
|
82
|
+
options[:source] = block.to_proc
|
83
|
+
end
|
84
|
+
domain.add_field name, type, options
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
module ClassMethods
|
89
|
+
def cloudsearch_domains= domains
|
90
|
+
@cloudsearch_domains = domains
|
91
|
+
end
|
92
|
+
|
93
|
+
def cloudsearch_domains
|
94
|
+
@cloudsearch_domains || {}
|
95
|
+
end
|
96
|
+
|
97
|
+
#
|
98
|
+
# Declares a Cloudsearchable index that returns a list of object of this class.
|
99
|
+
#
|
100
|
+
# @param name (optional) optional name for the index. If not specified, a default (unnamed) index for the class will be created
|
101
|
+
# @param options (optional) Hash defining an index
|
102
|
+
#
|
103
|
+
# @option options [String] :name Name of the index
|
104
|
+
#
|
105
|
+
#
|
106
|
+
def index_in_cloudsearch(name = nil, &block)
|
107
|
+
locator_field = :"#{cloudsearch_prefix.singularize}_id"
|
108
|
+
# Fetches the existing search domain, or generates a new one
|
109
|
+
unless domain = cloudsearch_domains[name]
|
110
|
+
domain = new_cloudsearch_index(name).tap do |d|
|
111
|
+
# This id field is used to reify search results
|
112
|
+
d.add_field(locator_field, :literal,
|
113
|
+
:result_enabled => true, :search_enabled => true,
|
114
|
+
:source => :id)
|
115
|
+
end
|
116
|
+
self.cloudsearch_domains = self.cloudsearch_domains.merge({name => domain})
|
117
|
+
end
|
118
|
+
|
119
|
+
if block_given?
|
120
|
+
dsl = DSL.new(domain, self)
|
121
|
+
dsl.instance_exec &block
|
122
|
+
end
|
123
|
+
|
124
|
+
# Define the search method
|
125
|
+
search_method_name = "search#{name && ('_' + name.to_s)}".to_sym
|
126
|
+
define_singleton_method search_method_name do
|
127
|
+
Query.new(self, cloudsearch_index(name), locator_field)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def cloudsearch_index name = nil
|
132
|
+
cloudsearch_domains[name]
|
133
|
+
end
|
134
|
+
|
135
|
+
#
|
136
|
+
# Prefix name used for indexes, defaults to class name underscored
|
137
|
+
#
|
138
|
+
def cloudsearch_prefix
|
139
|
+
name.pluralize.underscore.gsub('/', '_')
|
140
|
+
end
|
141
|
+
|
142
|
+
def new_cloudsearch_index name
|
143
|
+
name = [cloudsearch_prefix, name].compact.join('-').gsub('_','-')
|
144
|
+
Cloudsearchable::Domain.new name
|
145
|
+
end
|
146
|
+
|
147
|
+
# By default use 'find' to materialize items
|
148
|
+
def materialize_method method_name = nil
|
149
|
+
@materialize_method = method_name unless method_name.nil?
|
150
|
+
@materialize_method.nil? ? :find : @materialize_method
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
#
|
155
|
+
# Wraps a Cloudsearchable::QueryChain, provides methods to execute and reify
|
156
|
+
# a query into search result objects
|
157
|
+
#
|
158
|
+
class Query
|
159
|
+
include Enumerable
|
160
|
+
|
161
|
+
attr_reader :query, :class
|
162
|
+
|
163
|
+
#
|
164
|
+
# @param clazz [ActiveRecord::Model] The class of the Model object that
|
165
|
+
# is being searched. The result set will be objects of this type.
|
166
|
+
# @param domain [Domain] Cloudsearchable Domain to search
|
167
|
+
# @param identity_field [Symbol] name of the field that contains the id of
|
168
|
+
# the clazz (e.g. :collection_id)
|
169
|
+
#
|
170
|
+
def initialize(clazz, domain, identity_field)
|
171
|
+
@query = Cloudsearchable::QueryChain.new(domain, fatal_warnings: Cloudsearchable.config.fatal_warnings)
|
172
|
+
@class = clazz
|
173
|
+
@query.returning(identity_field)
|
174
|
+
@identity_field = identity_field
|
175
|
+
end
|
176
|
+
|
177
|
+
[:where, :text, :order, :limit, :offset, :returning].each do |method_name|
|
178
|
+
# Passthrough methods, see CloudSearch::Domain for docs
|
179
|
+
define_method method_name do |*args|
|
180
|
+
@query.send(method_name, *args)
|
181
|
+
self
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
# Pass through to Cloudsearchable::Domain#materialize!, then retrieve objects from database
|
186
|
+
# TODO: this does NOT preserve order!
|
187
|
+
def materialize!(*args)
|
188
|
+
@results ||= begin
|
189
|
+
record_ids = @query.map{|result_hit| result_hit['data'][@identity_field.to_s].first}.reject{|r| r.nil?}
|
190
|
+
@class.send(@class.materialize_method, record_ids)
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
def each &block
|
195
|
+
# Returns an enumerator
|
196
|
+
return enum_for(__method__) unless block_given?
|
197
|
+
materialize!
|
198
|
+
@results.respond_to?(:each) ? @results.each { |o| yield o } : [@results].send(:each, &block)
|
199
|
+
end
|
200
|
+
|
201
|
+
def found_count
|
202
|
+
query.found_count
|
203
|
+
end
|
204
|
+
|
205
|
+
end
|
206
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'aws-sdk'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module CloudSearch
|
5
|
+
API_VERSION = "2011-02-01"
|
6
|
+
|
7
|
+
def self.client
|
8
|
+
@client ||= AWS::CloudSearch::Client.new
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.client=(client)
|
12
|
+
@client = client
|
13
|
+
end
|
14
|
+
|
15
|
+
#
|
16
|
+
# Send an SDF document to CloudSearch via http post request.
|
17
|
+
# Returns parsed JSON response, or raises an exception
|
18
|
+
#
|
19
|
+
def self.post_sdf endpoint, sdf
|
20
|
+
self.post_sdf_list endpoint, [sdf]
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.post_sdf_list endpoint, sdf_list
|
24
|
+
uri = URI.parse("http://#{endpoint}/#{API_VERSION}/documents/batch")
|
25
|
+
|
26
|
+
req = Net::HTTP::Post.new(uri.path)
|
27
|
+
req.body = JSON.generate sdf_list
|
28
|
+
req["Content-Type"] = "application/json"
|
29
|
+
|
30
|
+
http = Net::HTTP.new uri.host,uri.port
|
31
|
+
response = http.start{|http| http.request(req)}
|
32
|
+
|
33
|
+
if response.is_a? Net::HTTPSuccess
|
34
|
+
JSON.parse response.body
|
35
|
+
else
|
36
|
+
# Raise an exception based on the response see http://ruby-doc.org/stdlib-1.9.2/libdoc/net/http/rdoc/Net/HTTP.html
|
37
|
+
response.error!
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
41
|
+
end
|