git.iko.soy/typeable/bloodhound
on 2eb39f7 help sign in
658 lines 18 kB history raw
1* Bloodhound
3#+CAPTION: Bloodhound (dog)
4[[./bloodhound.jpg]]
7#+CAPTION: Build Status
8[[https://travis-ci.org/bitemyapp/bloodhound][https://travis-ci.org/bitemyapp/bloodhound.svg]]
10* Elasticsearch client and query DSL for Haskell
12** Why?
14Search doesn't have to be hard. Let the dog do it.
16** Version compatibility
18Elasticsearch >= 1.0 is recommended. Bloodhound mostly works with 0.9.x, but I don't recommend it if you expect everything to work.
20** Stability
22Bloodhound is alpha at the moment. The library works fine, but I don't want to mislead anyone into thinking the API is final or stable. I wouldn't call the library "complete" or representative of everything you can do in Elasticsearch but compared to clients in other languages, the usability is good.
24* Hackage page and Haddock documentation
26http://hackage.haskell.org/package/bloodhound
28* Examples
30** Index Operations
32*** Create Index
34#+BEGIN_SRC haskell
36-- Formatted for use in ghci, so there are "let"s in front of the decls.
38-- if you see :{ and :}, they're so you can copy-paste
39-- the multi-line examples into your ghci REPL.
41:set -XDeriveGeneric
42import Database.Bloodhound
43import Data.Aeson
44import Data.Either (Either(..))
45import Data.Maybe (fromJust)
46import Data.Time.Calendar (Day(..))
47import Data.Time.Clock (secondsToDiffTime, UTCTime(..))
48import Data.Text (Text)
49import GHC.Generics (Generic)
50import Network.HTTP.Conduit
51import qualified Network.HTTP.Types.Status as NHTS
53-- no trailing slashes in servers, library handles building the path.
54let testServer = (Server "http://localhost:9200")
55let testIndex = IndexName "twitter"
56let testMapping = MappingName "tweet"
58-- defaultIndexSettings is exported by Database.Bloodhound as well
59let defaultIndexSettings = IndexSettings (ShardCount 3) (ReplicaCount 2)
61-- createIndex returns IO Reply
63-- response :: Reply, Reply is a synonym for Network.HTTP.Conduit.Response
64response <- createIndex testServer defaultIndexSettings testIndex
66#+END_SRC
68*** Delete Index
70**** Code
72#+BEGIN_SRC haskell
74-- response :: Reply
75response <- deleteIndex testServer testIndex
77#+END_SRC
79**** Example Response
81#+BEGIN_SRC haskell
83-- print response if it was a success
84Response {responseStatus = Status {statusCode = 200, statusMessage = "OK"}
85 , responseVersion = HTTP/1.1
86 , responseHeaders = [("Content-Type", "application/json; charset=UTF-8")
87 , ("Content-Length", "21")]
88 , responseBody = "{\"acknowledged\":true}"
89 , responseCookieJar = CJ {expose = []}
90 , responseClose' = ResponseClose}
92-- if the index to be deleted didn't exist anyway
93Response {responseStatus = Status {statusCode = 404, statusMessage = "Not Found"}
94 , responseVersion = HTTP/1.1
95 , responseHeaders = [("Content-Type", "application/json; charset=UTF-8")
96 , ("Content-Length","65")]
97 , responseBody = "{\"error\":\"IndexMissingException[[twitter] missing]\",\"status\":404}"
98 , responseCookieJar = CJ {expose = []}
99 , responseClose' = ResponseClose}
101#+END_SRC
103*** Refresh Index
105**** Note, you *have* to do this if you expect to read what you just wrote
107#+BEGIN_SRC haskell
109resp <- refreshIndex testServer testIndex
111#+END_SRC
113**** Example Response
115#+BEGIN_SRC haskell
117-- print resp on success
118Response {responseStatus = Status {statusCode = 200, statusMessage = "OK"}
119 , responseVersion = HTTP/1.1
120 , responseHeaders = [("Content-Type", "application/json; charset=UTF-8")
121 , ("Content-Length","50")]
122 , responseBody = "{\"_shards\":{\"total\":10,\"successful\":5,\"failed\":0}}"
123 , responseCookieJar = CJ {expose = []}
124 , responseClose' = ResponseClose}
126#+END_SRC
128** Mapping Operations
130*** Create Mapping
132#+BEGIN_SRC haskell
134-- don't forget imports and the like at the top.
136data TweetMapping = TweetMapping deriving (Eq, Show)
138-- I know writing the JSON manually sucks.
139-- I don't have a proper data type for Mappings yet.
140-- Let me know if this is something you need.
142:{
143instance ToJSON TweetMapping where
144 toJSON TweetMapping =
145 object ["tweet" .=
146 object ["properties" .=
147 object ["location" .=
148 object ["type" .= ("geo_point" :: Text)]]]]
149:}
151resp <- putMapping testServer testIndex testMapping TweetMapping
153#+END_SRC
155*** Delete Mapping
157#+BEGIN_SRC haskell
159resp <- deleteMapping testServer testIndex testMapping
161#+END_SRC
163** Document Operations
165*** Indexing Documents
167#+BEGIN_SRC haskell
169-- don't forget the imports and derive generic setting for ghci
170-- at the beginning of the examples.
172:{
173data Location = Location { lat :: Double
174 , lon :: Double } deriving (Eq, Generic, Show)
176data Tweet = Tweet { user :: Text
177 , postDate :: UTCTime
178 , message :: Text
179 , age :: Int
180 , location :: Location } deriving (Eq, Generic, Show)
182exampleTweet = Tweet { user = "bitemyapp"
183 , postDate = UTCTime
184 (ModifiedJulianDay 55000)
185 (secondsToDiffTime 10)
186 , message = "Use haskell!"
187 , age = 10000
188 , location = Location 40.12 (-71.34) }
190-- automagic (generic) derivation of instances because we're lazy.
191instance ToJSON Tweet
192instance FromJSON Tweet
193instance ToJSON Location
194instance FromJSON Location
195:}
197-- Should be able to toJSON and encode the data structures like this:
198-- λ> toJSON $ Location 10.0 10.0
199-- Object fromList [("lat",Number 10.0),("lon",Number 10.0)]
200-- λ> encode $ Location 10.0 10.0
201-- "{\"lat\":10,\"lon\":10}"
203resp <- indexDocument testServer testIndex testMapping exampleTweet (DocId "1")
205#+END_SRC
207**** Example Response
209#+BEGIN_SRC haskell
211Response {responseStatus =
212 Status {statusCode = 200, statusMessage = "OK"}
213 , responseVersion = HTTP/1.1, responseHeaders =
214 [("Content-Type","application/json; charset=UTF-8"),
215 ("Content-Length","75")]
216 , responseBody = "{\"_index\":\"twitter\",\"_type\":\"tweet\",\"_id\":\"1\",\"_version\":2,\"created\":false}"
217 , responseCookieJar = CJ {expose = []}, responseClose' = ResponseClose}
219#+END_SRC
221*** Deleting Documents
223#+BEGIN_SRC haskell
225resp <- deleteDocument testServer testIndex testMapping (DocId "1")
227#+END_SRC
229*** Getting Documents
231#+BEGIN_SRC haskell
233-- n.b., you'll need the earlier imports. responseBody is from http-conduit
235resp <- getDocument testServer testIndex testMapping (DocId "1")
237-- responseBody :: Response body -> body
238let body = responseBody resp
240-- you have two options, you use decode and just get Maybe (EsResult Tweet)
241-- or you can use eitherDecode and get Either String (EsResult Tweet)
243let maybeResult = decode body :: Maybe (EsResult Tweet)
244-- the explicit typing is so Aeson knows how to parse the JSON.
246-- use either if you want to know why something failed to parse.
247-- (string errors, sadly)
248let eitherResult = decode body :: Either String (EsResult Tweet)
250-- print eitherResult should look like:
251Right (EsResult {_index = "twitter"
252 , _type = "tweet"
253 , _id = "1"
254 , _version = 2
255 , found = Just True
256 , _source = Tweet {user = "bitemyapp"
257 , postDate = 2009-06-18 00:00:10 UTC
258 , message = "Use haskell!"
259 , age = 10000
260 , location = Location {lat = 40.12, lon = -71.34}}})
262-- _source in EsResult is parametric, we dispatch the type by passing in what we expect (Tweet) as a parameter to EsResult.
264-- use the _source record accessor to get at your document
265λ> fmap _source result
266Right (Tweet {user = "bitemyapp"
267 , postDate = 2009-06-18 00:00:10 UTC
268 , message = "Use haskell!"
269 , age = 10000
270 , location = Location {lat = 40.12, lon = -71.34}})
272#+END_SRC
274** Search
276*** Querying
278**** Term Query
280#+BEGIN_SRC haskell
282-- exported by the Client module, just defaults some stuff.
283-- mkSearch :: Maybe Query -> Maybe Filter -> Search
284-- mkSearch query filter = Search query filter Nothing False 0 10
286let query = TermQuery (Term "user" "bitemyapp") Nothing
288-- AND'ing identity filter with itself and then tacking it onto a query
289-- search should be a null-operation. I include it for the sake of example.
290-- <||> (or/plus) should make it into a search that returns everything.
292let filter = IdentityFilter <&&> IdentityFilter
294-- constructing the search object the searchByIndex function dispatches on.
295let search = mkSearch (Just query) (Just filter)
297-- you can also searchByType and specify the mapping name.
298reply <- searchByIndex testServer testIndex search
300let result = eitherDecode (responseBody reply) :: Either String (SearchResult Tweet)
302λ> fmap (hits . searchHits) result
303Right [Hit {hitIndex = IndexName "twitter"
304 , hitType = MappingName "tweet"
305 , hitDocId = DocId "1"
306 , hitScore = 0.30685282
307 , hitSource = Tweet {user = "bitemyapp"
308 , postDate = 2009-06-18 00:00:10 UTC
309 , message = "Use haskell!"
310 , age = 10000
311 , location = Location {lat = 40.12, lon = -71.34}}}]
313#+END_SRC
315**** Match Query
317#+BEGIN_SRC haskell
319let query = QueryMatchQuery $ mkMatchQuery (FieldName "user") (QueryString "bitemyapp")
320let search = mkSearch (Just query) Nothing
322#+END_SRC
325**** Multi-Match Query
327#+BEGIN_SRC haskell
329let fields = [FieldName "user", FieldName "message"]
330let query = QueryMultiMatchQuery $ mkMultiMatchQuery fields (QueryString "bitemyapp")
331let search = mkSearch (Just query) Nothing
333#+END_SRC
335**** Bool Query
337#+BEGIN_SRC haskell
339let innerQuery = QueryMatchQuery $
340 mkMatchQuery (FieldName "user") (QueryString "bitemyapp")
341let query = QueryBoolQuery $
342 mkBoolQuery (Just innerQuery) Nothing Nothing
343let search = mkSearch (Just query) Nothing
345#+END_SRC
347**** Boosting Query
349#+BEGIN_SRC haskell
351let posQuery = QueryMatchQuery $
352 mkMatchQuery (FieldName "user") (QueryString "bitemyapp")
353let negQuery = QueryMatchQuery $
354 mkMatchQuery (FieldName "user") (QueryString "notmyapp")
355let query = QueryBoostingQuery $
356 BoostingQuery posQuery negQuery (Boost 0.2)
358#+END_SRC
360**** Rest of the query/filter types
362Just follow the pattern you've seen here and check the Hackage API documentation.
364*** Sorting
366#+BEGIN_SRC haskell
368let sortSpec = DefaultSortSpec $ mkSort (FieldName "age") Ascending
370-- mkSort is a shortcut function that takes a FieldName and a SortOrder
371-- to generate a vanilla DefaultSort.
372-- checkt the DefaultSort type for the full list of customizable options.
374-- From and size are integers for pagination.
376-- When sorting on a field, scores are not computed. By setting TrackSortScores to true, scores will still be computed and tracked.
378-- type Sort = [SortSpec]
379-- type TrackSortScores = Bool
380-- type From = Int
381-- type Size = Int
383-- Search takes Maybe Query
384-- -> Maybe Filter
385-- -> Maybe Sort
386-- -> TrackSortScores
387-- -> From -> Size
389-- just add more sortspecs to the list if you want tie-breakers.
390let search = Search Nothing (Just IdentityFilter) (Just [sortSpec]) False 0 10
392#+END_SRC
394*** Filtering
396**** And, Not, and Or filters
398Filters form a monoid and seminearring.
400#+BEGIN_SRC haskell
402instance Monoid Filter where
403 mempty = IdentityFilter
404 mappend a b = AndFilter [a, b] defaultCache
406instance Seminearring Filter where
407 a <||> b = OrFilter [a, b] defaultCache
409-- AndFilter and OrFilter take [Filter] as an argument.
411-- This will return anything, because IdentityFilter returns everything
412OrFilter [IdentityFilter, someOtherFilter] False
414-- This will return exactly what someOtherFilter returns
415AndFilter [IdentityFilter, someOtherFilter] False
417-- Thanks to the seminearring and monoid, the above can be expressed as:
419-- "and"
420IdentityFilter <&&> someOtherFilter
422-- "or"
423IdentityFilter <||> someOtherFilter
425-- Also there is a NotFilter, it only accepts a single filter, not a list.
427NotFilter someOtherFilter False
429#+END_SRC
431**** Identity Filter
433#+BEGIN_SRC haskell
435-- And'ing two Identity
436let queryFilter = IdentityFilter <&&> IdentityFilter
438let search = mkSearch Nothing (Just queryFilter)
440reply <- searchByType testServer testIndex testMapping search
442#+END_SRC
444**** Boolean Filter
446Similar to boolean queries.
448#+BEGIN_SRC haskell
450-- Will return only items whose "user" field contains the term "bitemyapp"
451let queryFilter = BoolFilter (MustMatch (Term "user" "bitemyapp") False)
453-- Will return only items whose "user" field does not contain the term "bitemyapp"
454let queryFilter = BoolFilter (MustNotMatch (Term "user" "bitemyapp") False)
456-- The clause (query) should appear in the matching document.
457-- In a boolean query with no must clauses, one or more should
458-- clauses must match a document. The minimum number of should
459-- clauses to match can be set using the minimum_should_match parameter.
460let queryFilter = BoolFilter (ShouldMatch [(Term "user" "bitemyapp")] False)
462#+END_SRC
464**** Exists Filter
466#+BEGIN_SRC haskell
468-- Will filter for documents that have the field "user"
469let existsFilter = ExistsFilter (FieldName "user")
471#+END_SRC
473**** Geo BoundingBox Filter
475#+BEGIN_SRC haskell
477-- topLeft and bottomRight
478let box = GeoBoundingBox (LatLon 40.73 (-74.1)) (LatLon 40.10 (-71.12))
480let constraint = GeoBoundingBoxConstraint (FieldName "tweet.location") box False
482-- second argument is GeoFilterType, memory or indexed.
483let geoFilter = GeoBoundingBoxFilter constraint GeoFilterMemory
485#+END_SRC
487**** Geo Distance Filter
489#+BEGIN_SRC haskell
491let geoPoint = GeoPoint (FieldName "tweet.location") (LatLon 40.12 (-71.34))
493-- coefficient and units
494let distance = Distance 10.0 Miles
496-- GeoFilterType or NoOptimizeBbox
497let optimizeBbox = OptimizeGeoFilterType GeoFilterMemory
499-- SloppyArc is the usual/default optimization in Elasticsearch today
500-- but pre-1.0 versions will need to pick Arc or Plane.
502let geoFilter = GeoDistanceFilter geoPoint distance SloppyArc optimizeBbox False
504#+END_SRC
506**** Geo Distance Range Filter
508Think of a donut and you won't be far off.
510#+BEGIN_SRC haskell
512let geoPoint = GeoPoint (FieldName "tweet.location") (LatLon 40.12 (-71.34))
514let distanceRange = DistanceRange (Distance 0.0 Miles) (Distance 10.0 Miles)
516let geoFilter = GeoDistanceRangeFilter geoPoint distanceRange
518#+END_SRC
520**** Geo Polygon Filter
522#+BEGIN_SRC haskell
524-- I think I drew a square here.
525let points = [LatLon 40.0 (-70.00),
526 LatLon 40.0 (-72.00),
527 LatLon 41.0 (-70.00),
528 LatLon 41.0 (-72.00)]
530let geoFilter = GeoPolygonFilter (FieldName "tweet.location") points
532#+END_SRC
534**** Document IDs filter
536#+BEGIN_SRC haskell
538-- takes a mapping name and a list of DocIds
539IdsFilter (MappingName "tweet") [DocId "1"]
541#+END_SRC
543**** Range Filter
545***** Full Range
547#+BEGIN_SRC haskell
549-- RangeFilter :: FieldName
550-- -> Either HalfRange Range
551-- -> RangeExecution
552-- -> Cache -> Filter
554let filter = RangeFilter (FieldName "age")
555 (Right (RangeLtGt (LessThan 100000.0) (GreaterThan 1000.0)))
556 RangeExecutionIndex False
558#+END_SRC
560***** Half Range
562#+BEGIN_SRC haskell
564let filter = RangeFilter (FieldName "age")
565 (Left (HalfRangeLt (LessThan 100000.0)))
566 RangeExecutionIndex False
568#+END_SRC
570**** Regexp Filter
572#+BEGIN_SRC haskell
574-- RegexpFilter
575-- :: FieldName
576-- -> Regexp
577-- -> RegexpFlags
578-- -> CacheName
579-- -> Cache
580-- -> CacheKey
581-- -> Filter
582let filter = RegexpFilter (FieldName "user") (Regexp "bite.*app")
583 RegexpAll (CacheName "test") False (CacheKey "key")
585-- RegexpFlags can be a combination of RegexpAll, Complement,
586-- Interval, Intersection, AnyString, and a combination of two options thereof.
588#+END_SRC
590* Possible future functionality
592** Span Queries
594Beginning here: http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-span-first-query.html
596** Function Score Query
598http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html
600** Node discovery and failover
602Might require TCP support.
604** Support for TCP access to Elasticsearch
606Pretend to be a transport client?
608** Bulk cluster-join merge
610Might require making a lucene index on disk with the appropriate format.
612** GeoShapeQuery
614http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-geo-shape-query.html
616** GeoShapeFilter
618http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-geo-shape-filter.html
620** Geohash cell filter
622http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-geohash-cell-filter.html
624** HasChild Filter
626http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-has-child-filter.html
628** HasParent Filter
630http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-has-parent-filter.html
632** Indices Filter
634http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-indices-filter.html
636** Query Filter
638http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-query-filter.html
640** Script based sorting
642http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/search-request-sort.html#_script_based_sorting
644** Collapsing redundantly nested and/or structures
646The Seminearring instance, if deeply nested can possibly produce nested structure that is redundant. Depending on how this affects ES perforamnce, reducing this structure might be valuable.
648** Runtime checking for cycles in data structures
650check for n > 1 occurrences in DFS:
652http://hackage.haskell.org/package/stable-maps-0.0.5/docs/System-Mem-StableName-Dynamic.html
654http://hackage.haskell.org/package/stable-maps-0.0.5/docs/System-Mem-StableName-Dynamic-Map.html
656* Photo Origin
658Photo from HA! Designs: https://www.flickr.com/photos/hadesigns/