diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..ee46e7c --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,29 @@ +run: + tests: false +linters: + disable-all: true + enable: + - deadcode + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - structcheck + - typecheck + - unused + - varcheck + - dupl + - funlen + - goconst + - gocyclo + - gocritic + - goimports + - goprintffuncname + - gosec + - lll + - misspell + - nakedret + - prealloc + - unconvert + - unparam diff --git a/README.md b/README.md new file mode 100644 index 0000000..91fbee2 --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ +# esquery + +[![](https://img.shields.io/static/v1?label=godoc&message=reference&color=blue&style=flat-square)](https://godoc.org/github.com/aquasecurity/esquery) [![](https://img.shields.io/github/license/aquasecurity/esquery?style=flat-square)](LICENSE) + +**A non-obtrusive, idiomatic and easy-to-use query and aggregation builder for the [official Go client](https://github.com/elastic/go-elasticsearch) for [ElasticSearch](https://www.elastic.co/products/elasticsearch).** + +## Table of Contents + + + * [Description](#description) + * [Status](#status) + * [Installation](#installation) + * [Usage](#usage) + * [Notes](#notes) + * [Features](#features) + * [Supported Queries](#supported-queries) + * [Supported Aggregations](#supported-aggregations) + * [Custom Queries and Aggregations](#custom-queries-and-aggregations) + * [License](#license) + + +## Description + +`esquery` alleviates the need to use extremely nested maps (`map[string]interface{}`) and serializing queries to JSON manually. It also helps eliminating common mistakes such as misspelling query types, as everything is statically typed. + +Using `esquery` can make your code much easier to write, read and maintain, and significantly reduce the amount of code you write. Wanna know how much code you'll save? just check this project's tests. + +## Status + +This is an early release, API may still change. + +## Installation + +`esquery` is a Go module. To install, simply run this in your project's root directory: + +```bash +go get github.com/aquasecurity/esquery +``` + +## Usage + +esquery provides a [method chaining](https://en.wikipedia.org/wiki/Method_chaining)-style API for building and executing queries and aggregations. It does not wrap the official Go client nor does it require you to change your existing code in order to integrate the library. Queries can be directly built with `esquery`, and executed by passing an `*elasticsearch.Client` instance (with optional search parameters). Results are returned as-is from the official client (e.g. `*esapi.Response` objects). + +Getting started is extremely simple: + +```go +package main + +import ( + "context" + "log" + + "github.com/aquasecurity/esquery" + "github.com/elastic/go-elasticsearch/v7" +) + +func main() { + // connect to an ElasticSearch instance + es, err := elasticsearch.NewDefaultClient() + if err != nil { + log.Fatalf("Failed creating client: %s", err) + } + + // run a boolean search query + qRes, err := esquery.Query( + esquery. + Bool(). + Must(esquery.Term("title", "Go and Stuff")). + Filter(esquery.Term("tag", "tech")), + ).Run( + es, + es.Search.WithContext(context.TODO()), + es.Search.WithIndex("test"), + ) + if err != nil { + log.Fatalf("Failed searching for stuff: %s", err) + } + + defer qRes.Body.Close() + + // run an aggregation + aRes, err := esquery.Aggregate( + esquery.Avg("average_score", "score"), + esquery.Max("max_score", "score"), + ).Run( + es, + es.Search.WithContext(context.TODO()), + es.Search.WithIndex("test"), + ) + if err != nil { + log.Fatalf("Failed searching for stuff: %s", err) + } + + defer aRes.Body.Close() + + // ... +} +``` + +## Notes + +* `esquery` currently supports version 7 of the ElasticSearch Go client. +* The library cannot currently generate "short queries". For example, whereas + ElasticSearch can accept this: + +```json +{ "query": { "term": { "user": "Kimchy" } } } +``` + + The library will always generate this: + +```json +{ "query": { "term": { "user": { "value": "Kimchy" } } } } +``` + + This is also true for queries such as "bool", where fields like "must" can + either receive one query object, or an array of query objects. `esquery` will + generate an array even if there's only one query object. + +## Features + +### Supported Queries + +The following queries are currently supported: + +| ElasticSearch DSL | `esquery` Function | +| ------------------------|---------------------- | +| `"match"` | `Match()` | +| `"match_bool_prefix"` | `MatchBoolPrefix()` | +| `"match_phrase"` | `MatchPhrase()` | +| `"match_phrase_prefix"` | `MatchPhrasePrefix()` | +| `"match_all"` | `MatchAll()` | +| `"match_none"` | `MatchNone()` | +| `"exists"` | `Exists()` | +| `"fuzzy"` | `Fuzzy()` | +| `"ids"` | `IDs()` | +| `"prefix"` | `Prefix()` | +| `"range"` | `Range()` | +| `"regexp"` | `Regexp()` | +| `"term"` | `Term()` | +| `"terms"` | `Terms()` | +| `"terms_set"` | `TermsSet()` | +| `"wildcard"` | `Wildcard()` | +| `"bool"` | `Bool()` | +| `"boosting"` | `Boosting()` | +| `"constant_score"` | `ConstantScore()` | +| `"dis_max"` | `DisMax()` | + +### Supported Aggregations + +The following aggregations are currently supported: + +| ElasticSearch DSL | `esquery` Function | +| ------------------------|---------------------- | +| `"avg"` | `Avg()` | +| `"weighted_avg"` | `WeightedAvg()` | +| `"cardinality"` | `Cardinality()` | +| `"max"` | `Max()` | +| `"min"` | `Min()` | +| `"sum"` | `Sum()` | +| `"value_count"` | `ValueCount()` | +| `"percentiles"` | `Percentiles()` | +| `"stats"` | `Stats()` | +| `"string_stats"` | `StringStats()` | + +#### Custom Queries and Aggregations + +To execute an arbitrary query or aggregation (including those not yet supported by the library), use the `CustomQuery()` or `CustomAgg()` functions, respectively. Both accept any `map[string]interface{}` value. + +## License + +This library is distributed under the terms of the [Apache License 2.0](LICENSE). diff --git a/aggregations.go b/aggregations.go new file mode 100644 index 0000000..254f3fe --- /dev/null +++ b/aggregations.go @@ -0,0 +1,56 @@ +package esquery + +import ( + "bytes" + "encoding/json" + + "github.com/elastic/go-elasticsearch/v7" + "github.com/elastic/go-elasticsearch/v7/esapi" +) + +type Aggregation interface { + Mappable + Name() string +} + +type AggregationRequest struct { + Aggs map[string]Mappable +} + +func Aggregate(aggs ...Aggregation) *AggregationRequest { + req := &AggregationRequest{ + Aggs: make(map[string]Mappable), + } + for _, agg := range aggs { + req.Aggs[agg.Name()] = agg + } + + return req +} + +func (req *AggregationRequest) Map() map[string]interface{} { + m := make(map[string]interface{}) + + for name, agg := range req.Aggs { + m[name] = agg.Map() + } + + return map[string]interface{}{ + "aggs": m, + } +} + +func (req *AggregationRequest) Run( + api *elasticsearch.Client, + o ...func(*esapi.SearchRequest), +) (res *esapi.Response, err error) { + var b bytes.Buffer + err = json.NewEncoder(&b).Encode(req.Map()) + if err != nil { + return nil, err + } + + opts := append([]func(*esapi.SearchRequest){api.Search.WithBody(&b)}, o...) + + return api.Search(opts...) +} diff --git a/aggregations_test.go b/aggregations_test.go new file mode 100644 index 0000000..d113f2d --- /dev/null +++ b/aggregations_test.go @@ -0,0 +1,62 @@ +package esquery + +import ( + "testing" +) + +func TestAggregations(t *testing.T) { + runMapTests(t, []mapTest{ + { + "a simple, single aggregation", + Aggregate( + Avg("average_score", "score"), + ), + map[string]interface{}{ + "aggs": map[string]interface{}{ + "average_score": map[string]interface{}{ + "avg": map[string]interface{}{ + "field": "score", + }, + }, + }, + }, + }, + { + "a complex, multi-aggregation", + Aggregate( + Sum("total_score", "score"), + WeightedAvg("weighted_score"). + Value("score", 50). + Weight("weight", 1), + StringStats("tag_stats", "tags").ShowDistribution(true), + ), + map[string]interface{}{ + "aggs": map[string]interface{}{ + "total_score": map[string]interface{}{ + "sum": map[string]interface{}{ + "field": "score", + }, + }, + "weighted_score": map[string]interface{}{ + "weighted_avg": map[string]interface{}{ + "value": map[string]interface{}{ + "field": "score", + "missing": 50, + }, + "weight": map[string]interface{}{ + "field": "weight", + "missing": 1, + }, + }, + }, + "tag_stats": map[string]interface{}{ + "string_stats": map[string]interface{}{ + "field": "tags", + "show_distribution": true, + }, + }, + }, + }, + }, + }) +} diff --git a/aggs_custom.go b/aggs_custom.go new file mode 100644 index 0000000..6a03a1c --- /dev/null +++ b/aggs_custom.go @@ -0,0 +1,13 @@ +package esquery + +type CustomAggregation struct { + m map[string]interface{} +} + +func CustomAgg(m map[string]interface{}) *CustomAggregation { + return &CustomAggregation{m} +} + +func (agg *CustomAggregation) Map() map[string]interface{} { + return agg.m +} diff --git a/aggs_custom_test.go b/aggs_custom_test.go new file mode 100644 index 0000000..a807f5f --- /dev/null +++ b/aggs_custom_test.go @@ -0,0 +1,35 @@ +package esquery + +import "testing" + +func TestCustomAgg(t *testing.T) { + m := map[string]interface{}{ + "genres": map[string]interface{}{ + "terms": map[string]interface{}{ + "field": "genre", + }, + "t_shirts": map[string]interface{}{ + "filter": map[string]interface{}{ + "term": map[string]interface{}{ + "type": "t-shirt", + }, + }, + "aggs": map[string]interface{}{ + "avg_price": map[string]interface{}{ + "avg": map[string]interface{}{ + "field": "price", + }, + }, + }, + }, + }, + } + + runMapTests(t, []mapTest{ + { + "custom aggregation", + CustomAgg(m), + m, + }, + }) +} diff --git a/aggs_metric.go b/aggs_metric.go new file mode 100644 index 0000000..c120759 --- /dev/null +++ b/aggs_metric.go @@ -0,0 +1,324 @@ +package esquery + +import "github.com/fatih/structs" + +type BaseAgg struct { + name string + apiName string + *BaseAggParams `structs:",flatten"` +} + +type BaseAggParams struct { + Field string `structs:"field"` + Miss interface{} `structs:"missing,omitempty"` +} + +func newBaseAgg(apiName, name, field string) *BaseAgg { + return &BaseAgg{ + name: name, + apiName: apiName, + BaseAggParams: &BaseAggParams{ + Field: field, + }, + } +} + +func (agg *BaseAgg) Name() string { + return agg.name +} + +func (agg *BaseAgg) Map() map[string]interface{} { + return map[string]interface{}{ + agg.apiName: structs.Map(agg.BaseAggParams), + } +} + +/******************************************************************************* + * Avg Aggregation + * https://www.elastic.co/guide/en/elasticsearch/reference/ + * current/search-aggregations-metrics-avg-aggregation.html + ******************************************************************************/ + +type AvgAgg struct { + *BaseAgg `structs:",flatten"` +} + +func Avg(name, field string) *AvgAgg { + return &AvgAgg{ + BaseAgg: newBaseAgg("avg", name, field), + } +} + +func (agg *AvgAgg) Missing(val interface{}) *AvgAgg { + agg.Miss = val + return agg +} + +/******************************************************************************* + * Weighed Avg Aggregation + * https://www.elastic.co/guide/en/elasticsearch/reference/ + * current/search-aggregations-metrics-weight-avg-aggregation.html + ******************************************************************************/ + +type WeightedAvgAgg struct { + name string + apiName string + Val *BaseAggParams `structs:"value"` + Weig *BaseAggParams `structs:"weight"` +} + +func WeightedAvg(name string) *WeightedAvgAgg { + return &WeightedAvgAgg{ + name: name, + apiName: "weighted_avg", + } +} + +func (agg *WeightedAvgAgg) Name() string { + return agg.name +} + +func (agg *WeightedAvgAgg) Value(field string, missing ...interface{}) *WeightedAvgAgg { + agg.Val = new(BaseAggParams) + agg.Val.Field = field + if len(missing) > 0 { + agg.Val.Miss = missing[len(missing)-1] + } + return agg +} + +func (agg *WeightedAvgAgg) Weight(field string, missing ...interface{}) *WeightedAvgAgg { + agg.Weig = new(BaseAggParams) + agg.Weig.Field = field + if len(missing) > 0 { + agg.Weig.Miss = missing[len(missing)-1] + } + return agg +} + +func (agg *WeightedAvgAgg) Map() map[string]interface{} { + return map[string]interface{}{ + agg.apiName: structs.Map(agg), + } +} + +/******************************************************************************* + * Cardinality Aggregation + * https://www.elastic.co/guide/en/elasticsearch/reference/ + * current/search-aggregations-metrics-cardinality-aggregation.html + ******************************************************************************/ + +type CardinalityAgg struct { + *BaseAgg `structs:",flatten"` + PrecisionThr uint16 `structs:"precision_threshold,omitempty"` +} + +func Cardinality(name, field string) *CardinalityAgg { + return &CardinalityAgg{ + BaseAgg: newBaseAgg("cardinality", name, field), + } +} + +func (agg *CardinalityAgg) Missing(val interface{}) *CardinalityAgg { + agg.Miss = val + return agg +} + +func (agg *CardinalityAgg) PrecisionThreshold(val uint16) *CardinalityAgg { + agg.PrecisionThr = val + return agg +} + +func (agg *CardinalityAgg) Map() map[string]interface{} { + return map[string]interface{}{ + agg.apiName: structs.Map(agg), + } +} + +/******************************************************************************* + * Max Aggregation + * https://www.elastic.co/guide/en/elasticsearch/reference/ + * current/search-aggregations-metrics-max-aggregation.html + ******************************************************************************/ + +type MaxAgg struct { + *BaseAgg `structs:",flatten"` +} + +func Max(name, field string) *MaxAgg { + return &MaxAgg{ + BaseAgg: newBaseAgg("max", name, field), + } +} + +func (agg *MaxAgg) Missing(val interface{}) *MaxAgg { + agg.Miss = val + return agg +} + +/******************************************************************************* + * Min Aggregation + * https://www.elastic.co/guide/en/elasticsearch/reference/ + * current/search-aggregations-metrics-min-aggregation.html + ******************************************************************************/ + +type MinAgg struct { + *BaseAgg `structs:",flatten"` +} + +func Min(name, field string) *MinAgg { + return &MinAgg{ + BaseAgg: newBaseAgg("min", name, field), + } +} + +func (agg *MinAgg) Missing(val interface{}) *MinAgg { + agg.Miss = val + return agg +} + +/******************************************************************************* + * Sum Aggregation + * https://www.elastic.co/guide/en/elasticsearch/reference/ + * current/search-aggregations-metrics-sum-aggregation.html + ******************************************************************************/ + +type SumAgg struct { + *BaseAgg `structs:",flatten"` +} + +func Sum(name, field string) *SumAgg { + return &SumAgg{ + BaseAgg: newBaseAgg("sum", name, field), + } +} + +func (agg *SumAgg) Missing(val interface{}) *SumAgg { + agg.Miss = val + return agg +} + +/******************************************************************************* + * Value Count Aggregation + * https://www.elastic.co/guide/en/elasticsearch/reference/ + * current/search-aggregations-metrics-valuecount-aggregation.html + ******************************************************************************/ + +type ValueCountAgg struct { + *BaseAgg `structs:",flatten"` +} + +func ValueCount(name, field string) *ValueCountAgg { + return &ValueCountAgg{ + BaseAgg: newBaseAgg("value_count", name, field), + } +} + +/******************************************************************************* + * Percentiles Aggregation + * https://www.elastic.co/guide/en/elasticsearch/reference/ + * current/search-aggregations-metrics-percentile-aggregation.html + ******************************************************************************/ + +type PercentilesAgg struct { + *BaseAgg `structs:",flatten"` + Prcnts []float32 `structs:"percents,omitempty"` + Key *bool `structs:"keyed,omitempty"` + TDigest struct { + Compression uint16 `structs:"compression,omitempty"` + } `structs:"tdigest,omitempty"` + HDR struct { + NumHistogramDigits uint8 `structs:"number_of_significant_value_digits,omitempty"` + } `structs:"hdr,omitempty"` +} + +func Percentiles(name, field string) *PercentilesAgg { + return &PercentilesAgg{ + BaseAgg: newBaseAgg("percentiles", name, field), + } +} + +func (agg *PercentilesAgg) Percents(percents ...float32) *PercentilesAgg { + agg.Prcnts = percents + return agg +} + +func (agg *PercentilesAgg) Missing(val interface{}) *PercentilesAgg { + agg.Miss = val + return agg +} + +func (agg *PercentilesAgg) Keyed(b bool) *PercentilesAgg { + agg.Key = &b + return agg +} + +func (agg *PercentilesAgg) Compression(val uint16) *PercentilesAgg { + agg.TDigest.Compression = val + return agg +} + +func (agg *PercentilesAgg) NumHistogramDigits(val uint8) *PercentilesAgg { + agg.HDR.NumHistogramDigits = val + return agg +} + +func (agg *PercentilesAgg) Map() map[string]interface{} { + return map[string]interface{}{ + agg.apiName: structs.Map(agg), + } +} + +/******************************************************************************* + * Stats Aggregation + * https://www.elastic.co/guide/en/elasticsearch/reference/ + * current/search-aggregations-metrics-stats-aggregation.html + ******************************************************************************/ + +type StatsAgg struct { + *BaseAgg `structs:",flatten"` +} + +func Stats(name, field string) *StatsAgg { + return &StatsAgg{ + BaseAgg: newBaseAgg("stats", name, field), + } +} + +func (agg *StatsAgg) Missing(val interface{}) *StatsAgg { + agg.Miss = val + return agg +} + +/******************************************************************************* + * String Stats Aggregation + * https://www.elastic.co/guide/en/elasticsearch/reference/ + * current/search-aggregations-metrics-string-stats-aggregation.html + ******************************************************************************/ + +type StringStatsAgg struct { + *BaseAgg `structs:",flatten"` + ShowDist *bool `structs:"show_distribution,omitempty"` +} + +func StringStats(name, field string) *StringStatsAgg { + return &StringStatsAgg{ + BaseAgg: newBaseAgg("string_stats", name, field), + } +} + +func (agg *StringStatsAgg) Missing(val interface{}) *StringStatsAgg { + agg.Miss = val + return agg +} + +func (agg *StringStatsAgg) ShowDistribution(b bool) *StringStatsAgg { + agg.ShowDist = &b + return agg +} + +func (agg *StringStatsAgg) Map() map[string]interface{} { + return map[string]interface{}{ + agg.apiName: structs.Map(agg), + } +} diff --git a/aggs_metric_test.go b/aggs_metric_test.go new file mode 100644 index 0000000..54100e2 --- /dev/null +++ b/aggs_metric_test.go @@ -0,0 +1,158 @@ +package esquery + +import "testing" + +func TestMetricAggs(t *testing.T) { + runMapTests(t, []mapTest{ + { + "avg agg: simple", + Avg("average_score", "score"), + map[string]interface{}{ + "avg": map[string]interface{}{ + "field": "score", + }, + }, + }, + { + "avg agg: with missing", + Avg("average_score", "score").Missing(2), + map[string]interface{}{ + "avg": map[string]interface{}{ + "field": "score", + "missing": 2, + }, + }, + }, + { + "weighted avg", + WeightedAvg("weighted_grade").Value("grade", 2).Weight("weight"), + map[string]interface{}{ + "weighted_avg": map[string]interface{}{ + "value": map[string]interface{}{ + "field": "grade", + "missing": 2, + }, + "weight": map[string]interface{}{ + "field": "weight", + }, + }, + }, + }, + { + "cardinality: no precision threshold", + Cardinality("type_count", "type"), + map[string]interface{}{ + "cardinality": map[string]interface{}{ + "field": "type", + }, + }, + }, + { + "cardinality: with precision threshold", + Cardinality("type_count", "type").PrecisionThreshold(100), + map[string]interface{}{ + "cardinality": map[string]interface{}{ + "field": "type", + "precision_threshold": 100, + }, + }, + }, + { + "value_count agg: simple", + ValueCount("num_values", "score"), + map[string]interface{}{ + "value_count": map[string]interface{}{ + "field": "score", + }, + }, + }, + { + "sum agg: simple", + Sum("total_score", "score").Missing(1), + map[string]interface{}{ + "sum": map[string]interface{}{ + "field": "score", + "missing": 1, + }, + }, + }, + { + "max agg: simple", + Max("max_score", "score"), + map[string]interface{}{ + "max": map[string]interface{}{ + "field": "score", + }, + }, + }, + { + "min agg: simple", + Min("min_score", "score"), + map[string]interface{}{ + "min": map[string]interface{}{ + "field": "score", + }, + }, + }, + { + "percentiles: simple", + Percentiles("load_time_outlier", "load_time"), + map[string]interface{}{ + "percentiles": map[string]interface{}{ + "field": "load_time", + }, + }, + }, + { + "percentiles: complex", + Percentiles("load_time_outlier", "load_time"). + Keyed(true). + Percents(95, 99, 99.9). + Compression(200). + NumHistogramDigits(3). + Missing(20), + map[string]interface{}{ + "percentiles": map[string]interface{}{ + "field": "load_time", + "percents": []float32{95, 99, 99.9}, + "keyed": true, + "missing": 20, + "tdigest": map[string]interface{}{ + "compression": 200, + }, + "hdr": map[string]interface{}{ + "number_of_significant_value_digits": 3, + }, + }, + }, + }, + { + "stats agg", + Stats("grades_stats", "grade"), + map[string]interface{}{ + "stats": map[string]interface{}{ + "field": "grade", + }, + }, + }, + { + "string_stats agg: no show distribution", + StringStats("message_stats", "message.keyword"), + map[string]interface{}{ + "string_stats": map[string]interface{}{ + "field": "message.keyword", + }, + }, + }, + { + "string_stats agg: with show distribution", + StringStats("message_stats", "message.keyword").ShowDistribution(false), + map[string]interface{}{ + "string_stats": map[string]interface{}{ + "field": "message.keyword", + "show_distribution": false, + }, + }, + }, + }) +} diff --git a/es.go b/es.go new file mode 100644 index 0000000..3c21c4b --- /dev/null +++ b/es.go @@ -0,0 +1,5 @@ +package esquery + +type Mappable interface { + Map() map[string]interface{} +} diff --git a/es_test.go b/es_test.go new file mode 100644 index 0000000..346330a --- /dev/null +++ b/es_test.go @@ -0,0 +1,40 @@ +package esquery + +import ( + "encoding/json" + "reflect" + "testing" +) + +type mapTest struct { + name string + q Mappable + exp map[string]interface{} +} + +func runMapTests(t *testing.T, tests []mapTest) { + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + m := test.q.Map() + + // convert both maps to JSON in order to compare them. we do not + // use reflect.DeepEqual on the maps as this doesn't always work + exp, got, ok := sameJSON(test.exp, m) + if !ok { + t.Errorf("expected %s, got %s", exp, got) + } + }) + } +} + +func sameJSON(a, b map[string]interface{}) (aJSON, bJSON []byte, ok bool) { + aJSON, aErr := json.Marshal(a) + bJSON, bErr := json.Marshal(b) + + if aErr != nil || bErr != nil { + return aJSON, bJSON, false + } + + ok = reflect.DeepEqual(aJSON, bJSON) + return aJSON, bJSON, ok +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..79ae130 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/aquasecurity/esquery + +go 1.13 + +require ( + github.com/elastic/go-elasticsearch/v7 v7.6.0 + github.com/fatih/structs v1.1.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..680eeb3 --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/elastic/go-elasticsearch v0.0.0 h1:Pd5fqOuBxKxv83b0+xOAJDAkziWYwFinWnBO0y+TZaA= +github.com/elastic/go-elasticsearch v0.0.0/go.mod h1:TkBSJBuTyFdBnrNqoPc54FN0vKf5c04IdM4zuStJ7xg= +github.com/elastic/go-elasticsearch/v7 v7.6.0 h1:sYpGLpEFHgLUKLsZUBfuaVI9QgHjS3JdH9fX4/z8QI8= +github.com/elastic/go-elasticsearch/v7 v7.6.0/go.mod h1:OJ4wdbtDNk5g503kvlHLyErCgQwwzmDtaFC4XyOxXA4= +github.com/elastic/go-elasticsearch/v8 v8.0.0-20200210103600-aff00e5adfde h1:Y9SZx8RQqFycLxi5W5eFmxMqnmijULVc3LMjBTtZQdM= +github.com/elastic/go-elasticsearch/v8 v8.0.0-20200210103600-aff00e5adfde/go.mod h1:xe9a/L2aeOgFKKgrO3ibQTnMdpAeL0GC+5/HpGScSa4= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= diff --git a/queries.go b/queries.go new file mode 100644 index 0000000..25a4fdd --- /dev/null +++ b/queries.go @@ -0,0 +1,38 @@ +package esquery + +import ( + "bytes" + "encoding/json" + + "github.com/elastic/go-elasticsearch/v7" + "github.com/elastic/go-elasticsearch/v7/esapi" +) + +type QueryRequest struct { + Query Mappable +} + +func Query(q Mappable) *QueryRequest { + return &QueryRequest{q} +} + +func (req *QueryRequest) Map() map[string]interface{} { + return map[string]interface{}{ + "query": req.Query.Map(), + } +} + +func (req *QueryRequest) Run( + api *elasticsearch.Client, + o ...func(*esapi.SearchRequest), +) (res *esapi.Response, err error) { + var b bytes.Buffer + err = json.NewEncoder(&b).Encode(req.Query.Map()) + if err != nil { + return nil, err + } + + opts := append([]func(*esapi.SearchRequest){api.Search.WithBody(&b)}, o...) + + return api.Search(opts...) +} diff --git a/queries_test.go b/queries_test.go new file mode 100644 index 0000000..64b4d60 --- /dev/null +++ b/queries_test.go @@ -0,0 +1,68 @@ +package esquery + +import ( + "testing" +) + +func TestQueries(t *testing.T) { + runMapTests(t, []mapTest{ + { + "a simple match_all query", + Query(MatchAll()), + map[string]interface{}{ + "query": map[string]interface{}{ + "match_all": map[string]interface{}{}, + }, + }, + }, + { + "a complex query", + Query( + Bool(). + Must( + Range("date"). + Gt("some time in the past"). + Lte("now"). + Relation(CONTAINS). + TimeZone("Asia/Jerusalem"). + Boost(2.3), + + Match("author"). + Query("some guy"). + Analyzer("analyzer?"). + Fuzziness("fuzz"), + ). + Boost(3.1), + ), + map[string]interface{}{ + "query": map[string]interface{}{ + "bool": map[string]interface{}{ + "must": []map[string]interface{}{ + { + "range": map[string]interface{}{ + "date": map[string]interface{}{ + "gt": "some time in the past", + "lte": "now", + "relation": "CONTAINS", + "time_zone": "Asia/Jerusalem", + "boost": 2.3, + }, + }, + }, + { + "match": map[string]interface{}{ + "author": map[string]interface{}{ + "query": "some guy", + "analyzer": "analyzer?", + "fuzziness": "fuzz", + }, + }, + }, + }, + "boost": 3.1, + }, + }, + }, + }, + }) +} diff --git a/query_boolean.go b/query_boolean.go new file mode 100644 index 0000000..3e93c28 --- /dev/null +++ b/query_boolean.go @@ -0,0 +1,97 @@ +package esquery + +import "github.com/fatih/structs" + +/******************************************************************************* + * Boolean Queries + * https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-bool-query.html + ******************************************************************************/ + +type BoolQuery struct { + must []Mappable + filter []Mappable + mustNot []Mappable + should []Mappable + minimumShouldMatch int16 + boost float32 +} + +func Bool() *BoolQuery { + return &BoolQuery{} +} + +func (q *BoolQuery) Must(must ...Mappable) *BoolQuery { + q.must = append(q.must, must...) + return q +} + +func (q *BoolQuery) Filter(filter ...Mappable) *BoolQuery { + q.filter = append(q.filter, filter...) + return q +} + +func (q *BoolQuery) MustNot(mustnot ...Mappable) *BoolQuery { + q.mustNot = append(q.mustNot, mustnot...) + return q +} + +func (q *BoolQuery) Should(should ...Mappable) *BoolQuery { + q.should = append(q.should, should...) + return q +} + +func (q *BoolQuery) MinimumShouldMatch(val int16) *BoolQuery { + q.minimumShouldMatch = val + return q +} + +func (q *BoolQuery) Boost(val float32) *BoolQuery { + q.boost = val + return q +} + +func (q *BoolQuery) Map() map[string]interface{} { + var data struct { + Must []map[string]interface{} `structs:"must,omitempty"` + Filter []map[string]interface{} `structs:"filter,omitempty"` + MustNot []map[string]interface{} `structs:"must_not,omitempty"` + Should []map[string]interface{} `structs:"should,omitempty"` + MinimumShouldMatch int16 `structs:"minimum_should_match,omitempty"` + Boost float32 `structs:"boost,omitempty"` + } + + data.MinimumShouldMatch = q.minimumShouldMatch + data.Boost = q.boost + + if len(q.must) > 0 { + data.Must = make([]map[string]interface{}, len(q.must)) + for i, m := range q.must { + data.Must[i] = m.Map() + } + } + + if len(q.filter) > 0 { + data.Filter = make([]map[string]interface{}, len(q.filter)) + for i, m := range q.filter { + data.Filter[i] = m.Map() + } + } + + if len(q.mustNot) > 0 { + data.MustNot = make([]map[string]interface{}, len(q.mustNot)) + for i, m := range q.mustNot { + data.MustNot[i] = m.Map() + } + } + + if len(q.should) > 0 { + data.Should = make([]map[string]interface{}, len(q.should)) + for i, m := range q.should { + data.Should[i] = m.Map() + } + } + + return map[string]interface{}{ + "bool": structs.Map(data), + } +} diff --git a/query_boolean_test.go b/query_boolean_test.go new file mode 100644 index 0000000..7acbab6 --- /dev/null +++ b/query_boolean_test.go @@ -0,0 +1,107 @@ +package esquery + +import ( + "testing" +) + +func TestBool(t *testing.T) { + runMapTests(t, []mapTest{ + { + "bool with only a simple must", + Bool().Must(Term("tag", "tech")), + map[string]interface{}{ + "bool": map[string]interface{}{ + "must": []map[string]interface{}{ + { + "term": map[string]interface{}{ + "tag": map[string]interface{}{ + "value": "tech", + }, + }, + }, + }, + }, + }, + }, + { + "bool which must match_all and filter", + Bool().Must(MatchAll()).Filter(Term("status", "active")), + map[string]interface{}{ + "bool": map[string]interface{}{ + "must": []map[string]interface{}{ + {"match_all": map[string]interface{}{}}, + }, + "filter": []map[string]interface{}{ + { + "term": map[string]interface{}{ + "status": map[string]interface{}{ + "value": "active", + }, + }, + }, + }, + }, + }, + }, + { + "bool with a lot of stuff", + Bool(). + Must(Term("user", "kimchy")). + Filter(Term("tag", "tech")). + MustNot(Range("age").Gte(10).Lte(20)). + Should(Term("tag", "wow"), Term("tag", "elasticsearch")). + MinimumShouldMatch(1). + Boost(1.1), + map[string]interface{}{ + "bool": map[string]interface{}{ + "must": []map[string]interface{}{ + { + "term": map[string]interface{}{ + "user": map[string]interface{}{ + "value": "kimchy", + }, + }, + }, + }, + "filter": []map[string]interface{}{ + { + "term": map[string]interface{}{ + "tag": map[string]interface{}{ + "value": "tech", + }, + }, + }, + }, + "must_not": []map[string]interface{}{ + { + "range": map[string]interface{}{ + "age": map[string]interface{}{ + "gte": 10, + "lte": 20, + }, + }, + }, + }, + "should": []map[string]interface{}{ + { + "term": map[string]interface{}{ + "tag": map[string]interface{}{ + "value": "wow", + }, + }, + }, + { + "term": map[string]interface{}{ + "tag": map[string]interface{}{ + "value": "elasticsearch", + }, + }, + }, + }, + "minimum_should_match": 1, + "boost": 1.1, + }, + }, + }, + }) +} diff --git a/query_boosting.go b/query_boosting.go new file mode 100644 index 0000000..9342164 --- /dev/null +++ b/query_boosting.go @@ -0,0 +1,41 @@ +package esquery + +/******************************************************************************* + * Boosting Queries + * https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-boosting-query.html + ******************************************************************************/ + +type BoostingQuery struct { + Pos Mappable + Neg Mappable + NegBoost float32 +} + +func Boosting() *BoostingQuery { + return &BoostingQuery{} +} + +func (q *BoostingQuery) Positive(p Mappable) *BoostingQuery { + q.Pos = p + return q +} + +func (q *BoostingQuery) Negative(p Mappable) *BoostingQuery { + q.Neg = p + return q +} + +func (q *BoostingQuery) NegativeBoost(b float32) *BoostingQuery { + q.NegBoost = b + return q +} + +func (q *BoostingQuery) Map() map[string]interface{} { + return map[string]interface{}{ + "boosting": map[string]interface{}{ + "positive": q.Pos.Map(), + "negative": q.Neg.Map(), + "negative_boost": q.NegBoost, + }, + } +} diff --git a/query_boosting_test.go b/query_boosting_test.go new file mode 100644 index 0000000..c88b35f --- /dev/null +++ b/query_boosting_test.go @@ -0,0 +1,36 @@ +package esquery + +import ( + "testing" +) + +func TestBoosting(t *testing.T) { + runMapTests(t, []mapTest{ + { + "boosting query", + Boosting(). + Positive(Term("text", "apple")). + Negative(Term("text", "pie tart")). + NegativeBoost(0.5), + map[string]interface{}{ + "boosting": map[string]interface{}{ + "positive": map[string]interface{}{ + "term": map[string]interface{}{ + "text": map[string]interface{}{ + "value": "apple", + }, + }, + }, + "negative": map[string]interface{}{ + "term": map[string]interface{}{ + "text": map[string]interface{}{ + "value": "pie tart", + }, + }, + }, + "negative_boost": 0.5, + }, + }, + }, + }) +} diff --git a/query_constant_score.go b/query_constant_score.go new file mode 100644 index 0000000..cf65090 --- /dev/null +++ b/query_constant_score.go @@ -0,0 +1,33 @@ +package esquery + +import "github.com/fatih/structs" + +/******************************************************************************* + * Constant Score Queries + * https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-constant-score-query.html + ******************************************************************************/ + +type ConstantScoreQuery struct { + filter Mappable + boost float32 +} + +func ConstantScore(filter Mappable) *ConstantScoreQuery { + return &ConstantScoreQuery{ + filter: filter, + } +} + +func (q *ConstantScoreQuery) Boost(b float32) *ConstantScoreQuery { + q.boost = b + return q +} + +func (q *ConstantScoreQuery) Map() map[string]interface{} { + return map[string]interface{}{ + "constant_score": structs.Map(struct { + Filter map[string]interface{} `structs:"filter"` + Boost float32 `structs:"boost,omitempty"` + }{q.filter.Map(), q.boost}), + } +} diff --git a/query_constant_score_test.go b/query_constant_score_test.go new file mode 100644 index 0000000..c62576a --- /dev/null +++ b/query_constant_score_test.go @@ -0,0 +1,41 @@ +package esquery + +import ( + "testing" +) + +func TestConstantScore(t *testing.T) { + runMapTests(t, []mapTest{ + { + "constant_score query without boost", + ConstantScore(Term("user", "kimchy")), + map[string]interface{}{ + "constant_score": map[string]interface{}{ + "filter": map[string]interface{}{ + "term": map[string]interface{}{ + "user": map[string]interface{}{ + "value": "kimchy", + }, + }, + }, + }, + }, + }, + { + "constant_score query with boost", + ConstantScore(Term("user", "kimchy")).Boost(2.2), + map[string]interface{}{ + "constant_score": map[string]interface{}{ + "filter": map[string]interface{}{ + "term": map[string]interface{}{ + "user": map[string]interface{}{ + "value": "kimchy", + }, + }, + }, + "boost": 2.2, + }, + }, + }, + }) +} diff --git a/query_custom.go b/query_custom.go new file mode 100644 index 0000000..8c69dae --- /dev/null +++ b/query_custom.go @@ -0,0 +1,13 @@ +package esquery + +type CustomQry struct { + m map[string]interface{} +} + +func CustomQuery(m map[string]interface{}) *CustomQry { + return &CustomQry{m} +} + +func (q *CustomQry) Map() map[string]interface{} { + return q.m +} diff --git a/query_custom_test.go b/query_custom_test.go new file mode 100644 index 0000000..34ecfae --- /dev/null +++ b/query_custom_test.go @@ -0,0 +1,23 @@ +package esquery + +import "testing" + +func TestCustomQuery(t *testing.T) { + m := map[string]interface{}{ + "geo_distance": map[string]interface{}{ + "distance": "200km", + "pin.location": map[string]interface{}{ + "lat": 40, + "lon": -70, + }, + }, + } + + runMapTests(t, []mapTest{ + { + "custom query", + CustomQuery(m), + m, + }, + }) +} diff --git a/query_dis_max.go b/query_dis_max.go new file mode 100644 index 0000000..2889e3b --- /dev/null +++ b/query_dis_max.go @@ -0,0 +1,37 @@ +package esquery + +import "github.com/fatih/structs" + +/******************************************************************************* + * Disjunction Max Queries + * https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-dis-max-query.html + ******************************************************************************/ + +type DisMaxQuery struct { + queries []Mappable + tieBreaker float32 +} + +func DisMax(queries ...Mappable) *DisMaxQuery { + return &DisMaxQuery{ + queries: queries, + } +} + +func (q *DisMaxQuery) TieBreaker(b float32) *DisMaxQuery { + q.tieBreaker = b + return q +} + +func (q *DisMaxQuery) Map() map[string]interface{} { + inner := make([]map[string]interface{}, len(q.queries)) + for i, iq := range q.queries { + inner[i] = iq.Map() + } + return map[string]interface{}{ + "dis_max": structs.Map(struct { + Queries []map[string]interface{} `structs:"queries"` + TieBreaker float32 `structs:"tie_breaker,omitempty"` + }{inner, q.tieBreaker}), + } +} diff --git a/query_dis_max_test.go b/query_dis_max_test.go new file mode 100644 index 0000000..1152e14 --- /dev/null +++ b/query_dis_max_test.go @@ -0,0 +1,35 @@ +package esquery + +import ( + "testing" +) + +func TestDisMax(t *testing.T) { + runMapTests(t, []mapTest{ + { + "dis_max", + DisMax(Term("title", "Quick pets"), Term("body", "Quick pets")).TieBreaker(0.7), + map[string]interface{}{ + "dis_max": map[string]interface{}{ + "queries": []map[string]interface{}{ + { + "term": map[string]interface{}{ + "title": map[string]interface{}{ + "value": "Quick pets", + }, + }, + }, + { + "term": map[string]interface{}{ + "body": map[string]interface{}{ + "value": "Quick pets", + }, + }, + }, + }, + "tie_breaker": 0.7, + }, + }, + }, + }) +} diff --git a/query_match.go b/query_match.go new file mode 100644 index 0000000..0341746 --- /dev/null +++ b/query_match.go @@ -0,0 +1,203 @@ +package esquery + +import ( + "bytes" + "io" + + "github.com/fatih/structs" +) + +/******************************************************************************* + * Match Queries + * https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query.html + * https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-bool-prefix-query.html + * https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query-phrase.html + * https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query-phrase-prefix.html + ******************************************************************************/ +type matchType uint8 + +const ( + TypeMatch matchType = iota + TypeMatchBoolPrefix + TypeMatchPhrase + TypeMatchPhrasePrefix +) + +type MatchQuery struct { + field string + mType matchType + params matchParams +} + +func (a *MatchQuery) Map() map[string]interface{} { + var mType string + switch a.mType { + case TypeMatch: + mType = "match" + case TypeMatchBoolPrefix: + mType = "match_bool_prefix" + case TypeMatchPhrase: + mType = "match_phrase" + case TypeMatchPhrasePrefix: + mType = "match_phrase_prefix" + } + + return map[string]interface{}{ + mType: map[string]interface{}{ + a.field: structs.Map(a.params), + }, + } +} + +type matchParams struct { + Qry interface{} `structs:"query"` + Anl string `structs:"analyzer,omitempty"` + AutoGenerate *bool `structs:"auto_generate_synonyms_phrase_query,omitempty"` + Fuzz string `structs:"fuzziness,omitempty"` + MaxExp uint16 `structs:"max_expansions,omitempty"` + PrefLen uint16 `structs:"prefix_length,omitempty"` + Trans *bool `structs:"transpositions,omitempty"` + FuzzyRw string `structs:"fuzzy_rewrite,omitempty"` + Lent bool `structs:"lenient,omitempty"` + Op MatchOperator `structs:"operator,string,omitempty"` + MinMatch string `structs:"minimum_should_match,omitempty"` + ZeroTerms ZeroTerms `structs:"zero_terms_query,string,omitempty"` + Slp uint16 `structs:"slop,omitempty"` // only relevant for match_phrase query +} + +func Match(fieldName string, simpleQuery ...interface{}) *MatchQuery { + return newMatch(TypeMatch, fieldName, simpleQuery...) +} + +func MatchBoolPrefix(fieldName string, simpleQuery ...interface{}) *MatchQuery { + return newMatch(TypeMatchBoolPrefix, fieldName, simpleQuery...) +} + +func MatchPhrase(fieldName string, simpleQuery ...interface{}) *MatchQuery { + return newMatch(TypeMatchPhrase, fieldName, simpleQuery...) +} + +func MatchPhrasePrefix(fieldName string, simpleQuery ...interface{}) *MatchQuery { + return newMatch(TypeMatchPhrasePrefix, fieldName, simpleQuery...) +} + +func newMatch(mType matchType, fieldName string, simpleQuery ...interface{}) *MatchQuery { + var qry interface{} + if len(simpleQuery) > 0 { + qry = simpleQuery[len(simpleQuery)-1] + } + + return &MatchQuery{ + field: fieldName, + mType: mType, + params: matchParams{ + Qry: qry, + }, + } +} + +func (q *MatchQuery) Query(data interface{}) *MatchQuery { + q.params.Qry = data + return q +} + +func (q *MatchQuery) Analyzer(a string) *MatchQuery { + q.params.Anl = a + return q +} + +func (q *MatchQuery) AutoGenerateSynonymsPhraseQuery(b bool) *MatchQuery { + q.params.AutoGenerate = &b + return q +} + +func (q *MatchQuery) Fuzziness(f string) *MatchQuery { + q.params.Fuzz = f + return q +} + +func (q *MatchQuery) MaxExpansions(e uint16) *MatchQuery { + q.params.MaxExp = e + return q +} + +func (q *MatchQuery) PrefixLength(l uint16) *MatchQuery { + q.params.PrefLen = l + return q +} + +func (q *MatchQuery) Transpositions(b bool) *MatchQuery { + q.params.Trans = &b + return q +} + +func (q *MatchQuery) FuzzyRewrite(s string) *MatchQuery { + q.params.FuzzyRw = s + return q +} + +func (q *MatchQuery) Lenient(b bool) *MatchQuery { + q.params.Lent = b + return q +} + +func (q *MatchQuery) Operator(op MatchOperator) *MatchQuery { + q.params.Op = op + return q +} + +func (q *MatchQuery) MinimumShouldMatch(s string) *MatchQuery { + q.params.MinMatch = s + return q +} + +func (q *MatchQuery) Slop(n uint16) *MatchQuery { + q.params.Slp = n + return q +} + +func (q *MatchQuery) ZeroTermsQuery(s ZeroTerms) *MatchQuery { + q.params.ZeroTerms = s + return q +} + +func (q *MatchQuery) Reader() io.Reader { + var b bytes.Buffer + return &b +} + +type MatchOperator uint8 + +const ( + OR MatchOperator = iota + AND +) + +func (a MatchOperator) String() string { + switch a { + case OR: + return "or" + case AND: + return "and" + default: + return "" + } +} + +type ZeroTerms uint8 + +const ( + None ZeroTerms = iota + All +) + +func (a ZeroTerms) String() string { + switch a { + case None: + return "none" + case All: + return "all" + default: + return "" + } +} diff --git a/query_match_all.go b/query_match_all.go new file mode 100644 index 0000000..0d7f25a --- /dev/null +++ b/query_match_all.go @@ -0,0 +1,46 @@ +package esquery + +import "github.com/fatih/structs" + +/******************************************************************************* + * Match All Queries + * https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-all-query.html + ******************************************************************************/ + +type MatchAllQuery struct { + all bool + params matchAllParams +} + +type matchAllParams struct { + Boost float32 `structs:"boost,omitempty"` +} + +func (a *MatchAllQuery) Map() map[string]interface{} { + var mType string + switch a.all { + case true: + mType = "match_all" + default: + mType = "match_none" + } + + return map[string]interface{}{ + mType: structs.Map(a.params), + } +} + +func MatchAll() *MatchAllQuery { + return &MatchAllQuery{all: true} +} + +func (q *MatchAllQuery) Boost(b float32) *MatchAllQuery { + if q.all { + q.params.Boost = b + } + return q +} + +func MatchNone() *MatchAllQuery { + return &MatchAllQuery{all: false} +} diff --git a/query_match_all_test.go b/query_match_all_test.go new file mode 100644 index 0000000..dbc28b1 --- /dev/null +++ b/query_match_all_test.go @@ -0,0 +1,33 @@ +package esquery + +import ( + "testing" +) + +func TestMatchAll(t *testing.T) { + runMapTests(t, []mapTest{ + { + "match_all without a boost", + MatchAll(), + map[string]interface{}{ + "match_all": map[string]interface{}{}, + }, + }, + { + "match_all with a boost", + MatchAll().Boost(2.3), + map[string]interface{}{ + "match_all": map[string]interface{}{ + "boost": 2.3, + }, + }, + }, + { + "match_none", + MatchNone(), + map[string]interface{}{ + "match_none": map[string]interface{}{}, + }, + }, + }) +} diff --git a/query_match_test.go b/query_match_test.go new file mode 100644 index 0000000..3a3d290 --- /dev/null +++ b/query_match_test.go @@ -0,0 +1,68 @@ +package esquery + +import ( + "testing" +) + +func TestMatch(t *testing.T) { + runMapTests(t, []mapTest{ + { + "simple match", + Match("title", "sample text"), + map[string]interface{}{ + "match": map[string]interface{}{ + "title": map[string]interface{}{ + "query": "sample text", + }, + }, + }, + }, + { + "match with more params", + Match("issue_number").Query(16).Transpositions(false).MaxExpansions(32).Operator(AND), + map[string]interface{}{ + "match": map[string]interface{}{ + "issue_number": map[string]interface{}{ + "query": 16, + "max_expansions": 32, + "transpositions": false, + "operator": "and", + }, + }, + }, + }, + { + "match_bool_prefix", + MatchBoolPrefix("title", "sample text"), + map[string]interface{}{ + "match_bool_prefix": map[string]interface{}{ + "title": map[string]interface{}{ + "query": "sample text", + }, + }, + }, + }, + { + "match_phrase", + MatchPhrase("title", "sample text"), + map[string]interface{}{ + "match_phrase": map[string]interface{}{ + "title": map[string]interface{}{ + "query": "sample text", + }, + }, + }, + }, + { + "match_phrase_prefix", + MatchPhrasePrefix("title", "sample text"), + map[string]interface{}{ + "match_phrase_prefix": map[string]interface{}{ + "title": map[string]interface{}{ + "query": "sample text", + }, + }, + }, + }, + }) +} diff --git a/query_term_level.go b/query_term_level.go new file mode 100644 index 0000000..82e3adc --- /dev/null +++ b/query_term_level.go @@ -0,0 +1,447 @@ +package esquery + +import ( + "github.com/fatih/structs" +) + +/******************************************************************************* + * Exists Queries + * https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-exists-query.html + ******************************************************************************/ + +type ExistsQuery struct { + Field string `structs:"field"` +} + +func Exists(field string) *ExistsQuery { + return &ExistsQuery{field} +} + +func (q *ExistsQuery) Map() map[string]interface{} { + return map[string]interface{}{ + "exists": structs.Map(q), + } +} + +/******************************************************************************* + * IDs Queries + * https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-ids-query.html + ******************************************************************************/ + +type IDsQuery struct { + IDs struct { + Values []string `structs:"values"` + } `structs:"ids"` +} + +func IDs(vals ...string) *IDsQuery { + q := &IDsQuery{} + q.IDs.Values = vals + return q +} + +func (q *IDsQuery) Map() map[string]interface{} { + return structs.Map(q) +} + +/******************************************************************************* + * Prefix Queries + * https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-prefix-query.html + ******************************************************************************/ + +type PrefixQuery struct { + field string + params prefixQueryParams +} + +type prefixQueryParams struct { + Value string `structs:"value"` + Rewrite string `structs:"rewrite,omitempty"` +} + +func Prefix(field, value string) *PrefixQuery { + return &PrefixQuery{ + field: field, + params: prefixQueryParams{Value: value}, + } +} + +func (q *PrefixQuery) Rewrite(s string) *PrefixQuery { + q.params.Rewrite = s + return q +} + +func (q *PrefixQuery) Map() map[string]interface{} { + return map[string]interface{}{ + "prefix": map[string]interface{}{ + q.field: structs.Map(q.params), + }, + } +} + +/******************************************************************************* + * Range Queries + * https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html + ******************************************************************************/ + +type RangeQuery struct { + field string + params rangeQueryParams +} + +type rangeQueryParams struct { + Gt interface{} `structs:"gt,omitempty"` + Gte interface{} `structs:"gte,omitempty"` + Lt interface{} `structs:"lt,omitempty"` + Lte interface{} `structs:"lte,omitempty"` + Format string `structs:"format,omitempty"` + Relation RangeRelation `structs:"relation,string,omitempty"` + TimeZone string `structs:"time_zone,omitempty"` + Boost float32 `structs:"boost,omitempty"` +} + +func Range(field string) *RangeQuery { + return &RangeQuery{field: field} +} + +func (a *RangeQuery) Gt(val interface{}) *RangeQuery { + a.params.Gt = val + return a +} + +func (a *RangeQuery) Gte(val interface{}) *RangeQuery { + a.params.Gte = val + return a +} + +func (a *RangeQuery) Lt(val interface{}) *RangeQuery { + a.params.Lt = val + return a +} + +func (a *RangeQuery) Lte(val interface{}) *RangeQuery { + a.params.Lte = val + return a +} + +func (a *RangeQuery) Format(f string) *RangeQuery { + a.params.Format = f + return a +} + +func (a *RangeQuery) Relation(r RangeRelation) *RangeQuery { + a.params.Relation = r + return a +} + +func (a *RangeQuery) TimeZone(zone string) *RangeQuery { + a.params.TimeZone = zone + return a +} + +func (a *RangeQuery) Boost(b float32) *RangeQuery { + a.params.Boost = b + return a +} + +func (a *RangeQuery) Map() map[string]interface{} { + return map[string]interface{}{ + "range": map[string]interface{}{ + a.field: structs.Map(a.params), + }, + } +} + +type RangeRelation uint8 + +const ( + INTERSECTS RangeRelation = iota + CONTAINS + WITHIN +) + +func (a RangeRelation) String() string { + switch a { + case INTERSECTS: + return "INTERSECTS" + case CONTAINS: + return "CONTAINS" + case WITHIN: + return "WITHIN" + default: + return "" + } +} + +/******************************************************************************* + * Regexp Queries + * https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-regexp-query.html + ******************************************************************************/ + +type RegexpQuery struct { + field string + wildcard bool + params regexpQueryParams +} + +type regexpQueryParams struct { + Value string `structs:"value"` + Flags string `structs:"flags,omitempty"` + MaxDeterminizedStates uint16 `structs:"max_determinized_states,omitempty"` + Rewrite string `structs:"rewrite,omitempty"` +} + +func Regexp(field, value string) *RegexpQuery { + return &RegexpQuery{ + field: field, + params: regexpQueryParams{ + Value: value, + }, + } +} + +func (q *RegexpQuery) Value(v string) *RegexpQuery { + q.params.Value = v + return q +} + +func (q *RegexpQuery) Flags(f string) *RegexpQuery { + if !q.wildcard { + q.params.Flags = f + } + return q +} + +func (q *RegexpQuery) MaxDeterminizedStates(m uint16) *RegexpQuery { + if !q.wildcard { + q.params.MaxDeterminizedStates = m + } + return q +} + +func (q *RegexpQuery) Rewrite(r string) *RegexpQuery { + q.params.Rewrite = r + return q +} + +func (q *RegexpQuery) Map() map[string]interface{} { + var qType string + if q.wildcard { + qType = "wildcard" + } else { + qType = "regexp" + } + return map[string]interface{}{ + qType: map[string]interface{}{ + q.field: structs.Map(q.params), + }, + } +} + +/******************************************************************************* + * Wildcard Queries + * https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-wildcard-query.html + ******************************************************************************/ + +func Wildcard(field, value string) *RegexpQuery { + return &RegexpQuery{ + field: field, + wildcard: true, + params: regexpQueryParams{ + Value: value, + }, + } +} + +/******************************************************************************* + * Fuzzy Queries + * https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-fuzzy-query.html + ******************************************************************************/ + +type FuzzyQuery struct { + field string + params fuzzyQueryParams +} + +type fuzzyQueryParams struct { + Value string `structs:"value"` + Fuzziness string `structs:"fuzziness,omitempty"` + MaxExpansions uint16 `structs:"max_expansions,omitempty"` + PrefixLength uint16 `structs:"prefix_length,omitempty"` + Transpositions *bool `structs:"transpositions,omitempty"` + Rewrite string `structs:"rewrite,omitempty"` +} + +func Fuzzy(field, value string) *FuzzyQuery { + return &FuzzyQuery{ + field: field, + params: fuzzyQueryParams{ + Value: value, + }, + } +} + +func (q *FuzzyQuery) Value(val string) *FuzzyQuery { + q.params.Value = val + return q +} + +func (q *FuzzyQuery) Fuzziness(fuzz string) *FuzzyQuery { + q.params.Fuzziness = fuzz + return q +} + +func (q *FuzzyQuery) MaxExpansions(m uint16) *FuzzyQuery { + q.params.MaxExpansions = m + return q +} + +func (q *FuzzyQuery) PrefixLength(l uint16) *FuzzyQuery { + q.params.PrefixLength = l + return q +} + +func (q *FuzzyQuery) Transpositions(b bool) *FuzzyQuery { + q.params.Transpositions = &b + return q +} + +func (q *FuzzyQuery) Rewrite(s string) *FuzzyQuery { + q.params.Rewrite = s + return q +} + +func (q *FuzzyQuery) Map() map[string]interface{} { + return map[string]interface{}{ + "fuzzy": map[string]interface{}{ + q.field: structs.Map(q.params), + }, + } +} + +/******************************************************************************* + * Term Queries + * https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-term-query.html + ******************************************************************************/ + +type TermQuery struct { + field string + params termQueryParams +} + +type termQueryParams struct { + Value interface{} `structs:"value"` + Boost float32 `structs:"boost,omitempty"` +} + +func Term(field string, value interface{}) *TermQuery { + return &TermQuery{ + field: field, + params: termQueryParams{ + Value: value, + }, + } +} + +func (q *TermQuery) Value(val interface{}) *TermQuery { + q.params.Value = val + return q +} + +func (q *TermQuery) Boost(b float32) *TermQuery { + q.params.Boost = b + return q +} + +func (q *TermQuery) Map() map[string]interface{} { + return map[string]interface{}{ + "term": map[string]interface{}{ + q.field: structs.Map(q.params), + }, + } +} + +/******************************************************************************* + * Terms Queries + * https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-terms-query.html + ******************************************************************************/ + +type TermsQuery struct { + field string + values []interface{} + boost float32 +} + +func Terms(field string, values ...interface{}) *TermsQuery { + return &TermsQuery{ + field: field, + values: values, + } +} + +func (q *TermsQuery) Values(values ...interface{}) *TermsQuery { + q.values = values + return q +} + +func (q *TermsQuery) Boost(b float32) *TermsQuery { + q.boost = b + return q +} + +func (q TermsQuery) Map() map[string]interface{} { + innerMap := map[string]interface{}{q.field: q.values} + if q.boost > 0 { + innerMap["boost"] = q.boost + } + + return map[string]interface{}{"terms": innerMap} +} + +/******************************************************************************* + * Term Set Queries + * https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-terms-set-query.html + ******************************************************************************/ + +type TermsSetQuery struct { + field string + params termsSetQueryParams +} + +type termsSetQueryParams struct { + Terms []string `structs:"terms"` + MinimumShouldMatchField string `structs:"minimum_should_match_field,omitempty"` + MinimumShouldMatchScript string `structs:"minimum_should_match_script,omitempty"` +} + +func TermsSet(field string, terms ...string) *TermsSetQuery { + return &TermsSetQuery{ + field: field, + params: termsSetQueryParams{ + Terms: terms, + }, + } +} + +func (q *TermsSetQuery) Terms(terms ...string) *TermsSetQuery { + q.params.Terms = terms + return q +} + +func (q *TermsSetQuery) MinimumShouldMatchField(field string) *TermsSetQuery { + q.params.MinimumShouldMatchField = field + return q +} + +func (q *TermsSetQuery) MinimumShouldMatchScript(script string) *TermsSetQuery { + q.params.MinimumShouldMatchScript = script + return q +} + +func (q TermsSetQuery) Map() map[string]interface{} { + return map[string]interface{}{ + "terms_set": map[string]interface{}{ + q.field: structs.Map(q.params), + }, + } +} diff --git a/query_term_level_test.go b/query_term_level_test.go new file mode 100644 index 0000000..8878a68 --- /dev/null +++ b/query_term_level_test.go @@ -0,0 +1,151 @@ +package esquery + +import ( + "testing" +) + +func TestTermLevel(t *testing.T) { + runMapTests(t, []mapTest{ + { + "exists", + Exists("title"), + map[string]interface{}{ + "exists": map[string]interface{}{ + "field": "title", + }, + }, + }, + { + "ids", + IDs("1", "4", "100"), + map[string]interface{}{ + "ids": map[string]interface{}{ + "values": []string{"1", "4", "100"}, + }, + }, + }, + { + "simple prefix", + Prefix("user", "ki"), + map[string]interface{}{ + "prefix": map[string]interface{}{ + "user": map[string]interface{}{ + "value": "ki", + }, + }, + }, + }, + { + "complex prefix", + Prefix("user", "ki").Rewrite("ji"), + map[string]interface{}{ + "prefix": map[string]interface{}{ + "user": map[string]interface{}{ + "value": "ki", + "rewrite": "ji", + }, + }, + }, + }, + { + "int range", + Range("age").Gte(10).Lte(20).Boost(2.0), + map[string]interface{}{ + "range": map[string]interface{}{ + "age": map[string]interface{}{ + "gte": 10, + "lte": 20, + "boost": 2.0, + }, + }, + }, + }, + { + "string range", + Range("timestamp").Gte("now-1d/d").Lt("now/d").Relation(CONTAINS), + map[string]interface{}{ + "range": map[string]interface{}{ + "timestamp": map[string]interface{}{ + "gte": "now-1d/d", + "lt": "now/d", + "relation": "CONTAINS", + }, + }, + }, + }, + { + "regexp", + Regexp("user", "k.*y").Flags("ALL").MaxDeterminizedStates(10000).Rewrite("constant_score"), + map[string]interface{}{ + "regexp": map[string]interface{}{ + "user": map[string]interface{}{ + "value": "k.*y", + "flags": "ALL", + "max_determinized_states": 10000, + "rewrite": "constant_score", + }, + }, + }, + }, + { + "wildcard", + Wildcard("user", "ki*y").Rewrite("constant_score"), + map[string]interface{}{ + "wildcard": map[string]interface{}{ + "user": map[string]interface{}{ + "value": "ki*y", + "rewrite": "constant_score", + }, + }, + }, + }, + { + "fuzzy", + Fuzzy("user", "ki").Fuzziness("AUTO").MaxExpansions(50).Transpositions(true), + map[string]interface{}{ + "fuzzy": map[string]interface{}{ + "user": map[string]interface{}{ + "value": "ki", + "fuzziness": "AUTO", + "max_expansions": 50, + "transpositions": true, + }, + }, + }, + }, + { + "term", + Term("user", "Kimchy").Boost(1.3), + map[string]interface{}{ + "term": map[string]interface{}{ + "user": map[string]interface{}{ + "value": "Kimchy", + "boost": 1.3, + }, + }, + }, + }, + { + "terms", + Terms("user").Values("bla", "pl").Boost(1.3), + map[string]interface{}{ + "terms": map[string]interface{}{ + "user": []string{"bla", "pl"}, + "boost": 1.3, + }, + }, + }, + { + "terms_set", + TermsSet("programming_languages", "go", "rust", "COBOL").MinimumShouldMatchField("required_matches"), + map[string]interface{}{ + "terms_set": map[string]interface{}{ + "programming_languages": map[string]interface{}{ + "terms": []string{"go", "rust", "COBOL"}, + "minimum_should_match_field": "required_matches", + }, + }, + }, + }, + }) +}