diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..7b7cd9a --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,24 @@ +name: Go + +on: + push: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: '1.21.x' + - name: Install dependencies + run: go get . + - name: Build + run: go build -v ./... + - name: Test & prepare coverage + run: go test -coverprofile=coverage.out + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa92975 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.out \ No newline at end of file diff --git a/README.md b/README.md index 9b90104..6084762 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,136 @@ -# mgs -Convert URL query parameters to MongoDB queries +[![MIT License][license-shield]][license-url] + +<p align="center"> + <h3 align="center">Mongo Golang Search</h3> + <p align="center"> + Mongo Golang Search provides a query language to a MongoDB database. + <br /> + <a href="https://github.com/ajclopez/mgs#usage"><strong>Explore the docs</strong></a> + <br /> + <br /> + <a href="https://github.com/ajclopez/mgs/issues">Report Bug</a> + ยท + <a href="https://github.com/ajclopez/mgs/issues">Request Feature</a> + </p> +</p> + +# Mongo Golang Search (mgs) + + +### Content index + +* [What is this?](#what-is-this) +* [Getting Started](#getting-started) + * [Installation](#installation) +* [Usage](#usage) +* [Supported features](#supported-features) + * [Filtering](#filtering) + * [Pagination](#pagination) + * [Sorting](#sorting) + * [Projection](#projection) +* [Available options](#available-options) + * [Customize limit value](#customize-limit-value) + * [Specify casting per param keys](#specify-casting-per-param-keys) +* [Contributing](#contributing) +* [License](#license) + +## What is this? + +Mongo Golang Search provides a simple query language to perform advanced searches for your collections in **MongoDB**. + +You could also use **Mongo Golang Search** to searching, sorting, pagination and combining logical operators. + +## Getting Started + +### Installation + + +## Usage + + +## Supported features + +### Filtering + +| Operator | URI | Example | +| ----------------- | --------------------- | --------------------------------- | +| `$eq` | `key=val` | `type=public` | +| `$ne` | `key!=val` | `status!=SENT` | +| `$gt` | `key>val` | `price>5` | +| `$gte` | `key>=val` | `price>=9` | +| `$lt` | `key<val` | `date<2020-01-01T14:00:00.000Z` | +| `$lte` | `key<=val` | `priority<=-5` | +| `$in` | `key=val1,val2` | `status=QUEUED,DEQUEUED` | +| `$nin` | `key!=val1,val2` | `status!=QUEUED,DEQUEUED` | +| `$exists` | `key` | `email` | +| `$exists` | `!key` | `!email` | +| `$regex` | `key=/value/<opts>` | `email=/@gmail\.com$/` | +| `$regex` | `key!=/value/<opts>` | `phone!=/^58/` | + + +### Pagination + +Useful to limit the number of records returned. + +- Operator keys are `skip` and `limit`. +- Use `limit` operator to limit the number of records returned. +- Use `skip` operator to skip the specified number of records. + +```json +skip=20&limit=10 +``` + +### Sorting + +Useful to sort returned records. + +- Operator key is `sort`. +- It accepts a comma-separated list of fields. +- Use `-` prefixes to sort in descending order. +- Use `+` prefixes to sort in ascedending order. + +```json +sort=id,-date +``` + +### Projection + +Useful to limit fields to return in each records. + +- Operator key is `fields`. +- It accepts a comma-separated list of fields. + +```json +fields=firstname,lastname,phone,email +``` + +**Note:** +* The `_id` field (returned by default). + +## Available options + +You can use advanced options: + +### Customize limit value + + +### Specify casting per param keys + + +## Contributing + +Should you like to provide any feedback, please open up an Issue, I appreciate feedback and comments. Any contributions you make are **greatly appreciated**. + +1. Fork the Project +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add some amazing-feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +This software is released under the MIT license. See `LICENSE` for more information. + + +[license-shield]: https://img.shields.io/badge/License-MIT-yellow.svg +[license-url]: https://github.com/ajclopez/mgs/blob/master/LICENSE diff --git a/convert.go b/convert.go new file mode 100644 index 0000000..82e00e5 --- /dev/null +++ b/convert.go @@ -0,0 +1,57 @@ +package mgs + +import ( + "reflect" +) + +// Convert converts the criteria value to a MongoDB query +func Convert(criteria SearchCriteria, filter map[string]interface{}) { + + value := ParseValue(criteria.Value, criteria.Caster) + + switch criteria.Operation { + case EQUAL: + key := reflect.ValueOf(value).Kind() + if key == reflect.Slice { + filter[criteria.Key] = buildMongoQuery("$in", value) + } else if key == reflect.Struct { + filter[criteria.Key] = buildRegexOperation(value) + } else { + filter[criteria.Key] = value + } + case NOT_EQUAL: + key := reflect.ValueOf(value).Kind() + if key == reflect.Slice { + filter[criteria.Key] = buildMongoQuery("$nin", value) + } else if key == reflect.Struct { + filter[criteria.Key] = buildMongoQuery("$not", buildRegexOperation(value)) + } else { + filter[criteria.Key] = buildMongoQuery("$ne", value) + } + case GREATER_THAN: + filter[criteria.Key] = buildMongoQuery("$gt", value) + case GREATER_THAN_EQUAL: + filter[criteria.Key] = buildMongoQuery("$gte", value) + case LESS_THAN: + filter[criteria.Key] = buildMongoQuery("$lt", value) + case LESS_THAN_EQUAL: + filter[criteria.Key] = buildMongoQuery("$lte", value) + case EXISTS: + filter[criteria.Key] = buildMongoQuery("$exists", !criteria.Prefix) + } +} + +func buildMongoQuery(operator string, value interface{}) map[string]interface{} { + query := make(map[string]interface{}) + query[operator] = value + + return query +} + +func buildRegexOperation(value interface{}) map[string]interface{} { + regex := make(map[string]interface{}) + regex["$regex"] = value.(Regex).Pattern + regex["$options"] = value.(Regex).Option + + return regex +} diff --git a/convert_test.go b/convert_test.go new file mode 100644 index 0000000..be376e6 --- /dev/null +++ b/convert_test.go @@ -0,0 +1,131 @@ +package mgs + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +var converetTests = []struct { + Criteria SearchCriteria + Filter map[string]interface{} + Expected map[string]interface{} +}{ + { + SearchCriteria{ + Prefix: false, + Key: "name", + Operation: EQUAL, + Value: "Jhon", + }, + map[string]interface{}{}, + map[string]interface{}{"name": "Jhon"}, + }, + { + SearchCriteria{ + Prefix: false, + Key: "status", + Operation: EQUAL, + Value: "QUEUED,DEQUEUED", + }, + map[string]interface{}{}, + map[string]interface{}{"status": map[string]interface{}{"$in": []interface{}{"QUEUED", "DEQUEUED"}}}, + }, + { + SearchCriteria{ + Prefix: false, + Key: "email", + Operation: EQUAL, + Value: "/@gmail\\.com$/", + }, + map[string]interface{}{}, + map[string]interface{}{"email": map[string]interface{}{"$regex": "@gmail\\.com$", "$options": ""}}, + }, + { + SearchCriteria{ + Prefix: false, + Key: "status", + Operation: NOT_EQUAL, + Value: "SENT", + }, + map[string]interface{}{}, + map[string]interface{}{"status": map[string]interface{}{"$ne": "SENT"}}, + }, + { + SearchCriteria{ + Prefix: false, + Key: "status", + Operation: NOT_EQUAL, + Value: "QUEUED,DEQUEUED", + }, + map[string]interface{}{}, + map[string]interface{}{"status": map[string]interface{}{"$nin": []interface{}{"QUEUED", "DEQUEUED"}}}, + }, + { + SearchCriteria{ + Prefix: false, + Key: "phone", + Operation: NOT_EQUAL, + Value: "/^58/", + }, + map[string]interface{}{}, + map[string]interface{}{"phone": map[string]interface{}{"$not": map[string]interface{}{"$regex": "^58", "$options": ""}}}, + }, + { + SearchCriteria{ + Prefix: false, + Key: "price", + Operation: GREATER_THAN, + Value: "5", + }, + map[string]interface{}{}, + map[string]interface{}{"price": map[string]interface{}{"$gt": int64(5)}}, + }, + { + SearchCriteria{ + Prefix: false, + Key: "price", + Operation: GREATER_THAN_EQUAL, + Value: "5", + }, + map[string]interface{}{}, + map[string]interface{}{"price": map[string]interface{}{"$gte": int64(5)}}, + }, + { + SearchCriteria{ + Prefix: false, + Key: "price", + Operation: LESS_THAN, + Value: "5", + }, + map[string]interface{}{}, + map[string]interface{}{"price": map[string]interface{}{"$lt": int64(5)}}, + }, + { + SearchCriteria{ + Prefix: false, + Key: "price", + Operation: LESS_THAN_EQUAL, + Value: "5", + }, + map[string]interface{}{}, + map[string]interface{}{"price": map[string]interface{}{"$lte": int64(5)}}, + }, + { + SearchCriteria{ + Prefix: true, + Key: "email", + Operation: EXISTS, + Value: "", + }, + map[string]interface{}{}, + map[string]interface{}{"email": map[string]interface{}{"$exists": false}}, + }, +} + +func TestShouldConvertFromSearchCriteria(t *testing.T) { + for _, test := range converetTests { + Convert(test.Criteria, test.Filter) + assert.Equal(t, test.Expected, test.Filter) + } +} diff --git a/findoptions.go b/findoptions.go new file mode 100644 index 0000000..1b161db --- /dev/null +++ b/findoptions.go @@ -0,0 +1,45 @@ +package mgs + +type CastType int + +// list of allowed cast type. +const ( + NUMBER CastType = iota + 1 + DATE + BOOLEAN + PATTERN + STRING +) + +// FindOptions is a structure that allows to use advanced options. +type FindOptions struct { + // Caster map to set object type on values by key (BOOLEAN, NUMBER, PATTERN, DATE, STRING). + Caster *map[string]CastType + // DefaultLimit default value for limit key. + DefaultLimit *int64 + // MaxLimit maximum value for limit key. + MaxLimit *int64 +} + +// FindOption creates a new FindOptions instance. +func FindOption() *FindOptions { + return &FindOptions{} +} + +// SetCaster sets the value for the object type on values by key. +func (f *FindOptions) SetCaster(m map[string]CastType) *FindOptions { + f.Caster = &m + return f +} + +// SetDefaultLimit sets the value for the default value for limit key. +func (f *FindOptions) SetDefaultLimit(i int64) *FindOptions { + f.DefaultLimit = &i + return f +} + +// SetMaxLimit sets the value for the maximum value for limit key. +func (f *FindOptions) SetMaxLimit(i int64) *FindOptions { + f.MaxLimit = &i + return f +} diff --git a/findoptions_test.go b/findoptions_test.go new file mode 100644 index 0000000..2c14fbe --- /dev/null +++ b/findoptions_test.go @@ -0,0 +1,34 @@ +package mgs + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShouldCreateInstanceFindOpetions(t *testing.T) { + + assert.Equal(t, &FindOptions{}, FindOption()) +} + +func TestShouldCreateInstanceFindOpetionsWithValues(t *testing.T) { + + caster := map[string]CastType{ + "mobile": STRING, + } + + opts := FindOption() + opts.SetCaster(caster) + opts.SetDefaultLimit(100) + opts.SetMaxLimit(100) + + dl := int64(100) + ml := int64(100) + expected := FindOptions{ + Caster: &caster, + DefaultLimit: &dl, + MaxLimit: &ml, + } + + assert.Equal(t, &expected, opts) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5b37c67 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/ajclopez/mgs + +go 1.21.2 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.8.4 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8cf6655 --- /dev/null +++ b/go.sum @@ -0,0 +1,9 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mgs.go b/mgs.go new file mode 100644 index 0000000..070c174 --- /dev/null +++ b/mgs.go @@ -0,0 +1,118 @@ +package mgs + +import ( + "errors" + "net/url" + "strings" +) + +var ( + // ErrUnescapeCharacters is returned when cannot converter an unescape string + ErrUnescapeCharacters = errors.New("unescape characters") + // ErrValueNoMatch is returned when the converter cannot match a string to + // an unsigned integer. + ErrValueNoMatch = errors.New("value does not match") +) + +// MongoGoSearch converts query into a MongoDB query object. +func MongoGoSearch(query string, opts *FindOptions) (Query, error) { + + res := Query{} + + if strings.TrimSpace(query) == "" { + return res, nil + } + + var err error + query, err = url.QueryUnescape(strings.Replace(query, "+", "%2B", -1)) + + if err != nil { + return res, ErrUnescapeCharacters + } + + filter := make(map[string]interface{}) + + for _, criteria := range Parse(query, opts.Caster) { + switch criteria.Key { + case "filter": + // advanced queries + case "skip": + err = parseSkip(&res, criteria.Value) + case "limit": + err = parseLimit(&res, criteria.Value, opts) + case "sort": + parseSort(&res, criteria.Value) + case "fields": + parseFields(&res, criteria.Value) + default: + Convert(criteria, filter) + } + + if err != nil { + return res, err + } + } + + res.Filter = filter + + return res, err +} + +func parseSkip(res *Query, value string) error { + skip, err := parseIntValueToInt(value) + if err != nil { + return err + } + + res.Skip = skip + return nil +} + +func parseLimit(res *Query, value string, opts *FindOptions) error { + limit, err := parseIntValueToInt(value) + + if err != nil { + if opts.DefaultLimit != nil { + res.Limit = *opts.DefaultLimit + return nil + } else { + return err + } + } + + maxLimit := opts.MaxLimit + if maxLimit != nil && limit > *maxLimit { + res.Limit = *maxLimit + } else { + res.Limit = limit + } + + return nil +} + +func parseSort(res *Query, value string) { + sort := make(map[string]int) + values := strings.Split(value, ",") + + for i := range values { + if strings.HasPrefix(values[i], "+") { + sort[values[i][1:]] = 1 + } else if strings.HasPrefix(values[i], "-") { + sort[values[i][1:]] = -1 + } else { + sort[values[i]] = 1 + } + } + + res.Sort = sort +} + +func parseFields(res *Query, value string) { + projection := make(map[string]int) + values := strings.Split(value, ",") + for i := range values { + projection[values[i]] = 1 + } + + res.Projection = projection +} diff --git a/mgs_test.go b/mgs_test.go new file mode 100644 index 0000000..f1e3daa --- /dev/null +++ b/mgs_test.go @@ -0,0 +1,223 @@ +package mgs + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +type result struct { + Query Query + Err error +} + +var tests = []struct { + Input string + Expected result +}{ + { + "skip=10", + result{ + Query{ + Filter: map[string]interface{}{}, + Sort: map[string]int(nil), + Limit: 0, + Skip: 10, + Projection: map[string]int(nil), + }, + nil, + }, + }, + { + "skip=number", + result{ + Query{ + Filter: map[string]interface{}(nil), + Sort: map[string]int(nil), + Limit: 0, + Skip: 0, + Projection: map[string]int(nil), + }, + ErrValueNoMatch, + }, + }, + { + "limit=25", + result{ + Query{ + Filter: map[string]interface{}{}, + Sort: map[string]int(nil), + Limit: 25, + Skip: 0, + Projection: map[string]int(nil), + }, + nil, + }, + }, + { + "limit=number", + result{ + Query{ + Filter: map[string]interface{}(nil), + Sort: map[string]int(nil), + Limit: 0, + Skip: 0, + Projection: map[string]int(nil), + }, + ErrValueNoMatch, + }, + }, + { + "sort=+date,-age,name", + result{ + Query{ + Filter: map[string]interface{}{}, + Sort: map[string]int{ + "date": 1, + "age": -1, + "name": 1, + }, + Limit: 0, + Skip: 0, + Projection: map[string]int(nil), + }, + nil, + }, + }, + { + "fields=firstname,lastname,email", + result{ + Query{ + Filter: map[string]interface{}{}, + Sort: map[string]int(nil), + Limit: 0, + Skip: 0, + Projection: map[string]int{ + "firstname": 1, + "lastname": 1, + "email": 1, + }, + }, + nil, + }, + }, + { + "city=Madrid&age>=18", + result{ + Query{ + Filter: map[string]interface{}{ + "city": "Madrid", + "age": map[string]interface{}{ + "$gte": int64(18), + }, + }, + Sort: map[string]int(nil), + Limit: 0, + Skip: 0, + Projection: map[string]int(nil), + }, + nil, + }, + }, +} + +func TestReturnDefaultQueryForMongoGoSearchWhenQueryIsEmpty(t *testing.T) { + result, err := MongoGoSearch("", &FindOptions{}) + + if err != nil { + t.Errorf("Error parse url to mongo query search") + } + + assert.Equal(t, Query{}, result) +} + +func TestReturnErrorForMongoGoSearchWhenQueryInvalidCharacters(t *testing.T) { + result, err := MongoGoSearch("name=Jho%%n", &FindOptions{}) + + if err == nil { + t.Errorf("Error parse url to mongo query search") + } + + assert.Equal(t, Query{}, result) +} + +func TestReturnQuery(t *testing.T) { + for _, test := range tests { + query, err := MongoGoSearch(test.Input, &FindOptions{}) + + assert.Equal(t, test.Expected.Query, query) + assert.Equal(t, test.Expected.Err, err) + } +} + +func TestReturnQueryUseFindOptionsWithCaster(t *testing.T) { + + caster := map[string]CastType{ + "mobile": STRING, + } + + opts := FindOption() + opts.SetCaster(caster) + + expected := result{ + Query{ + Filter: map[string]interface{}{ + "mobile": "+56900000000", + }, + Sort: map[string]int(nil), + Limit: 0, + Skip: 0, + Projection: map[string]int(nil), + }, + nil, + } + + query, err := MongoGoSearch("mobile=+56900000000", opts) + + assert.Equal(t, expected.Query, query) + assert.Equal(t, expected.Err, err) +} + +func TestReturnQueryUseFindOptionsWithDefaultLimit(t *testing.T) { + + opts := FindOption() + opts.SetDefaultLimit(10) + + expected := result{ + Query{ + Filter: map[string]interface{}{}, + Sort: map[string]int(nil), + Limit: 10, + Skip: 0, + Projection: map[string]int(nil), + }, + nil, + } + + query, err := MongoGoSearch("limit=a", opts) + + assert.Equal(t, expected.Query, query) + assert.Equal(t, expected.Err, err) +} + +func TestReturnQueryUseFindOptionsWithMaxLimit(t *testing.T) { + + opts := FindOption() + opts.SetMaxLimit(500) + + expected := result{ + Query{ + Filter: map[string]interface{}{}, + Sort: map[string]int(nil), + Limit: 500, + Skip: 0, + Projection: map[string]int(nil), + }, + nil, + } + + query, err := MongoGoSearch("limit=2000", opts) + + assert.Equal(t, expected.Query, query) + assert.Equal(t, expected.Err, err) +} diff --git a/operator.go b/operator.go new file mode 100644 index 0000000..9b21818 --- /dev/null +++ b/operator.go @@ -0,0 +1,39 @@ +package mgs + +type SearchOperator int + +// list of allowed operators. +const ( + EQUAL SearchOperator = iota + 1 + NOT_EQUAL + GREATER_THAN + GREATER_THAN_EQUAL + LESS_THAN + LESS_THAN_EQUAL + EXISTS +) + +// String gets operator string. +func (s SearchOperator) String() string { + return [...]string{"EQUAL", "NOT_EQUAL", "GREATER_THAN", "GREATER_THAN_EQUAL", "LESS_THAN", "LESS_THAN_EQUAL", "EXISTS"}[s-1] +} + +// GetOperation allows get operation type to filter query +func (s SearchOperator) GetOperation(input string) SearchOperator { + switch input { + case "=": + return EQUAL + case "!=": + return NOT_EQUAL + case ">": + return GREATER_THAN + case ">=": + return GREATER_THAN_EQUAL + case "<": + return LESS_THAN + case "<=": + return LESS_THAN_EQUAL + default: + return EXISTS + } +} diff --git a/operator_test.go b/operator_test.go new file mode 100644 index 0000000..cfaa67c --- /dev/null +++ b/operator_test.go @@ -0,0 +1,49 @@ +package mgs + +import "testing" + +type operatorTest struct { + Input string + Expected SearchOperator +} + +type operatorStringTest struct { + Input SearchOperator + Expected string +} + +var operatorTests = []operatorTest{ + {"=", EQUAL}, + {"!=", NOT_EQUAL}, + {">", GREATER_THAN}, + {">=", GREATER_THAN_EQUAL}, + {"<", LESS_THAN}, + {"<=", LESS_THAN_EQUAL}, + {"!", EXISTS}, +} + +var operatorStringTests = []operatorStringTest{ + {EQUAL, "EQUAL"}, + {NOT_EQUAL, "NOT_EQUAL"}, + {GREATER_THAN, "GREATER_THAN"}, + {GREATER_THAN_EQUAL, "GREATER_THAN_EQUAL"}, + {LESS_THAN, "LESS_THAN"}, + {LESS_THAN_EQUAL, "LESS_THAN_EQUAL"}, + {EXISTS, "EXISTS"}, +} + +func TestShouldGetOperationFromString(t *testing.T) { + for _, test := range operatorTests { + if result := test.Expected.GetOperation(test.Input); result != test.Expected { + t.Errorf("Result %s not equal to expected %s", result, test.Expected) + } + } +} + +func TestShouldReturnStringFromSearchOperator(t *testing.T) { + for _, test := range operatorStringTests { + if result := test.Input.String(); result != test.Expected { + t.Errorf("Result %s not equal to expected %s", result, test.Expected) + } + } +} diff --git a/parser.go b/parser.go new file mode 100644 index 0000000..6bdc92d --- /dev/null +++ b/parser.go @@ -0,0 +1,130 @@ +package mgs + +import ( + "strconv" + "strings" + "time" +) + +const DATE_FORMAT = "2006-01-02T15:04:05.000Z" + +// Parse parses a given url query. +func Parse(query string, caster *map[string]CastType) []SearchCriteria { + criteria := []SearchCriteria{} + for _, condition := range strings.Split(query, "&") { + criteria = append(criteria, criteriaParser(condition, caster)) + } + return criteria +} + +func criteriaParser(condition string, caster *map[string]CastType) SearchCriteria { + + pattern := GetOperatorPattern() + match := pattern.FindStringSubmatch(condition) + + key := match[pattern.SubexpIndex("Key")] + var value CastType + if caster != nil { + value = (*caster)[key] + } + + return SearchCriteria{ + Prefix: match[pattern.SubexpIndex("Prefix")] != "", + Key: key, + Operation: SearchOperator(1).GetOperation(match[pattern.SubexpIndex("Operator")]), + Value: match[pattern.SubexpIndex("Value")], + Caster: &value, + } +} + +// ParseValue converts a string to a data type +func ParseValue(value string, cast *CastType) interface{} { + + if cast == nil { + return parseValue(value) + } + + switch option := *cast; option { + case BOOLEAN: + if b, err := strconv.ParseBool(value); err == nil { + return b + } + case DATE: + if datetime, err := time.Parse(DATE_FORMAT, value); err == nil { + return datetime + } + case NUMBER: + if integer, err := strconv.ParseInt(value, 10, 64); err == nil { + return integer + } + + if float, err := strconv.ParseFloat(value, 64); err == nil { + return float + } + case PATTERN: + if match := GetRegexPattern().FindStringSubmatch(value); match != nil { + return Regex{ + Pattern: match[GetRegexPattern().SubexpIndex("Pattern")], + Option: match[GetRegexPattern().SubexpIndex("Option")], + } + } + case STRING: + return value + } + + return parseValue(value) +} + +func parseValue(value string) interface{} { + + if strings.EqualFold(value, "true") || strings.EqualFold(value, "false") { + if b, err := strconv.ParseBool(strings.ToLower(value)); err == nil { + return b + } + } + + var err error + + var integer int64 + if integer, err = strconv.ParseInt(value, 10, 64); err == nil { + return integer + } + + var float float64 + if float, err = strconv.ParseFloat(value, 64); err == nil { + return float + } + + var datetime time.Time + if datetime, err = time.Parse(DATE_FORMAT, value); err == nil { + return datetime + } + + list := strings.Split(value, ",") + if len(list) > 1 { + var characters []interface{} + for _, _value := range list { + characters = append(characters, parseValue(_value)) + } + if len(characters) > 0 { + return characters + } + } + + if match := GetRegexPattern().FindStringSubmatch(value); match != nil { + return Regex{ + Pattern: match[GetRegexPattern().SubexpIndex("Pattern")], + Option: match[GetRegexPattern().SubexpIndex("Option")], + } + } + + return value +} + +func parseIntValueToInt(value string) (int64, error) { + result, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0, ErrValueNoMatch + } + return result, nil +} diff --git a/parser_test.go b/parser_test.go new file mode 100644 index 0000000..d291c38 --- /dev/null +++ b/parser_test.go @@ -0,0 +1,148 @@ +package mgs + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var booleanTests = []struct { + Input string + Caster *CastType + Expected bool +}{ + {"true", nil, true}, + {"True", nil, true}, + {"TRUE", nil, true}, + {"TrUe", nil, true}, + {"false", nil, false}, + {"False", nil, false}, + {"FALSE", nil, false}, + {"FaLsE", nil, false}, +} + +func TestShouldReturnCriteriaForParseWithQuery(t *testing.T) { + opts := &FindOptions{} + var value CastType + criteria := Parse("name=John", opts.Caster) + expected := []SearchCriteria{ + { + Prefix: false, + Key: "name", + Operation: EQUAL, + Value: "John", + Caster: &value, + }, + } + + if len(criteria) == 0 { + t.Errorf("Error creating criteria array") + } + + assert.Equal(t, expected, criteria) +} + +func TestShouldBoolTypeForParseValue(t *testing.T) { + for _, test := range booleanTests { + assert.Equal(t, test.Expected, ParseValue(test.Input, nil)) + } +} + +func TestShouldIntegerTypeForParseValue(t *testing.T) { + + assert.Equal(t, int64(10), ParseValue("10", nil)) +} + +func TestShouldFloatTypeForParseValue(t *testing.T) { + + assert.Equal(t, float64(3.1415), ParseValue("3.1415", nil)) +} + +func TestShouldDateTimeTypeForParseValue(t *testing.T) { + expected := time.Date(2023, 10, 9, 20, 00, 00, 999000000, time.UTC) + + assert.Equal(t, expected, ParseValue("2023-10-09T20:00:00.999Z", nil)) +} + +func TestShouldStringListTypeForParseValue(t *testing.T) { + expected := []interface{}{"apple", "banana", "grape"} + + assert.Equal(t, expected, ParseValue("apple,banana,grape", nil)) +} + +func TestShouldIntegerListTypeForParseValue(t *testing.T) { + expected := []interface{}{int64(1), int64(2), int64(3)} + + assert.Equal(t, expected, ParseValue("1,2,3", nil)) +} + +func TestShouldRegexTypeForParseValue(t *testing.T) { + + assert.Equal(t, "John", ParseValue("John", nil)) +} + +func TestShouldStringTypeForParseValue(t *testing.T) { + expected := Regex{ + Pattern: "@gmail\\.com$", + } + + assert.Equal(t, expected, ParseValue("/@gmail\\.com$/", nil)) +} + +func TestShouldReturnUnsignedIntForParseIntValueToUnsignedIntWhenIsValidNumberString(t *testing.T) { + result, err := parseIntValueToInt("1") + + if err != nil { + t.Errorf("Error format string to uint") + } + + assert.Equal(t, int64(1), result) +} + +func TestShouldReturnErrorForParseIntValueToUnsignedIntWhenIsInvalidNumberString(t *testing.T) { + result, err := parseIntValueToInt("a") + + if err == nil { + t.Errorf("Error format string to uint") + } + + assert.Equal(t, int64(0), result) +} + +func TestShouldIntegerTypeForParseValueWithCastBoolean(t *testing.T) { + + cast := BOOLEAN + + assert.Equal(t, true, ParseValue("true", &cast)) +} + +func TestShouldIntegerTypeForParseValueWithCastDate(t *testing.T) { + + cast := DATE + expected := time.Date(2023, 10, 9, 20, 00, 00, 999000000, time.UTC) + + assert.Equal(t, expected, ParseValue("2023-10-09T20:00:00.999Z", &cast)) +} + +func TestShouldIntegerTypeForParseValueWithIntCastNumber(t *testing.T) { + + cast := NUMBER + assert.Equal(t, int64(10), ParseValue("10", &cast)) +} + +func TestShouldIntegerTypeForParseValueWithFloatCastNumber(t *testing.T) { + + cast := NUMBER + assert.Equal(t, float64(10.5), ParseValue("10.5", &cast)) +} + +func TestShouldIntegerTypeForParseValueWithCastRegex(t *testing.T) { + + cast := PATTERN + expected := Regex{ + Pattern: "@gmail\\.com$", + } + + assert.Equal(t, expected, ParseValue("/@gmail\\.com$/", &cast)) +} diff --git a/pattern.go b/pattern.go new file mode 100644 index 0000000..d5ba28d --- /dev/null +++ b/pattern.go @@ -0,0 +1,28 @@ +package mgs + +import ( + "regexp" +) + +const OPERATOR_PATTERN string = "(?P<Prefix>!?)(?P<Key>[^><!=]+)(?P<Operator>[><]=?|!?=|)(?P<Value>.*)" + +// const REGEX_PATTERN string = "^/(?P<Pattern>.*)(?P<Option>[igm]*)/$" +const REGEX_PATTERN string = "^\\/(?P<Pattern>.*)\\/(?P<Option>[igm]*)$" + +var operator *regexp.Regexp = nil +var pattern *regexp.Regexp = nil + +func init() { + operator = regexp.MustCompile(OPERATOR_PATTERN) + pattern = regexp.MustCompile(REGEX_PATTERN) +} + +// GetOperatorPattern gets operator RegExp pattern +func GetOperatorPattern() *regexp.Regexp { + return operator +} + +// GetRegexPattern gets RegExp pattern +func GetRegexPattern() *regexp.Regexp { + return pattern +} diff --git a/pattern_test.go b/pattern_test.go new file mode 100644 index 0000000..8e90444 --- /dev/null +++ b/pattern_test.go @@ -0,0 +1,15 @@ +package mgs + +import "testing" + +func TestShouldGetOperatorPattern(t *testing.T) { + if GetOperatorPattern() == nil { + t.Errorf("Operator regular expression instance failed") + } +} + +func TestShouldGetRegexpPattern(t *testing.T) { + if GetRegexPattern() == nil { + t.Errorf("Regular expression instance failed") + } +} diff --git a/structs.go b/structs.go new file mode 100644 index 0000000..9802ddd --- /dev/null +++ b/structs.go @@ -0,0 +1,37 @@ +package mgs + +// Query is a structure that holds information about DB request. +type Query struct { + // Filter is a document containing query operators. + Filter map[string]interface{} + // Sort is a document specifying the order in which documents should be returned. + Sort map[string]int + // Limit is the maximum number of documents to return. + Limit int64 + // Skip is a number of documents to be skipped. + Skip int64 + // Projection is the limit fields to return in each records. + Projection map[string]int +} + +// SearchCriteria is a structure that holds search criteria specification. +type SearchCriteria struct { + // Prefix is a bool that allows to check that the search criteria contain prefixes. + Prefix bool + // key is a string that allows the key to be identified in the search criteria. + Key string + // Operation is a structure that containg operator type. + Operation SearchOperator + // Value is a string that allows the value to be identified in the search criteria. + Value string + // Caster is a cast type value. + Caster *CastType +} + +// Regex is a structure that holds RegExp specification. +type Regex struct { + // Pattern is a string contains regex pattern should be used. + Pattern string + // Opetion is a string contains regex options should be used. + Option string +}