waistband 0.0.15

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,18 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ .DS_Store
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format nested
2
+ --color
data/.rvmrc ADDED
@@ -0,0 +1,2 @@
1
+ rvm use ruby-1.9.3-p374@waistband --create
2
+ export PATH=./bin:$PATH
data/Gemfile ADDED
@@ -0,0 +1,9 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in waistband.gemspec
4
+ gemspec
5
+
6
+ gem 'rspec'
7
+ gem 'debugger'
8
+ gem 'kaminari'
9
+ gem 'timecop'
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 David Jairala
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.
@@ -0,0 +1,193 @@
1
+ # Waistband
2
+
3
+ Ruby interface to Elastic Search
4
+
5
+ ## Installation
6
+
7
+ Install ElasticSearch:
8
+
9
+ ```bash
10
+ brew install elasticsearch
11
+ ```
12
+
13
+ Add this line to your application's Gemfile:
14
+
15
+ gem 'waistband', git: 'git@github.com:taskrabbit/waistband.git', tag: 'v0.0.9'
16
+
17
+ Be sure to check out the [releases page](https://github.com/taskrabbit/waistband/releases) to get the latest tag.
18
+
19
+ And then execute:
20
+
21
+ $ bundle
22
+
23
+ Or install it yourself as:
24
+
25
+ $ gem install waistband
26
+
27
+ ## Configuration
28
+
29
+ Configuration is generally pretty simple. First, create a folder where you'll store your Waistband configuration docs, usually under `#{APP_DIR}/config/waistband/`, you can also just throw it under `#{APP_DIR}/config/` if you want. The baseline config contains something like this:
30
+
31
+ ```yml
32
+ # #{APP_DIR}/config/waistband/waistband.yml
33
+ development:
34
+ servers:
35
+ server1:
36
+ host: http://localhost
37
+ port: 9200
38
+ ```
39
+
40
+ You can name the servers whatever you want, and one of them is selected at random using `Array.sample` when initializing the configuration singleton. Here's an example with two servers:
41
+
42
+ ```yml
43
+ # #{APP_DIR}/config/waistband/waistband.yml
44
+ development:
45
+ servers:
46
+ server1:
47
+ host: http://173.247.192.214
48
+ port: 9200
49
+ server2:
50
+ host: http://173.247.192.215
51
+ port: 9200
52
+ ```
53
+
54
+ You'll need a separate config file for each index you use, containing the index settings and mappings. For example, for my search index, I use something akin to this:
55
+
56
+ ```yml
57
+ # #{APP_DIR}/config/waistband/waistband_search.yml
58
+ development:
59
+ name: search
60
+ stringify: false
61
+ settings:
62
+ index:
63
+ number_of_shards: 4
64
+ mappings:
65
+ event:
66
+ _source:
67
+ includes: ["*"]
68
+ ```
69
+
70
+ ## List of config settings:
71
+
72
+ * `name`: name of the index. You can (and probably should) have a different name for the index for your test environment.
73
+ * `stringify`: determines wether whatever is stored into the index is going to be converted to a string before storage. Usually false unless you need it to be true for specific cases, like if for some `key => value` pairs the value is of different types some times. If you do decide to stringify your values for an index, please read the **Stringify All** section of the docs further down.
74
+ * `settings`: settings for the Elastic Search index. Refer to the ["admin indices update settings"](http://www.elasticsearch.org/guide/reference/api/admin-indices-update-settings/) document for more info.
75
+ * `mappings`: the index mappings. More often than not you'll want to include all of the document attribute, so you'll do something like in the example above. For more info, refer to the [mapping reference]("http://www.elasticsearch.org/guide/reference/mapping/").
76
+
77
+ ## Initializer
78
+
79
+ After getting all the YML config files in place, you'll just need to hook up an initializer to these files:
80
+
81
+ ```ruby
82
+ # #{APP_DIR}/config/initializers/waistband.rb
83
+ Waistband.configure do |c|
84
+ c.config_dir = "#{APP_DIR}/spec/config/waistband"
85
+ end
86
+ ```
87
+
88
+ ## Usage
89
+
90
+ ### Indexes
91
+
92
+
93
+ #### Creating and destroying the indexes
94
+
95
+ For each index you have, you'll probably want to make sure it's created on initialization, so either in the same waistband initializer or in another initializer, depending on your preferences, you'll have to create them. For our search example:
96
+
97
+ ```ruby
98
+ # #{APP_DIR}/config/initializers/waistband.rb
99
+ # ...
100
+ Waistband::Index.new('search').create!
101
+ ```
102
+
103
+ This will create the index if it's not been created already or return nil if it already exists.
104
+
105
+ Destroying an index is equally easy:
106
+
107
+ ```ruby
108
+ Waistband::Index.new('search').destroy!
109
+ ```
110
+
111
+ When writing tests, it would generally be advisable to destroy and create the indexes in a `before(:each)` or `before(:all)` depending in your circumstances. Also, remember for testing that replication and data availability is not inmediate on the indexes, so if you create an immediate expectation for data to be there, you should refresh the index before it:
112
+
113
+ ```ruby
114
+ Waistband::Index.new('search').refresh
115
+ ```
116
+
117
+ #### Writing, reading and deleting from an index
118
+
119
+ ```ruby
120
+ index = Waistband::Index.new('search')
121
+
122
+ # writing
123
+ index.store!('my_data', {'important' => true, 'valuable' => {'always' => true}})
124
+ # => "{\"ok\":true,\"_index\":\"search\",\"_type\":\"search\",\"_id\":\"my_data\",\"_version\":1}"
125
+
126
+ # reading
127
+ index.read('my_data')
128
+ # => {"important"=>true, "valuable"=>{"always"=>true}}
129
+
130
+ # deleting
131
+ index.delete!('my_data')
132
+ # => "{\"ok\":true,\"found\":true,\"_index\":\"search\",\"_type\":\"search\",\"_id\":\"my_data\",\"_version\":2}"
133
+
134
+ # reading non-existent data
135
+ index.read('my_data')
136
+ # => nil
137
+ ```
138
+
139
+ ### Searching
140
+
141
+ For searching, Waistband has the Query class available:
142
+
143
+ ```ruby
144
+ index = Waistband::Index.new('search')
145
+ query = index.query('shopping')
146
+ query.add_fields('name', 'description') # look for the search term `shopping` in the attributes `name` and `description`
147
+ query.add_term('task', 'true') # only in documents where the attribute task is set to true
148
+
149
+ query.results # => returns an array of Waistband::QueryResult
150
+
151
+ query.total_hits
152
+ # => 28481
153
+
154
+ # get the second page of results:
155
+ query.page = 2
156
+ query.results
157
+
158
+ # change the page size:
159
+ query.page_size = 50
160
+ query.page = 1
161
+ query.results
162
+ ```
163
+
164
+ For paginating the results, you can use the `#paginated_results` method, which requires the [Kaminari](https://github.com/amatsuda/kaminari), gem. If you use another gem, you can just override the method, etc.
165
+
166
+ ### Stringify All
167
+
168
+ For convenience, Waistband provides two mixins useful to stringify Arrays and Hashes recursively in the `Waistband::StringifyAll::Array` and `Waistband::StringifyAll::Hash` modules. Feel free to include them into your Array or Hash classes as necessary in an initializer if needed.
169
+
170
+ ### Model
171
+
172
+ The gem offers a `Waistband::Model` that you can use to store model type data into the Elastic Search index. You just inherit from the class and define the following class methods:
173
+
174
+ ```ruby
175
+ class Thing < Waistband::Model
176
+ with_index :my_index_name
177
+
178
+ columns :user_id, :content, :admin_visible
179
+ defaults admin_visible: false
180
+
181
+ validates :user_id, :content
182
+ end
183
+ ```
184
+
185
+ For more information and extra methods, take a peek into the class docs.
186
+
187
+ ## Contributing
188
+
189
+ 1. Fork it
190
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
191
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
192
+ 4. Push to the branch (`git push origin my-new-feature`)
193
+ 5. Create new Pull Request
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,25 @@
1
+ require "waistband/version"
2
+
3
+ module Waistband
4
+
5
+ autoload :Configuration, "waistband/configuration"
6
+ autoload :StringifyAll, "waistband/stringify_all"
7
+ autoload :QueryResult, "waistband/query_result"
8
+ autoload :Query, "waistband/query"
9
+ autoload :Index, "waistband/index"
10
+ autoload :QuickError, "waistband/quick_error"
11
+ autoload :Model, "waistband/model"
12
+
13
+ class << self
14
+
15
+ def configure
16
+ yield ::Waistband::Configuration.instance if block_given?
17
+ config_instance = ::Waistband::Configuration.instance
18
+ config_instance.setup
19
+ config_instance
20
+ end
21
+ alias_method :config, :configure
22
+
23
+ end
24
+
25
+ end
@@ -0,0 +1,51 @@
1
+ require 'singleton'
2
+ require 'active_support/core_ext/hash/indifferent_access'
3
+
4
+ module Waistband
5
+ class Configuration
6
+
7
+ include Singleton
8
+
9
+ attr_accessor :config_dir
10
+
11
+ def initialize
12
+ @yml_config = {}
13
+ @indexes = {}
14
+ end
15
+
16
+ def setup
17
+ raise "Please define a valid `config_dir` configuration variable!" unless config_dir
18
+ raise "Couldn't find configuration directory #{config_dir}" unless File.exist?(config_dir)
19
+
20
+ @env ||= ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development'
21
+ @yml_config = YAML.load_file("#{config_dir}/waistband.yml")[@env].with_indifferent_access
22
+ end
23
+
24
+ def index(name)
25
+ @indexes[name] ||= YAML.load_file("#{config_dir}/waistband_#{name}.yml")[@env].with_indifferent_access
26
+ end
27
+
28
+ def hostname
29
+ "#{host}:#{port}"
30
+ end
31
+
32
+ def method_missing(method_name, *args, &block)
33
+ return current_server[method_name] if current_server[method_name]
34
+ return @yml_config[method_name] if @yml_config[method_name]
35
+ super
36
+ end
37
+
38
+ private
39
+
40
+ def current_server
41
+ servers.sample
42
+ end
43
+
44
+ def servers
45
+ @servers ||= @yml_config['servers'].map {|server_name, config| config}
46
+ end
47
+
48
+ # /private
49
+
50
+ end
51
+ end
@@ -0,0 +1,91 @@
1
+ require 'json'
2
+ require 'rest-client'
3
+ require 'active_support/core_ext/string/inflections'
4
+ require 'active_support/core_ext/hash/indifferent_access'
5
+
6
+ module Waistband
7
+ class Index
8
+
9
+ MAX_RETRIES = 10
10
+
11
+ def initialize(index)
12
+ @index = index
13
+ @index_name = config['name']
14
+ @stringify = config['stringify']
15
+ @retries = 0
16
+ end
17
+
18
+ # create the index
19
+ def create!
20
+ RestClient.post(url, index_json)
21
+ rescue RestClient::BadRequest => ex
22
+ nil
23
+ end
24
+
25
+ # destroy the index
26
+ def destroy!
27
+ RestClient.delete(url)
28
+ rescue RestClient::ResourceNotFound => ex
29
+ nil
30
+ end
31
+
32
+ def update_settings!
33
+ RestClient.put("#{url}/_settings", settings_json)
34
+ end
35
+
36
+ # refresh the index
37
+ def refresh
38
+ RestClient.post("#{url}/_refresh", {})
39
+ end
40
+
41
+ def store!(key, data)
42
+ # map everything to strings
43
+ data = data.stringify_all if @stringify && data.respond_to?(:stringify_all)
44
+
45
+ RestClient.put(url_for_key(key), data.to_json)
46
+ end
47
+
48
+ def delete!(key)
49
+ RestClient.delete(url_for_key(key))
50
+ end
51
+
52
+ def read(key)
53
+ fetched = RestClient.get(url_for_key(key))
54
+ JSON.parse(fetched)['_source'].with_indifferent_access
55
+ rescue RestClient::ResourceNotFound => ex
56
+ nil
57
+ end
58
+
59
+ def query(term, options = {})
60
+ Waistband::Query.new(@index_name, term, options)
61
+ end
62
+
63
+ private
64
+
65
+ def url_for_key(key)
66
+ "#{url}/#{@index.singularize}/#{key}"
67
+ end
68
+
69
+ def settings_json
70
+ @settings_json ||= begin
71
+ settings = config['settings']['index'].except('number_of_shards')
72
+ {index: settings}.to_json
73
+ end
74
+ end
75
+
76
+ def index_json
77
+ @index_json ||= config.except('name', 'stringify').to_json
78
+ end
79
+
80
+ def config
81
+ @config ||= Waistband.config.index(@index)
82
+ end
83
+
84
+ def url
85
+ "#{Waistband.config.hostname}/#{@index_name}"
86
+ end
87
+
88
+ # /private
89
+
90
+ end
91
+ end
@@ -0,0 +1,215 @@
1
+ require 'digest/sha1'
2
+ require 'json'
3
+ require 'active_support/core_ext/class/attribute'
4
+ require 'active_support/values/time_zone'
5
+ require 'active_support/time_with_zone'
6
+
7
+ module Waistband
8
+ class Model
9
+
10
+ class_attribute :index_name, :column_names, :validate_columns,
11
+ :default_values, :stringify_columns
12
+
13
+ class << self
14
+
15
+ def index
16
+ @index ||= Waistband::Index.new(index_name)
17
+ end
18
+
19
+ def find(id)
20
+ attrs = index.read(id)
21
+ raise ActiveRecord::RecordNotFound.new("#{self} not found!") unless attrs
22
+ new attrs
23
+ end
24
+
25
+ def create(attributes)
26
+ instance = new(attributes)
27
+ instance.save
28
+ instance
29
+ end
30
+
31
+ def query
32
+ q = index.query('')
33
+ q.add_term('model_type', name.underscore)
34
+ q
35
+ end
36
+
37
+ def query_desc
38
+ q = query
39
+ q.add_sort 'created_at', 'desc'
40
+ q
41
+ end
42
+
43
+ def query_asc
44
+ q = query
45
+ q.add_sort 'created_at', 'asc'
46
+ q
47
+ end
48
+
49
+ def first
50
+ attrs = query_asc.results.first.try(:source)
51
+ return new(attrs) if attrs
52
+ nil
53
+ end
54
+
55
+ def last
56
+ attrs = query_desc.results.first.try(:source)
57
+ return new(attrs) if attrs
58
+ nil
59
+ end
60
+
61
+ def with_index(name)
62
+ self.index_name = name.to_s
63
+ end
64
+
65
+ def create_index!
66
+ Waistband::Index.new(index_name).create!
67
+ end
68
+
69
+ def destroy_index!
70
+ Waistband::Index.new(index_name).destroy!
71
+ end
72
+
73
+ def validates(*cols)
74
+ self.validate_columns ||= []
75
+ self.validate_columns |= cols.map(&:to_sym)
76
+ end
77
+
78
+ def defaults(values = {})
79
+ self.default_values ||= {}
80
+
81
+ values.each do |k, v|
82
+ self.default_values = self.default_values.merge({k.to_sym => v})
83
+ end
84
+ end
85
+
86
+ def stringify(*cols)
87
+ self.stringify_columns ||= []
88
+ self.stringify_columns |= cols.map(&:to_sym)
89
+ end
90
+
91
+ def columns(*cols)
92
+ cols |= [:id, :model_type, :created_at, :updated_at]
93
+ self.column_names ||= []
94
+ self.column_names |= cols.map(&:to_sym)
95
+
96
+ cols.each do |col|
97
+ # Normal attributes setters and getters
98
+ class_eval <<-EV, __FILE__, __LINE__ + 1
99
+ def #{col}
100
+ read_attribute(:#{col})
101
+ end
102
+
103
+ def #{col}=(val)
104
+ write_attribute(:#{col}, val)
105
+ end
106
+ EV
107
+
108
+ # Relationship type columns: `user_id`, `app_id` to `user`, `app`
109
+ if col =~ /(.*)_id$/
110
+ class_eval <<-EV, __FILE__, __LINE__ + 1
111
+ def #{$1}
112
+ klass = "#{$1}".classify.constantize
113
+ klass.find(#{col})
114
+ end
115
+
116
+ def #{$1}=(val)
117
+ write_attribute(:#{$1}_id, val.id)
118
+ end
119
+ EV
120
+ end
121
+
122
+ end
123
+ end
124
+
125
+ end # /class << self
126
+
127
+ attr_reader :errors
128
+
129
+ def initialize(attributes = {})
130
+ @attributes = (attributes || {}).symbolize_keys
131
+
132
+ @attributes.each do |key, val|
133
+ if self.class.stringify_columns.include?(key)
134
+ self.send("#{key}=", val)
135
+ elsif !self.class.column_names.include?(key)
136
+ raise ArgumentError.new("#{key} is not a valid column name!")
137
+ end
138
+ end
139
+
140
+ self.class.default_values.each do |k, v|
141
+ self.send("#{k}=", v) if self.send(k).nil?
142
+ end
143
+
144
+ @errors = ::Waistband::QuickError.new
145
+ end
146
+
147
+ def save
148
+ return false unless valid?
149
+
150
+ before_save
151
+
152
+ prev_id = self.id
153
+ prev_created_at = self.created_at
154
+ prev_updated_at = self.updated_at
155
+
156
+ self.id ||= generated_id
157
+ self.created_at ||= (Time.zone || Time).now.to_i
158
+ self.updated_at = (Time.zone || Time).now.to_i
159
+ self.model_type = self.class.name.downcase
160
+
161
+ stored_json = JSON.parse store!
162
+ stored = !!stored_json.try(:[], 'ok')
163
+
164
+ if stored
165
+ after_save
166
+ else stored
167
+ self.id = prev_id
168
+ self.created_at = prev_created_at
169
+ self.updated_at = prev_updated_at
170
+ end
171
+
172
+ stored
173
+ end
174
+
175
+ def before_save
176
+ end
177
+
178
+ def after_save
179
+ end
180
+
181
+ def valid?
182
+ self.class.validate_columns.each do |col|
183
+ @errors << "#{col} cannot be nil" if self.send(col).nil?
184
+ end
185
+
186
+ @errors.empty?
187
+ end
188
+
189
+ def attributes
190
+ Hash[self.class.column_names.map{|col| [col, self.send(col)] }]
191
+ end
192
+
193
+ def read_attribute(attribute)
194
+ @attributes[attribute]
195
+ end
196
+
197
+ def write_attribute(attribute, val)
198
+ val = val.to_s if self.class.stringify_columns.include?(attribute.to_sym)
199
+ @attributes[attribute] = val
200
+ end
201
+
202
+ private
203
+
204
+ def store!
205
+ self.class.index.store!(id, attributes)
206
+ end
207
+
208
+ def generated_id
209
+ @generated_id ||= Digest::SHA1.hexdigest "#{attributes}:#{rand(999999)}"
210
+ end
211
+
212
+ # /private
213
+
214
+ end
215
+ end