stretchy 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 213d84388de53cfe8d9b815d34ef544349cb2bd0
4
+ data.tar.gz: 5cdfb873ac77da2e9815c7ac3f36da1dc27889e8
5
+ SHA512:
6
+ metadata.gz: 590ee2ca8abb6251effe21b6561a29459f80a6e4361158c9d0103c80dc0594a97b00c5c8b77b51beea296ce21350274b5a4ab128e40e010b5fb00b8cb289449b
7
+ data.tar.gz: e60c32da5e5cf45553f9f2163c0764d6ba0653b22d1e1942a28d777e61f81dca8585dd43c24ac9390a89a13ffe9be954bf598f11b0bf0e817555b43d535d399a
data/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ .rspec
11
+ *.gem
data/.travis.yml ADDED
@@ -0,0 +1,6 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.1.5
4
+ before_install: gem update bundler
5
+ services:
6
+ - elasticsearch
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in stretchy.gemspec
4
+ gemspec
data/README.md ADDED
@@ -0,0 +1,184 @@
1
+ # Stretchy
2
+ [![Build Status](https://travis-ci.org/hired/stretchy.svg?branch=master)](https://travis-ci.org/hired/stretchy)
3
+
4
+ Stretchy is a query builder for [Elasticsearch](https://www.elastic.co/products/elasticsearch). It helps you quickly construct the JSON to send to Elastic, which can get [rather complicated](http://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html).
5
+
6
+ Stretchy is modeled after ActiveRecord's interface and architecture - query objects are immutable and chainable, which makes quickly building the right query and caching the results easy.
7
+
8
+ Stretchy is *not*:
9
+
10
+ 1. an integration with ActiveModel to help you index your data
11
+ 2. a way to manage Elasticsearch configuration
12
+ 3. a general-purpose Elasticsearch API client
13
+
14
+ The first two are very application-specific. For any non-trivial app, the level of customization necessary will have you writing almost everything yourself. The last one is better handled by the [elasticsearch gem](http://www.rubydoc.info/gems/elasticsearch-api/).
15
+
16
+ ## Installation
17
+
18
+ Add this line to your application's Gemfile:
19
+
20
+ ```ruby
21
+ gem 'stretchy'
22
+ ```
23
+
24
+ And then execute:
25
+
26
+ $ bundle
27
+
28
+ Or install it yourself as:
29
+
30
+ $ gem install stretchy
31
+
32
+ ## Usage
33
+
34
+ Stretchy is still in early development, so it does not yet support the full feature set of the [Elasticsearch API](http://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html). It does support fairly basic queries in an ActiveRecord-ish style.
35
+
36
+ ### Base
37
+
38
+ ```ruby
39
+ query = Stretchy::Query.new(index: 'app_production', type: 'model_name')
40
+ ```
41
+
42
+ From here, you can chain the following query methods:
43
+
44
+ ### Match
45
+
46
+ ```ruby
47
+ query = query.match('welcome to my web site')
48
+ query = query.match('welcome to my web site', field: 'title', operator: 'or')
49
+ ```
50
+
51
+ Performs a full-text search for the given string. `field` and `operator` are optional, and default to `_all` and `and` respectively.
52
+
53
+ #### Variants
54
+
55
+ * `not_match` - filters for documents not matching a full-text search
56
+
57
+
58
+ ### Where
59
+
60
+ ```ruby
61
+ query = query.where(
62
+ name: 'Exact Name',
63
+ email: [
64
+ 'exact@email.com',
65
+ 'another.user.with.same.name@email.com'
66
+ ]
67
+ )
68
+ ```
69
+
70
+ Allows passing a hash of matchable options in `field: [values]` format. If any one of the values matches, that field will be considered matched. All fields must match for a document to be returned. See the [Terms filter](http://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-terms-filter.html) for more details.
71
+
72
+ If you pass `field: nil`, Stretchy will construct the relevant `not { exists: field }` filter and apply it as expected.
73
+
74
+ #### Gotcha
75
+
76
+ Matches _must_ be exact; the values you pass in here are not analyzed by Elasticsearch, while the values stored in the index are (unless you turned analysis off for that field).
77
+
78
+ #### Variants
79
+
80
+ * `not_where` - filters for documents *not* matching the criteria
81
+ * `boost_where` - boosts the relevance score for matching documents
82
+ * `boost_not_where` - boosts the relevance score for documents not matching the criteria
83
+
84
+ ### Range
85
+
86
+ ```ruby
87
+ query = query.range(field: 'rating', min: 3, max: 5)
88
+ ```
89
+
90
+ Only documents with the specified field, and within the specified range (inclusive) match. You can also pass in dates and times as ranges. Currently, you must pass both a min and a max value.
91
+
92
+ #### Variants
93
+
94
+ * `not_range` - filters for only documents where the field is *outside* the given range
95
+ * `boost_range` - boosts the relevance score for matching documents
96
+
97
+ ### Geo
98
+
99
+ ```ruby
100
+ query = query.geo(field: 'coords', distance: '20mi', lat: 35.0117, lng: 135.7683)
101
+ ```
102
+
103
+ Filters for documents where the specified `geo_point` field is within the given range.
104
+
105
+ #### Gotcha
106
+
107
+ The field must be mapped as a `geo_point` field. See [Elasticsearch types](http://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-geo-point-type.html) for more info.
108
+
109
+ #### Variants
110
+
111
+ * `not_geo` - filters for documents outside the specified range
112
+ * `boost_geo` - boosts the relevance score for documents based on how far from the given point they are
113
+
114
+ ### Limit and Offset
115
+
116
+ ```ruby
117
+ query = query.limit(20).offset(1000)
118
+ ```
119
+
120
+ Works the same way as ActiveRecord's limit and offset methods.
121
+
122
+ ### Boost Random
123
+
124
+ ```ruby
125
+ query = query.boost_random(user.id, 1.4)
126
+ ```
127
+
128
+ Provides a random-but-deterministic boost to relevance scores. The first parameter is required, and represents the random seed. The second parameter is optional, and represents the weight for the random factor. See [Random Scoring](http://www.elastic.co/guide/en/elasticsearch/guide/master/random-scoring.html) for more details.
129
+
130
+ ### Explain
131
+
132
+ ```ruby
133
+ query = query.explain.results
134
+ ```
135
+
136
+ Provides Elasticsearch explanation results in `query.response` . See [the explain documentation](http://www.elastic.co/guide/en/elasticsearch/reference/1.x/search-explain.html) for more info.
137
+
138
+ ### Response
139
+
140
+ ```ruby
141
+ query.response
142
+ ```
143
+
144
+ Executes the query, returns the raw JSON response from Elasticsearch and caches it. Use this to get at search API data not in the source documents.
145
+
146
+ ### Results
147
+
148
+ ```ruby
149
+ query.results
150
+ ```
151
+
152
+ Executes the query and provides the parsed json for each hit returned by Elasticsearch.
153
+
154
+ ### Ids
155
+
156
+ ```ruby
157
+ query.ids
158
+ ```
159
+
160
+ Provides only the ids for each hit. If your document ids are numeric (as is the case for most ActiveRecord-integrated documents), they will be converted to integers.
161
+
162
+ This is somewhat intelligent - if you have already called `results` the ids will be fetched from there, otherwise it will run the search and skip the data-fetch phase in Elasticsearch.
163
+
164
+ ### Total
165
+
166
+ ```ruby
167
+ query.total
168
+ ```
169
+
170
+ Returns the total number of matches returned by the query - not just the current page. Makes plugging into [Kaminari](https://github.com/amatsuda/kaminari) a snap.
171
+
172
+ ## Development
173
+
174
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `bin/console` for an interactive prompt that will allow you to experiment.
175
+
176
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release` to create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
177
+
178
+ ## Contributing
179
+
180
+ 1. Fork it ( https://github.com/[my-github-username]/stretchy/fork )
181
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
182
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
183
+ 4. Push to the branch (`git push origin my-new-feature`)
184
+ 5. Create a new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,7 @@
1
+ require "bundler/gem_tasks"
2
+ begin
3
+ require 'rspec/core/rake_task'
4
+ RSpec::Core::RakeTask.new(:spec)
5
+ task default: :spec
6
+ rescue LoadError
7
+ end
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "stretchy"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
data/bin/setup ADDED
@@ -0,0 +1,7 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+
5
+ bundle install
6
+
7
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,20 @@
1
+ module Stretchy
2
+ module Boosts
3
+ class FilterBoost
4
+
5
+ DEFAULT_WEIGHT = 1.2
6
+
7
+ def initialize(filter:, weight: DEFAULT_WEIGHT)
8
+ @filter = filter
9
+ @weight = weight
10
+ end
11
+
12
+ def to_search
13
+ {
14
+ filter: @filter.to_search,
15
+ weight: @weight
16
+ }
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,38 @@
1
+ module Stretchy
2
+ module Boosts
3
+ class GeoBoost
4
+
5
+ DEFAULTS = {
6
+ field: 'coords',
7
+ offset: '10km',
8
+ scale: '50km',
9
+ decay: 0.75,
10
+ weight: 1.2
11
+ }.freeze
12
+
13
+ def initialize(options = {})
14
+ @field = options[:field] || DEFAULTS[:field]
15
+ @offset = options[:offset] || DEFAULTS[:offset]
16
+ @scale = options[:scale] || DEFAULTS[:scale]
17
+ @decay = options[:decay] || DEFAULTS[:decay]
18
+ @weight = options[:weight] || DEFAULTS[:weight]
19
+ @lat = options[:lat]
20
+ @lng = options[:lng]
21
+ end
22
+
23
+ def to_search
24
+ {
25
+ gauss: {
26
+ @field => {
27
+ origin: { lat: @lat, lon: @lng },
28
+ offset: @offset,
29
+ scale: @scale,
30
+ decay: @decay
31
+ }
32
+ },
33
+ weight: @weight
34
+ }
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,26 @@
1
+ module Stretchy
2
+ module Boosts
3
+ class RandomBoost
4
+
5
+ DEFAULT_WEIGHT = 1.2
6
+
7
+ # randomizes order (somewhat) consistently per-user
8
+ # http://www.elastic.co/guide/en/elasticsearch/guide/current/random-scoring.html
9
+
10
+ def initialize(seed, weight = DEFAULT_WEIGHT)
11
+ @seed = seed
12
+ @weight = weight
13
+ end
14
+
15
+ def to_search
16
+ {
17
+ random_score: {
18
+ seed: @seed
19
+ },
20
+ weight: @weight
21
+ }
22
+ end
23
+
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,61 @@
1
+ require 'json'
2
+ module Stretchy
3
+ module ClientActions
4
+
5
+ def self.extended(base)
6
+ unless base.respond_to?(:client) && base.respond_to?(:index_name)
7
+ raise "ClientActions requires methods 'client' and 'index_name'"
8
+ end
9
+ end
10
+
11
+ # used for ensuring a concistent index in specs
12
+ def refresh
13
+ client.indices.refresh index: index_name
14
+ end
15
+
16
+ def count
17
+ client.cat.count(index: index_name).split(' ')[2].to_i
18
+ end
19
+
20
+ def search(type:, body:, fields: nil)
21
+ options = { index: index_name, type: type, body: body }
22
+ options[:fields] = fields if fields.is_a?(Array)
23
+
24
+ client.search(options)
25
+ end
26
+
27
+ def index(type:, body:, id: nil)
28
+ id ||= body['id'] || body['_id'] || body[:id] || body[:_id]
29
+ client.index(index: index_name, type: type, id: id, body: body)
30
+ end
31
+
32
+ def bulk(type:, documents:)
33
+ requests = documents.flat_map do |document|
34
+ id = document['id'] || document['_id'] || document[:id] || document[:_id]
35
+ [
36
+ { index: { '_index' => index_name, '_type' => type, '_id' => id } },
37
+ document
38
+ ]
39
+ end
40
+ client.bulk body: requests
41
+ end
42
+
43
+ def exists(_index_name = index_name)
44
+ client.indices.exists(index: _index_name)
45
+ end
46
+ alias :exists? :exists
47
+
48
+ def delete(_index_name = index_name)
49
+ client.indices.delete(index: _index_name) if exists?(_index_name)
50
+ end
51
+
52
+ def create(_index_name = index_name)
53
+ client.indices.create(index: _index_name) unless exists?(_index_name)
54
+ end
55
+
56
+ def mapping(_index_name, _type, _body)
57
+ client.indices.put_mapping(index: _index_name, type: _type, body: _body)
58
+ end
59
+
60
+ end
61
+ end
@@ -0,0 +1,34 @@
1
+ module Stretchy
2
+ module Configuration
3
+
4
+ attr_accessor :index_name, :logger, :url, :adapter, :client
5
+
6
+ def self.extended(base)
7
+ base.set_default_configuration
8
+ end
9
+
10
+ def configure
11
+ yield self
12
+ end
13
+
14
+ def set_default_configuration
15
+ self.index_name = 'myapp'
16
+ self.adapter = :excon
17
+ self.url = ENV['ELASTICSEARCH_URL']
18
+ end
19
+
20
+ def client_options
21
+ Hash[
22
+ index_name: index_name,
23
+ log: !!logger,
24
+ logger: logger,
25
+ adapter: adapter,
26
+ url: url
27
+ ]
28
+ end
29
+
30
+ def client(options = {})
31
+ @client ||= Elasticsearch::Client.new(client_options.merge(options))
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,17 @@
1
+ module Stretchy
2
+ module Filters
3
+ class AndFilter
4
+
5
+ def initialize(filters)
6
+ @filters = Array(filters)
7
+ end
8
+
9
+ def to_search
10
+ {
11
+ and: @filters.map(&:to_search)
12
+ }
13
+ end
14
+
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ module Stretchy
2
+ module Filters
3
+ class BoolFilter
4
+ def initialize(must:, must_not:, should: nil)
5
+ @must = Array(must)
6
+ @must_not = Array(must_not)
7
+ @should = Array(should)
8
+ end
9
+
10
+ def to_search
11
+ json = {}
12
+ json[:must] = @must.map(&:to_search) if @must
13
+ json[:must_not] = @must_not.map(&:to_search) if @must_not
14
+ json[:should] = @should.map(&:to_search) if @should
15
+ { bool: json }
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,20 @@
1
+ module Stretchy
2
+ module Filters
3
+ class ExistsFilter
4
+
5
+ # CAUTION: this will match empty strings
6
+ # see http://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-exists-filter.html
7
+ def initialize(field)
8
+ @field = field
9
+ end
10
+
11
+ def to_search
12
+ {
13
+ exists: {
14
+ field: @field
15
+ }
16
+ }
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,24 @@
1
+ module Stretchy
2
+ module Filters
3
+ class GeoFilter
4
+ def initialize(field: 'coords', distance: '50km', lat:, lng:)
5
+ @field = field
6
+ @distance = distance
7
+ @lat = lat
8
+ @lng = lng
9
+ end
10
+
11
+ def to_search
12
+ {
13
+ geo_distance: {
14
+ distance: @distance,
15
+ @field => {
16
+ lat: @lat,
17
+ lon: @lng
18
+ }
19
+ }
20
+ }
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,20 @@
1
+ module Stretchy
2
+ module Filters
3
+ class NotFilter
4
+
5
+ def initialize(filters)
6
+ filters = Array(filters)
7
+
8
+ if filters.count == 1
9
+ @filter = filters.first
10
+ else
11
+ @filter = AndFilter.new(filters)
12
+ end
13
+ end
14
+
15
+ def to_search
16
+ { not: @filter.to_search }
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,16 @@
1
+ module Stretchy
2
+ module Filters
3
+ class QueryFilter
4
+
5
+ def initialize(query)
6
+ @query = query
7
+ end
8
+
9
+ def to_search
10
+ {
11
+ query: @query.to_search
12
+ }
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,22 @@
1
+ module Stretchy
2
+ module Filters
3
+ class RangeFilter
4
+ def initialize(field:, min:, max:)
5
+ @field = field
6
+ @min = min
7
+ @max = max
8
+ end
9
+
10
+ def to_search
11
+ range = {}
12
+ range[:gte] = @min if @min
13
+ range[:lte] = @max if @max
14
+ {
15
+ range: {
16
+ @field => range
17
+ }
18
+ }
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,19 @@
1
+ module Stretchy
2
+ module Filters
3
+ class TermsFilter
4
+
5
+ def initialize(field:, values:)
6
+ @field = field
7
+ @values = Array(values)
8
+ end
9
+
10
+ def to_search
11
+ {
12
+ terms: {
13
+ @field => @values
14
+ }
15
+ }
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,30 @@
1
+ module Stretchy
2
+ class NullQuery
3
+ def initialize(options = {})
4
+ end
5
+
6
+ def response
7
+ {}
8
+ end
9
+
10
+ def id_response
11
+ {}
12
+ end
13
+
14
+ def results
15
+ []
16
+ end
17
+
18
+ def ids
19
+ []
20
+ end
21
+
22
+ def shards
23
+ []
24
+ end
25
+
26
+ def total
27
+ 0
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,18 @@
1
+ module Stretchy
2
+ module Queries
3
+ class FilteredQuery
4
+
5
+ def initialize(query: nil, filter:)
6
+ @query = query
7
+ @filter = filter
8
+ end
9
+
10
+ def to_search
11
+ json = {}
12
+ json[:query] = @query.to_search if @query
13
+ json[:filter] = @filter.to_search if @filter
14
+ { filtered: json }
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,70 @@
1
+ module Stretchy
2
+ module Queries
3
+ class FunctionScoreQuery
4
+
5
+ SCORE_MODES = %w(multiply sum avg first max min)
6
+ BOOST_MODES = %w(multiply replace sum avg max min)
7
+
8
+ def initialize(options = {})
9
+ @functions = Array(options[:functions])
10
+ @query = options[:query]
11
+ @filter = options[:filter]
12
+
13
+ self.class.attributes.map do |field|
14
+ instance_variable_set("@#{field}", options[field])
15
+ end
16
+ validate
17
+ end
18
+
19
+ def self.attributes
20
+ [:boost, :max_boost, :score_mode, :boost_mode, :min_score]
21
+ end
22
+
23
+ def validate
24
+ if @query && @filter
25
+ raise ArgumentError.new("Cannot have both query and filter -- combine using a FilteredQuery")
26
+ end
27
+
28
+ if @boost && !@boost.is_a?(Numeric)
29
+ raise ArgumentError.new("Boost must be a number - it is the global boost for the whole query")
30
+ end
31
+
32
+ if @max_boost && !@max_boost.is_a?(Numeric)
33
+ raise ArgumentError.new("Max boost must be a number")
34
+ end
35
+
36
+ if @min_score && !@min_score.is_a?(Numeric)
37
+ raise ArgumentError.new("min_score must be a number - it is the global boost for the whole query")
38
+ end
39
+
40
+ if @score_mode && !SCORE_MODES.include?(@score_mode)
41
+ raise ArgumentError.new("Score mode must be one of #{SCORE_MODES.join(', ')}")
42
+ end
43
+
44
+ if @boost_mode && !BOOST_MODES.include?(@boost_mode)
45
+ raise ArgumentError.new("Score mode must be one of #{BOOST_MODES.join(', ')}")
46
+ end
47
+ end
48
+
49
+ def to_search
50
+ json = {}
51
+ json[:functions] = @functions.map(&:to_search)
52
+ if @query
53
+ json[:query] = @query.to_search
54
+ elsif @filter
55
+ json[:filter] = @filter.to_search
56
+ else
57
+ json[:query] = Stretchy::Queries::MatchAllQuery.new.to_search
58
+ end
59
+
60
+ self.class.attributes.reduce(json) do |body, field|
61
+ ivar = instance_variable_get("@#{field}")
62
+ body[field] = ivar if ivar
63
+ body
64
+ end
65
+
66
+ { function_score: json }
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,9 @@
1
+ module Stretchy
2
+ module Queries
3
+ class MatchAllQuery
4
+ def to_search
5
+ { match_all: {} }
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,24 @@
1
+ module Stretchy
2
+ module Queries
3
+ class MatchQuery
4
+
5
+ def initialize(string, field: '_all', operator: 'and')
6
+ @field = field
7
+ @operator = operator
8
+ @string = string
9
+ end
10
+
11
+ def to_search
12
+ {
13
+ match: {
14
+ @field => {
15
+ query: @string,
16
+ operator: @operator
17
+ }
18
+ }
19
+ }
20
+ end
21
+
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,241 @@
1
+ module Stretchy
2
+ class Query
3
+
4
+ DEFAULT_LIMIT = 40
5
+
6
+ def initialize(options = {})
7
+ @offset = options[:offset] || 0
8
+ @limit = options[:limit] || DEFAULT_LIMIT
9
+ @match = options[:match]
10
+ @filters = options[:filters]
11
+ @not_filters = options[:not_filters]
12
+ @boosts = options[:boosts]
13
+ @type = options[:type] || 'documents'
14
+ @index = options[:index] || Stretchy.index_name
15
+ @explain = options[:explain]
16
+ end
17
+
18
+ def clone_with(options)
19
+ filters = [@filters, options[:filters], options[:filter]].flatten.compact
20
+ not_filters = [@not_filters, options[:not_filters], options[:not_filter]].flatten.compact
21
+ boosts = [@boosts, options[:boosts], options[:boost]].flatten.compact
22
+
23
+ self.class.new(
24
+ type: @type,
25
+ index: @index,
26
+ explain: options[:explain] || @explain,
27
+ offset: options[:offset] || @offset,
28
+ limit: options[:limit] || @limit,
29
+ match: options[:match] || @match,
30
+ filters: filters,
31
+ not_filters: not_filters,
32
+ boosts: boosts
33
+ )
34
+ end
35
+
36
+ def offset(num)
37
+ clone_with(offset: num)
38
+ end
39
+
40
+ def limit(num)
41
+ clone_with(limit: num)
42
+ end
43
+
44
+ def explain
45
+ clone_with(explain: true)
46
+ end
47
+
48
+ def where(options = {})
49
+ return self if options.empty?
50
+ filters, not_filters = where_clause(options)
51
+ clone_with(filters: filters, not_filters: not_filters)
52
+ end
53
+
54
+ def not_where(options = {})
55
+ return self if options.empty?
56
+ # reverse the order returned here, since we're doing not_where
57
+ not_filters, filters = where_clause(options)
58
+ clone_with(filters: filters, not_filters: not_filters)
59
+ end
60
+
61
+ def boost_where(options = {})
62
+ return self if options.empty?
63
+ weight = options.delete(:weight) || 1.2
64
+
65
+ boosts = options.map do |field, values|
66
+ filter = nil
67
+ if values.nil?
68
+ filter = Filters::NotFilter.new(Filters::ExistsFilter.new(field))
69
+ else
70
+ filter = Filters::TermsFilter.new(field: field, values: Array(values))
71
+ end
72
+ Boosts::FilterBoost.new(filter: filter, weight: weight)
73
+ end
74
+ clone_with(boosts: boosts)
75
+ end
76
+
77
+ def boost_not_where(options = {})
78
+ return self if options.empty?
79
+ weight = options.delete(:weight) || 1.2
80
+
81
+ boosts = []
82
+ options.each do |field, values|
83
+ filter = nil
84
+ if values.nil?
85
+ filter = Filters::ExistsFilter.new(field)
86
+ elsif values.is_a?(Array)
87
+ filter = NotFilter.new(Filters::TermsFilter.new(field: field, values: Array(values)))
88
+ end
89
+ Boosts::FilterBoost.new(filter: filter)
90
+ end
91
+ clone_with(boosts: boosts)
92
+ end
93
+
94
+ def range(field:, min: nil, max: nil)
95
+ return self unless min || max
96
+ clone_with(filter: Filters::RangeFilter.new(
97
+ field: field,
98
+ min: min,
99
+ max: max
100
+ ))
101
+ end
102
+
103
+ def not_range(field:, min: nil, max: nil)
104
+ return self unless min || max
105
+ clone_with(not_filter: Filters::RangeFilter.new(
106
+ field: field,
107
+ min: min,
108
+ max: max
109
+ ))
110
+ end
111
+
112
+ def boost_range(field:, min: nil, max: nil, weight: 1.2)
113
+ return self unless min || max
114
+ clone_with(boost: Boosts::Boost.new(
115
+ filter: Filters::RangeFilter.new(
116
+ field: field,
117
+ min: min,
118
+ max: max
119
+ ),
120
+ weight: weight
121
+ ))
122
+ end
123
+
124
+ def geo(field: 'coords', distance: '50km', lat:, lng:)
125
+ clone_with(filter: Filters::GeoFilter.new(
126
+ field: field,
127
+ distance: distance,
128
+ lat: lat,
129
+ lng: lng
130
+ ))
131
+ end
132
+
133
+ def not_geo(field: 'coords', distance: '50km', lat:, lng:)
134
+ clone_with(not_filter: Filters::GeoFilter.new(
135
+ field: field,
136
+ distance: distance,
137
+ lat: lat,
138
+ lng: lng
139
+ ))
140
+ end
141
+
142
+ def boost_geo(options = {})
143
+ return self if options.empty?
144
+ clone_with(boost: Boosts::GeoBoost.new(options))
145
+ end
146
+
147
+ def match(string, options = {})
148
+ return self if string.empty?
149
+ field = options[:field] || '_all'
150
+ operator = options[:operator] || 'and'
151
+ clone_with(match: Queries::MatchQuery.new(string, field: field, operator: operator))
152
+ end
153
+
154
+ def not_match(string, options = {})
155
+ field = options[:field] || '_all'
156
+ operator = options[:operator] || 'and'
157
+ clone_with(not_filter: Filters::QueryFilter.new(
158
+ Queries::MatchQuery.new(string, field: field, operator: operator)
159
+ ))
160
+ end
161
+
162
+ def boost_random(seed)
163
+ clone_with(boost: Boosts::RandomBoost.new(seed))
164
+ end
165
+
166
+ def request
167
+ @request ||= RequestBody.new(
168
+ match: @match,
169
+ filters: @filters,
170
+ not_filters: @not_filters,
171
+ boosts: @boosts,
172
+ limit: @limit,
173
+ offset: @offset,
174
+ explain: @explain
175
+ )
176
+ end
177
+
178
+ def response
179
+ @response = Stretchy.search(type: @type, body: request.to_search)
180
+ # caution: huuuuge logs, but pretty helpful
181
+ # Rails.logger.debug(Colorize.purple(JSON.pretty_generate(@response)))
182
+ @response
183
+ end
184
+
185
+ def id_response
186
+ @id_response ||= Stretchy.search(type: @type, body: request.to_search, fields: [])
187
+ # caution: huuuuge logs, but pretty helpful
188
+ # Rails.logger.debug(Colorize.purple(JSON.pretty_generate(@id_response)))
189
+ @id_response
190
+ end
191
+
192
+ def results
193
+ @results ||= response['hits']['hits'].map do |hit|
194
+ hit['_source'].merge(
195
+ '_id' => hit['_id'],
196
+ '_type' => hit['_type'],
197
+ '_index' => hit['_index']
198
+ )
199
+ end
200
+ end
201
+
202
+ def ids
203
+ @ids ||= result_metadata('hits', 'hits').map do |hit|
204
+ hit['_id'].to_i == 0 ? hit['_id'] : hit['_id'].to_i
205
+ end
206
+ end
207
+
208
+ def shards
209
+ @shards ||= result_metadata('shards')
210
+ end
211
+
212
+ def total
213
+ @total ||= result_metadata('hits', 'total')
214
+ end
215
+
216
+ private
217
+ def result_metadata(*args)
218
+ if @response
219
+ args.reduce(@response){|json, field| json.nil? ? nil : json[field] }
220
+ else
221
+ args.reduce(id_response){|json, field| json.nil? ? nil : json[field] }
222
+ end
223
+ end
224
+
225
+ def where_clause(options = {})
226
+ return [[], []] if options.empty?
227
+ filters = []
228
+ not_filters = []
229
+
230
+ options.each do |field, values|
231
+ if values.nil?
232
+ not_filters << Filters::ExistsFilter.new(field)
233
+ else
234
+ filters << Filters::TermsFilter.new(field: field, values: Array(values))
235
+ end
236
+ end
237
+ [filters, not_filters]
238
+ end
239
+
240
+ end
241
+ end
@@ -0,0 +1,67 @@
1
+ module Stretchy
2
+ class RequestBody
3
+
4
+ def initialize(options = {})
5
+ @json = {}
6
+ @match = options[:match]
7
+ @filters = Array(options[:filters])
8
+ @not_filters = Array(options[:not_filters])
9
+ @boosts = Array(options[:boosts])
10
+ @offset = options[:offset] || 0
11
+ @limit = options[:limit] || Query::DEFAULT_LIMIT
12
+ @explain = options[:explain]
13
+ end
14
+
15
+ def to_search
16
+ return @json unless @json.empty?
17
+
18
+ query = @match || Queries::MatchAllQuery.new
19
+
20
+ if @filters.any? && @not_filters.any?
21
+ query = Queries::FilteredQuery.new(
22
+ query: query,
23
+ filter: Filters::BoolFilter.new(
24
+ must: @filters,
25
+ must_not: @not_filters
26
+ )
27
+ )
28
+ elsif @filters.any?
29
+ if @filters.count == 1
30
+ query = Queries::FilteredQuery.new(
31
+ query: query,
32
+ filter: @filters.first
33
+ )
34
+ else
35
+ query = Queries::FilteredQuery.new(
36
+ query: query,
37
+ filter: Filters::AndFilter.new(@filters)
38
+ )
39
+ end
40
+ elsif @not_filters.any?
41
+ query = Queries::FilteredQuery.new(
42
+ query: query,
43
+ filter: Filters::NotFilter.new(@not_filters)
44
+ )
45
+ end
46
+
47
+ if @boosts.any?
48
+ query = Queries::FunctionScoreQuery.new(
49
+ query: query,
50
+ functions: @boosts,
51
+ score_mode: 'sum',
52
+ boost_mode: 'max'
53
+ )
54
+ end
55
+
56
+ @json = {}
57
+ @json[:query] = query.to_search
58
+ @json[:from] = @offset
59
+ @json[:size] = @limit
60
+ @json[:explain] = @explain if @explain
61
+
62
+ # not a ton of output, usually worth having
63
+ # puts "Generated elastic query: #{JSON.pretty_generate(@json)}"
64
+ @json
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,3 @@
1
+ module Stretchy
2
+ VERSION = "0.1.1"
3
+ end
data/lib/stretchy.rb ADDED
@@ -0,0 +1,14 @@
1
+ require 'json'
2
+ require 'logger'
3
+ require 'excon'
4
+ require 'elasticsearch'
5
+
6
+ Dir[File.join(File.dirname(__FILE__), 'stretchy', '**', '*.rb')].each do |path|
7
+ require path
8
+ end
9
+
10
+ module Stretchy
11
+ extend Configuration
12
+ extend ClientActions
13
+
14
+ end
data/stretchy.gemspec ADDED
@@ -0,0 +1,29 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'stretchy/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "stretchy"
8
+ spec.version = Stretchy::VERSION
9
+ spec.authors = ["agius"]
10
+ spec.email = ["andrew@atevans.com"]
11
+ spec.licenses = ['MIT']
12
+
13
+ spec.summary = %q{Query builder for Elasticsearch}
14
+ spec.description = %q{Build queries for Elasticsearch with a chainable interface like ActiveRecord's.}
15
+ spec.homepage = "https://github.com/hired/stretchy"
16
+
17
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
18
+ spec.bindir = "exe"
19
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
+ spec.require_paths = ["lib"]
21
+
22
+ spec.add_dependency "elasticsearch", "~> 1.0"
23
+ spec.add_dependency "excon", "~> 0.45"
24
+
25
+ spec.add_development_dependency "bundler", "~> 1.8"
26
+ spec.add_development_dependency "rake", "~> 10.0"
27
+ spec.add_development_dependency "rspec", "~> 3.2"
28
+ spec.add_development_dependency "fuubar", "~> 2.0"
29
+ end
metadata ADDED
@@ -0,0 +1,158 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: stretchy
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - agius
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2015-04-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: elasticsearch
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: excon
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.45'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.45'
41
+ - !ruby/object:Gem::Dependency
42
+ name: bundler
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.8'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.8'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '10.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '10.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.2'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.2'
83
+ - !ruby/object:Gem::Dependency
84
+ name: fuubar
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '2.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '2.0'
97
+ description: Build queries for Elasticsearch with a chainable interface like ActiveRecord's.
98
+ email:
99
+ - andrew@atevans.com
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - ".gitignore"
105
+ - ".travis.yml"
106
+ - Gemfile
107
+ - README.md
108
+ - Rakefile
109
+ - bin/console
110
+ - bin/setup
111
+ - lib/stretchy.rb
112
+ - lib/stretchy/boosts/filter_boost.rb
113
+ - lib/stretchy/boosts/geo_boost.rb
114
+ - lib/stretchy/boosts/random_boost.rb
115
+ - lib/stretchy/client_actions.rb
116
+ - lib/stretchy/configuration.rb
117
+ - lib/stretchy/filters/and_filter.rb
118
+ - lib/stretchy/filters/bool_filter.rb
119
+ - lib/stretchy/filters/exists_filter.rb
120
+ - lib/stretchy/filters/geo_filter.rb
121
+ - lib/stretchy/filters/not_filter.rb
122
+ - lib/stretchy/filters/query_filter.rb
123
+ - lib/stretchy/filters/range_filter.rb
124
+ - lib/stretchy/filters/terms_filter.rb
125
+ - lib/stretchy/null_query.rb
126
+ - lib/stretchy/queries/filtered_query.rb
127
+ - lib/stretchy/queries/function_score_query.rb
128
+ - lib/stretchy/queries/match_all_query.rb
129
+ - lib/stretchy/queries/match_query.rb
130
+ - lib/stretchy/query.rb
131
+ - lib/stretchy/request_body.rb
132
+ - lib/stretchy/version.rb
133
+ - stretchy.gemspec
134
+ homepage: https://github.com/hired/stretchy
135
+ licenses:
136
+ - MIT
137
+ metadata: {}
138
+ post_install_message:
139
+ rdoc_options: []
140
+ require_paths:
141
+ - lib
142
+ required_ruby_version: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - ">="
145
+ - !ruby/object:Gem::Version
146
+ version: '0'
147
+ required_rubygems_version: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: '0'
152
+ requirements: []
153
+ rubyforge_project:
154
+ rubygems_version: 2.2.2
155
+ signing_key:
156
+ specification_version: 4
157
+ summary: Query builder for Elasticsearch
158
+ test_files: []