├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── .golangci.yml ├── CLAUDE.md ├── LICENSE ├── README.md ├── Taskfile.yml ├── cmd └── dumbqlgen │ ├── README.md │ ├── bench_test.go │ ├── examples │ ├── user.go │ └── user_matcher.gen.go │ ├── integration_test.go │ ├── main.go │ ├── templates │ └── router.tmpl │ └── testdata │ ├── benchuser_matcher.gen.go │ ├── testuser_matcher.gen.go │ └── types.go ├── codecov.yml ├── dumbql.go ├── dumbql_example_test.go ├── go.mod ├── go.sum ├── match ├── errors.go ├── match_example_test.go ├── path.go ├── router.go ├── struct.go ├── struct_example_test.go ├── struct_internal_test.go └── struct_test.go ├── query ├── ast.go ├── ast_test.go ├── grammar.peg ├── match.go ├── match_internal_test.go ├── match_test.go ├── parser.gen.go ├── parser_helpers.go ├── parser_helpers_test.go ├── parser_test.go ├── sql.go ├── sql_direct_test.go ├── sql_test.go ├── validation.go └── validation_test.go └── schema ├── rules.go ├── rules_internal_test.go ├── rules_test.go └── schema.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: { } 5 | push: 6 | branches: 7 | - main 8 | 9 | # Default permissions for all jobs 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | setup: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: checkout 18 | uses: actions/checkout@v3 19 | 20 | - name: setup-go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: '1.24' 24 | 25 | - name: go-mod-download 26 | run: go mod download 27 | 28 | - name: cache-go-deps 29 | uses: actions/cache@v4 30 | with: 31 | path: | 32 | ~/.cache/go-build 33 | ~/go/pkg/mod 34 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 35 | restore-keys: | 36 | ${{ runner.os }}-go- 37 | go-modules: 38 | needs: setup 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: checkout 42 | uses: actions/checkout@v3 43 | 44 | - name: setup-go 45 | uses: actions/setup-go@v5 46 | with: 47 | go-version: '1.24' 48 | 49 | - name: check-go-mod-tidy 50 | run: | 51 | # Store the current state before running go mod tidy 52 | git diff --quiet HEAD || { echo "Working directory is not clean. Commit or stash your changes first."; exit 1; } 53 | 54 | # Run go mod tidy 55 | go mod tidy 56 | 57 | # Check if there are any changes after running go mod tidy 58 | if ! git diff --quiet; then 59 | echo "::error::go mod tidy produced changes that are not committed. Please run 'go mod tidy' locally and commit the changes." 60 | git diff --name-only 61 | exit 1 62 | fi 63 | 64 | go-generate: 65 | needs: setup 66 | runs-on: ubuntu-latest 67 | steps: 68 | - name: checkout 69 | uses: actions/checkout@v3 70 | 71 | - name: setup-go 72 | uses: actions/setup-go@v5 73 | with: 74 | go-version: '1.24' 75 | 76 | - name: check-go-generate 77 | run: | 78 | # Store the current state before running go generate 79 | git diff --quiet HEAD || { echo "Working directory is not clean. Commit or stash your changes first."; exit 1; } 80 | 81 | # Run go generate 82 | go generate ./... 83 | 84 | # Check if there are any changes after running go generate 85 | if ! git diff --quiet; then 86 | echo "::error::go generate produced changes that are not committed. Please run 'go generate' locally and commit the changes." 87 | git diff --name-only 88 | exit 1 89 | fi 90 | 91 | tests: 92 | needs: [go-modules, go-generate] 93 | runs-on: ubuntu-latest 94 | permissions: 95 | contents: read 96 | statuses: write # Required for codecov 97 | steps: 98 | - name: checkout 99 | uses: actions/checkout@v3 100 | 101 | - name: restore-cache 102 | uses: actions/cache@v4 103 | with: 104 | path: | 105 | ~/.cache/go-build 106 | ~/go/pkg/mod 107 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 108 | 109 | - name: setup-go 110 | uses: actions/setup-go@v5 111 | with: 112 | go-version: '1.24' 113 | 114 | - name: library-tests 115 | run: | 116 | go test ./... -coverprofile=$GITHUB_WORKSPACE/coverage.out 117 | cat $GITHUB_WORKSPACE/coverage.out | grep -v "query/parser.gen.go" | grep -v "query/ast.go:81" > $GITHUB_WORKSPACE/coverage_filtered.out 118 | go tool cover -func=$GITHUB_WORKSPACE/coverage_filtered.out 119 | 120 | - name: dumbqlgen-tests 121 | run: | 122 | cd cmd/dumbqlgen 123 | go test ./... 124 | 125 | - name: codecov 126 | uses: codecov/codecov-action@v5 127 | with: 128 | token: ${{ secrets.CODECOV_TOKEN }} 129 | files: $GITHUB_WORKSPACE/coverage_filtered.out 130 | 131 | analysis: 132 | needs: [go-modules, go-generate] 133 | runs-on: ubuntu-latest 134 | steps: 135 | - name: checkout 136 | uses: actions/checkout@v3 137 | 138 | - name: restore-cache 139 | uses: actions/cache@v4 140 | with: 141 | path: | 142 | ~/.cache/go-build 143 | ~/go/pkg/mod 144 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 145 | 146 | - name: setup-go 147 | uses: actions/setup-go@v5 148 | with: 149 | go-version: '1.24' 150 | 151 | - name: golangci-lint 152 | uses: golangci/golangci-lint-action@v6 153 | with: 154 | version: v1.64.6 155 | 156 | build: 157 | needs: [tests, analysis] 158 | runs-on: ubuntu-latest 159 | steps: 160 | - name: checkout 161 | uses: actions/checkout@v3 162 | 163 | - name: restore-cache 164 | uses: actions/cache@v4 165 | with: 166 | path: | 167 | ~/.cache/go-build 168 | ~/go/pkg/mod 169 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 170 | 171 | - name: setup-go 172 | uses: actions/setup-go@v5 173 | with: 174 | go-version: '1.24' 175 | 176 | - name: build 177 | env: 178 | CGO_ENABLED: 0 179 | run: | 180 | mkdir -p bin 181 | cd cmd/dumbqlgen 182 | go build -o ../../bin/dumbqlgen . 183 | ../../bin/dumbqlgen --version 184 | 185 | - name: archive-build-artifact 186 | uses: actions/upload-artifact@v4 187 | with: 188 | name: dumbqlgen 189 | path: bin/dumbqlgen 190 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/go 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=go 3 | 4 | ### Go ### 5 | # If you prefer the allow list template instead of the deny list, see community template: 6 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 7 | # 8 | # Binaries for programs and plugins 9 | *.exe 10 | *.exe~ 11 | *.dll 12 | *.so 13 | *.dylib 14 | 15 | # Test binary, built with `go test -c` 16 | *.test 17 | 18 | # Output of the go coverage tool, specifically when used with LiteIDE 19 | *.out 20 | 21 | # Dependency directories (remove the comment below to include it) 22 | # vendor/ 23 | 24 | # Go workspace file 25 | go.work 26 | 27 | # End of https://www.toptal.com/developers/gitignore/api/go 28 | 29 | bin 30 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 2m 3 | 4 | linters: 5 | enable: 6 | - cyclop 7 | - decorder 8 | - exhaustive 9 | - fatcontext 10 | - funlen 11 | - gocognit 12 | - gocritic 13 | - gocyclo 14 | - gofumpt 15 | - gosec 16 | - grouper 17 | - iface 18 | - interfacebloat 19 | - lll 20 | - makezero 21 | - misspell 22 | - mnd 23 | - nestif 24 | - nilnil 25 | - nonamedreturns 26 | - perfsprint 27 | - prealloc 28 | - reassign 29 | - recvcheck 30 | - revive 31 | - testifylint 32 | - testpackage 33 | - tparallel 34 | - unconvert 35 | - unparam 36 | - usestdlibvars 37 | - wastedassign 38 | - whitespace 39 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # DumbQL Project Guide 2 | 3 | ## Build/Test Commands 4 | - `task lint` - Run linting with golangci-lint 5 | - `task test` - Run all tests with coverage 6 | - `go test ./...` - Run all tests 7 | - `go test ./path/to/package` - Run tests in specific package 8 | - `go test -run TestName ./path/to/package` - Run specific test 9 | - `task generate` - Run code generators 10 | - `go run ./...` - Run the project 11 | 12 | ## Code Style Guidelines 13 | - Naming: Use camelCase for variables, PascalCase for exported items 14 | - Errors: Return meaningful error messages, use multierr for combining errors 15 | - Formatting: Use standard Go formatting (`gofmt`) 16 | - Types: Prefer strong typing, use int64/float64 consistently for numeric values 17 | - Testing: Write thorough tests with testify, use table-driven tests 18 | - Documentation: Document public APIs with complete sentences 19 | - Structure: Keep packages focused on specific functionality 20 | - Imports: Group standard library, external, and internal packages 21 | - Complexity: Keep functions small and focused, avoid deep nesting 22 | - In general, follow Go's best practices and idioms and the project's existing style 23 | 24 | ## Project Structure 25 | - /query - Query parsing, validation, and SQL generation 26 | - /match - Struct matching functionality 27 | - /schema - Schema definition and validation rules 28 | 29 | ## Workflow Instructions 30 | - When it's said that we're fixing the issue, request this issue with gh-cli and its comments to get the context 31 | - Write tests before writing code 32 | - After changes and before committing, run `task lint` and `task test`. If any of these fail, fix the issues before proceeding 33 | - After creating PR, wait for CI/CD to pass 34 | - Then check if test coverage decreased. If it did, add or modify tests to keep at least the same coverage 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Ildar Karymov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

DumbQL

3 | 4 | ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/tomakado/dumbql) ![GitHub License](https://img.shields.io/github/license/tomakado/dumbql) ![GitHub Tag](https://img.shields.io/github/v/tag/tomakado/dumbql) [![Go Report Card](https://goreportcard.com/badge/go.tomakado.io/dumbql)](https://goreportcard.com/report/go.tomakado.io/dumbql) [![CI](https://github.com/tomakado/dumbql/actions/workflows/main.yml/badge.svg)](https://github.com/tomakado/dumbql/actions/workflows/main.yml) [![codecov](https://codecov.io/gh/tomakado/dumbql/graph/badge.svg?token=15IWJO0R0K)](https://codecov.io/gh/tomakado/dumbql) [![Go Reference](https://pkg.go.dev/badge/go.tomakado.io/dumbql.svg)](https://pkg.go.dev/go.tomakado.io/dumbql) 5 | 6 | Simple (dumb?) query language and parser for Go. 7 | 8 |
9 | 10 | ## Features 11 | 12 | - Field expressions (`age >= 18`, `field.name:"field value"`, etc.) 13 | - Boolean expressions (`age >= 18 and city = Barcelona`, `occupation = designer or occupation = "ux analyst"`) 14 | - One-of/In expressions (`occupation = [designer, "ux analyst"]`) 15 | - Boolean fields support with shorthand syntax (`is_active`, `verified and premium`) 16 | - Schema validation 17 | - Drop-in usage with [squirrel](https://github.com/Masterminds/squirrel) or SQL drivers directly 18 | - Struct matching with `dumbql` struct tag 19 | - Via reflection (slow but works out of box) 20 | - Via [code generation](./cmd/dumbqlgen/README.md) 21 | 22 | ## Examples 23 | 24 | ### Simple parse 25 | 26 | ```go 27 | package main 28 | 29 | import ( 30 | "fmt" 31 | 32 | "go.tomakado.io/dumbql" 33 | ) 34 | 35 | func main() { 36 | const q = `profile.age >= 18 and profile.city = Barcelona` 37 | ast, err := dumbql.Parse(q) 38 | if err != nil { 39 | panic(err) 40 | } 41 | 42 | fmt.Println(ast) 43 | // Output: (and (>= profile.age 18) (= profile.city "Barcelona")) 44 | } 45 | ``` 46 | 47 | ### Validation against schema 48 | 49 | ```go 50 | package main 51 | 52 | import ( 53 | "fmt" 54 | 55 | "go.tomakado.io/dumbql" 56 | "go.tomakado.io/dumbql/schema" 57 | ) 58 | 59 | func main() { 60 | schm := schema.Schema{ 61 | "status": schema.All( 62 | schema.Is[string](), 63 | schema.EqualsOneOf("pending", "approved", "rejected"), 64 | ), 65 | "period_months": schema.Max(int64(3)), 66 | "title": schema.LenInRange(1, 100), 67 | } 68 | 69 | // The following query is invalid against the schema: 70 | // - period_months == 4, but max allowed value is 3 71 | // - field `name` is not described in the schema 72 | // 73 | // Invalid parts of the query are dropped. 74 | const q = `status:pending and period_months:4 and (title:"hello world" or name:"John Doe")` 75 | expr, err := dumbql.Parse(q) 76 | if err != nil { 77 | panic(err) 78 | } 79 | 80 | validated, err := expr.Validate(schm) 81 | fmt.Println(validated) 82 | fmt.Printf("validation error: %v\n", err) 83 | // Output: 84 | // (and (= status "pending") (= title "hello world")) 85 | // validation error: field "period_months": value must be equal or less than 3, got 4; field "name" not found in schema 86 | } 87 | ``` 88 | 89 | ### Convert to SQL 90 | 91 | ```go 92 | package main 93 | 94 | import ( 95 | "fmt" 96 | 97 | sq "github.com/Masterminds/squirrel" 98 | "go.tomakado.io/dumbql" 99 | ) 100 | 101 | func main() { 102 | const q = `status:pending and period_months < 4 and (title:"hello world" or name:"John Doe")` 103 | expr, err := dumbql.Parse(q) 104 | if err != nil { 105 | panic(err) 106 | } 107 | 108 | sql, args, err := sq.Select("*"). 109 | From("users"). 110 | Where(expr). 111 | ToSql() 112 | if err != nil { 113 | panic(err) 114 | } 115 | 116 | fmt.Println(sql) 117 | fmt.Println(args) 118 | // Output: 119 | // SELECT * FROM users WHERE ((status = ? AND period_months < ?) AND (title = ? OR name = ?)) 120 | // [pending 4 hello world John Doe] 121 | } 122 | ``` 123 | 124 | See [dumbql_example_test.go](dumbql_example_test.go) 125 | 126 | ### Match against structs 127 | 128 | ```go 129 | package main 130 | 131 | import ( 132 | "fmt" 133 | 134 | "go.tomakado.io/dumbql" 135 | "go.tomakado.io/dumbql/match" 136 | "go.tomakado.io/dumbql/query" 137 | ) 138 | 139 | type User struct { 140 | ID int64 `dumbql:"id"` 141 | Name string `dumbql:"name"` 142 | Age int64 `dumbql:"age"` 143 | Score float64 `dumbql:"score"` 144 | Location string `dumbql:"location"` 145 | Role string `dumbql:"role"` 146 | } 147 | 148 | func main() { 149 | users := []User{ 150 | { 151 | ID: 1, 152 | Name: "John Doe", 153 | Age: 30, 154 | Score: 4.5, 155 | Location: "New York", 156 | Role: "admin", 157 | }, 158 | { 159 | ID: 2, 160 | Name: "Jane Smith", 161 | Age: 25, 162 | Score: 3.8, 163 | Location: "Los Angeles", 164 | Role: "user", 165 | }, 166 | { 167 | ID: 3, 168 | Name: "Bob Johnson", 169 | Age: 35, 170 | Score: 4.2, 171 | Location: "Chicago", 172 | Role: "user", 173 | }, 174 | // This one will be dropped: 175 | { 176 | ID: 4, 177 | Name: "Alice Smith", 178 | Age: 25, 179 | Score: 3.8, 180 | Location: "Los Angeles", 181 | Role: "admin", 182 | }, 183 | } 184 | 185 | q := `(age >= 30 and score > 4.0) or (location:"Los Angeles" and role:"user")` 186 | ast, err := dumbql.Parse(q) 187 | if err != nil { 188 | panic(err) 189 | } 190 | matcher := &match.StructMatcher{} 191 | filtered := make([]User, 0, len(users)) 192 | 193 | for _, user := range users { 194 | if expr.Match(&user, matcher) { 195 | filtered = append(filtered, user) 196 | } 197 | } 198 | 199 | fmt.Println(filtered) 200 | // [{1 John Doe 30 4.5 New York admin} {2 Jane Smith 25 3.8 Los Angeles user} {3 Bob Johnson 35 4.2 Chicago user}] 201 | } 202 | ``` 203 | 204 | See [match_example_test.go](match_example_test.go) for more examples. 205 | 206 | ## Query syntax 207 | 208 | This section is a non-formal description of DumbQL syntax. For strict description see [grammar file](query/grammar.peg). 209 | 210 | ### Field expression 211 | 212 | Field name & value pair divided by operator. Field name is any alphanumeric identifier (with underscore), value can be string, int64, float64, or bool. 213 | One-of expression is also supported (see below). 214 | 215 | ``` 216 | 217 | ``` 218 | 219 | for example 220 | 221 | ``` 222 | period_months < 4 223 | is_active:true 224 | ``` 225 | 226 | ### Field expression operators 227 | 228 | | Operator | Meaning | Supported types | 229 | |----------------------|-------------------------------|--------------------------------------| 230 | | `:` or `=` | Equal, one of | `int64`, `float64`, `string`, `bool` | 231 | | `!=` or `!:` | Not equal | `int64`, `float64`, `string`, `bool` | 232 | | `~` | "Like" or "contains" operator | `string` | 233 | | `>`, `>=`, `<`, `<=` | Comparison | `int64`, `float64` | 234 | | `?` or `exists` | Field exists and is not zero | All types | 235 | 236 | 237 | ### Boolean operators 238 | 239 | Multiple field expression can be combined into boolean expressions with `and` (`AND`) or `or` (`OR`) operators: 240 | 241 | ``` 242 | status:pending and period_months < 4 and (title:"hello world" or name:"John Doe") 243 | ``` 244 | 245 | ### Boolean Field Shorthand 246 | 247 | Boolean fields can be expressed in a simpler shorthand syntax: 248 | 249 | ``` 250 | verified # equivalent to verified:true 251 | verified and premium # equivalent to verified:true and premium:true 252 | not verified # equivalent to not (verified:true) 253 | verified or admin # equivalent to verified:true or admin:true 254 | ``` 255 | 256 | ### "One of" expression 257 | 258 | Sometimes instead of multiple `and`/`or` clauses against the same field: 259 | 260 | ``` 261 | occupation = designer or occupation = "ux analyst" 262 | ``` 263 | 264 | it's more convenient to use equivalent “one of” expressions: 265 | 266 | ``` 267 | occupation: [designer, "ux analyst"] 268 | ``` 269 | 270 | ### Field presence operator 271 | 272 | The field presence operator (`?` or `exists`) checks if a field exists and is not its zero value: 273 | 274 | ``` 275 | id? # Checks if ID field exists and is not 0 276 | name? # Checks if Name field exists and is not empty string 277 | description? # Checks if Description field exists and is not empty string 278 | count? # Checks if Count field exists and is not 0 279 | is_active? # Checks if IsActive field exists and is not false 280 | amount? # Checks if Amount field exists and is not 0.0 281 | ``` 282 | 283 | You can also use the keyword form: `name exists` 284 | 285 | It can be combined with other operators: 286 | 287 | ``` 288 | name? and age>20 289 | not email? 290 | ``` 291 | 292 | In SQL generation, the field presence operator is translated to `IS NOT NULL` clause. 293 | 294 | ### Numbers 295 | 296 | If number does not have digits after `.` it's treated as integer and stored as `int64`. And it's `float64` otherwise. 297 | 298 | ### Strings 299 | 300 | String is a sequence of Unicode characters surrounded by double quotes (`"`). In some cases like single word it's possible to write string value without double quotes. 301 | 302 | ### Booleans 303 | 304 | Boolean values are represented by `true` or `false` literals and can be used with the equality operators (`=`, `:`, `!=`, `!:`). 305 | 306 | ``` 307 | is_active:true 308 | verified = true 309 | is_banned != false 310 | ``` 311 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: 3 2 | 3 | vars: 4 | PROJECT_BIN_DIR: "$(pwd)/bin" 5 | 6 | GOLANGCI_LINT_VERSION: "v1.64.6" 7 | GOLANGCI_LINT_BIN: "{{ .PROJECT_BIN_DIR }}/golangci-lint" 8 | 9 | tasks: 10 | # Tools 11 | install-tools: 12 | desc: "Install tools" 13 | cmd: | 14 | GOBIN={{ .PROJECT_BIN_DIR }} go install github.com/golangci/golangci-lint/cmd/golangci-lint@{{ .GOLANGCI_LINT_VERSION }} && \ 15 | {{ .GOLANGCI_LINT_BIN }} --version 16 | 17 | reinstall-tools: 18 | desc: "Reinstall tools (e.g. for updating to new versions)" 19 | cmd: | 20 | rm -rf {{ .PROJECT_BIN_DIR }} && \ 21 | mkdir -p {{ .PROJECT_BIN_DIR }} && \ 22 | task install-tools 23 | 24 | # Lint 25 | lint: 26 | desc: "Run golangci-lint" 27 | cmd: | 28 | {{ .GOLANGCI_LINT_BIN }} run --fix ./... 29 | 30 | # Test 31 | lib-test: 32 | desc: "Run unit tests" 33 | cmds: 34 | - go test ./... -coverprofile=coverage.out 35 | - cat coverage.out | grep -v "query/parser.gen.go" | grep -v "cmd/*" > coverage_filtered.out 36 | - go tool cover -func=coverage_filtered.out 37 | 38 | cmd-test: 39 | desc: "Run tests" 40 | dir: cmd/dumbqlgen 41 | cmds: 42 | - go test ./... 43 | 44 | test: 45 | desc: "Run tests" 46 | dir: cmd/dumbqlgen 47 | deps: 48 | - lib-test 49 | - cmd-test 50 | 51 | cmd-bench: 52 | desc: "Run benchmarks" 53 | dir: cmd/dumbqlgen 54 | cmd: go test -bench=. -benchmem 55 | 56 | # Checks 57 | check: 58 | desc: "Run checks" 59 | deps: 60 | - lint 61 | - test 62 | 63 | # Codegen 64 | generate: 65 | desc: "Run code generators" 66 | cmd: go generate ./... 67 | 68 | # Build 69 | cmd-build: 70 | desc: "Build dumbqlgen" 71 | dir: cmd/dumbqlgen 72 | env: 73 | CGO_ENABLED: 0 74 | cmd: go build -o ../../bin/dumbqlgen . 75 | -------------------------------------------------------------------------------- /cmd/dumbqlgen/README.md: -------------------------------------------------------------------------------- 1 | # DumbQL Code Generator 2 | 3 | `dumbqlgen` is a code generation tool for DumbQL that generates type-specific matchers for structs. 4 | This helps avoid reflection and provides better performance for matching queries. 5 | 6 | ## Installation 7 | 8 | ```bash 9 | go install go.tomakado.io/dumbql/cmd/dumbqlgen@latest 10 | ``` 11 | 12 | ## Usage 13 | 14 | You can use the code generator in two ways: 15 | 16 | ### 1. Directly with go generate 17 | 18 | Add a go:generate comment in your code: 19 | 20 | ```go 21 | //go:generate go run go.tomakado.io/dumbql/cmd/dumbqlgen -type User -package . 22 | ``` 23 | 24 | Then run: 25 | 26 | ```bash 27 | go generate ./... 28 | ``` 29 | 30 | ### 2. Manually at the command line 31 | 32 | ```bash 33 | dumbqlgen -type User -package ./path/to/package -output user_matcher.gen.go 34 | ``` 35 | 36 | ## Flags 37 | 38 | - `-type`: (Required) The name of the struct to generate a matcher for 39 | - `-package`: (Optional) The path to the package containing the struct (defaults to ".") 40 | - `-output`: (Optional) The output file path (defaults to "lowercase_type_matcher.gen.go") 41 | 42 | ## Example 43 | 44 | Given a struct: 45 | 46 | ```go 47 | type User struct { 48 | ID int64 `dumbql:"id"` 49 | Name string `dumbql:"name"` 50 | Email string `dumbql:"email"` 51 | CreatedAt time.Time 52 | Private bool `dumbql:"-"` // Skip this field 53 | } 54 | ``` 55 | 56 | Running: 57 | 58 | ```bash 59 | dumbqlgen -type User -package . 60 | ``` 61 | 62 | Will generate: 63 | 64 | ```go 65 | // Code generated by dumbqlgen; DO NOT EDIT. 66 | package mypackage 67 | 68 | import ( 69 | "strings" 70 | 71 | "go.tomakado.io/dumbql/match" 72 | ) 73 | 74 | // UserRouter is a generated Router implementation for User. 75 | // It implements the match.Router interface. 76 | type UserRouter struct{} 77 | 78 | // NewUserMatcher creates a new StructMatcher with a generated router for User. 79 | func NewUserMatcher() *match.StructMatcher { 80 | return match.NewStructMatcher(&UserRouter{}) 81 | } 82 | 83 | // Route method and other implementation details... 84 | ``` 85 | 86 | ## Benefits 87 | 88 | - No reflection at runtime for better performance 89 | - Type-safe field access 90 | - Works with the existing DumbQL query engine 91 | - Supports struct tags and nested fields -------------------------------------------------------------------------------- /cmd/dumbqlgen/bench_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "go.tomakado.io/dumbql" 8 | "go.tomakado.io/dumbql/cmd/dumbqlgen/testdata" 9 | "go.tomakado.io/dumbql/match" 10 | ) 11 | 12 | func BenchmarkReflectionRouter(b *testing.B) { 13 | user := &testdata.BenchUser{ 14 | ID: 123, 15 | Name: "John Doe", 16 | Email: "john@example.com", 17 | Age: 30, 18 | CreatedAt: time.Now(), 19 | Active: true, 20 | } 21 | 22 | expr, err := dumbql.Parse(`name:"John Doe" AND email:"john@example.com" AND age > 25 AND active:true`) 23 | if err != nil { 24 | b.Fatalf("Failed to parse query: %v", err) 25 | } 26 | 27 | matcher := &match.StructMatcher{} // This uses the reflection router by default 28 | 29 | for b.Loop() { 30 | _ = expr.Match(user, matcher) 31 | } 32 | } 33 | 34 | func BenchmarkGeneratedRouter(b *testing.B) { 35 | user := &testdata.BenchUser{ 36 | ID: 123, 37 | Name: "John Doe", 38 | Email: "john@example.com", 39 | Age: 30, 40 | CreatedAt: time.Now(), 41 | Active: true, 42 | } 43 | 44 | expr, err := dumbql.Parse(`name:"John Doe" AND email:"john@example.com" AND age > 25 AND active:true`) 45 | if err != nil { 46 | b.Fatalf("Failed to parse query: %v", err) 47 | } 48 | 49 | matcher := testdata.NewBenchUserMatcher() 50 | 51 | for b.Loop() { 52 | _ = expr.Match(user, matcher) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /cmd/dumbqlgen/examples/user.go: -------------------------------------------------------------------------------- 1 | package examples 2 | 3 | import ( 4 | "time" 5 | 6 | "go.tomakado.io/dumbql" 7 | ) 8 | 9 | //go:generate go run go.tomakado.io/dumbql/cmd/dumbqlgen -type User -package . -output user_matcher.gen.go 10 | 11 | // User is a sample struct to demonstrate the code generator 12 | type User struct { 13 | ID int64 `json:"id" dumbql:"id"` 14 | Name string `json:"name" dumbql:"name"` 15 | Email string `dumbql:"email" json:"email"` 16 | CreatedAt time.Time 17 | Address Address `json:"address" dumbql:"address"` 18 | Private bool `json:"private" dumbql:"-"` // Skip this field 19 | } 20 | 21 | // Address is a nested struct 22 | type Address struct { 23 | Street string `dumbql:"street"` 24 | City string `dumbql:"city"` 25 | State string `dumbql:"state"` 26 | Zip string `dumbql:"zip"` 27 | } 28 | 29 | // Below is an example of how to use the generated matcher 30 | func Example() { 31 | // Create a user 32 | user := User{ 33 | ID: 123, 34 | Name: "John Doe", 35 | Email: "john@example.com", 36 | CreatedAt: time.Now(), 37 | Address: Address{ 38 | Street: "123 Main St", 39 | City: "Anytown", 40 | State: "CA", 41 | Zip: "12345", 42 | }, 43 | } 44 | 45 | // Create a query 46 | q, _ := dumbql.Parse(`name = "John Doe" AND email = "john@example.com"`) 47 | 48 | // Use the generated matcher (this would be generated after running go generate) 49 | matcher := NewUserMatcher() 50 | 51 | // Match the query against the user 52 | result := q.Match(&user, matcher) 53 | _ = result // result will be true in this case 54 | } 55 | -------------------------------------------------------------------------------- /cmd/dumbqlgen/examples/user_matcher.gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by dumbqlgen (devel); DO NOT EDIT. 2 | package examples 3 | 4 | import ( 5 | "go.tomakado.io/dumbql/match" 6 | ) 7 | 8 | // UserRouter is a generated Router implementation for User. 9 | // It implements the match.Router interface. 10 | type UserRouter struct{} 11 | 12 | // NewUserMatcher creates a new StructMatcher with a generated router for User. 13 | func NewUserMatcher() *match.StructMatcher { 14 | return match.NewStructMatcher(&UserRouter{}) 15 | } 16 | 17 | // Route resolves a field path in the target User and returns the value. 18 | // It supports nested field access using dot notation (e.g., "address.city"). 19 | func (r *UserRouter) Route(target any, field string) (any, error) { 20 | var ( 21 | cursor = target 22 | err error 23 | ) 24 | 25 | for field := range match.Path(field) { 26 | if field == "" { 27 | return nil, match.ErrFieldNotFound 28 | } 29 | 30 | cursor, err = r.resolveField(target, field) 31 | if err != nil { 32 | return nil, err 33 | } 34 | } 35 | 36 | return cursor, nil 37 | } 38 | 39 | // resolveField handles resolving a direct field (no dots in the name) 40 | func (r *UserRouter) resolveField(target any, field string) (any, error) { 41 | obj, err := r.normalizeUser(target) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | switch field { 47 | case "id": 48 | return obj.ID, nil 49 | case "name": 50 | return obj.Name, nil 51 | case "email": 52 | return obj.Email, nil 53 | case "CreatedAt": 54 | return obj.CreatedAt, nil 55 | case "address": 56 | return obj.Address, nil 57 | default: 58 | return nil, match.ErrFieldNotFound 59 | } 60 | } 61 | 62 | func (r *UserRouter) normalizeUser(target any) (*User, error) { 63 | // Normalize the target to a pointer to User 64 | if obj, ok := target.(*User); ok { 65 | if obj == nil { 66 | return nil, match.ErrNotAStruct 67 | } 68 | return obj, nil 69 | } 70 | if obj, ok := target.(User); ok { 71 | return &obj, nil 72 | } 73 | return nil, match.ErrNotAStruct 74 | } 75 | -------------------------------------------------------------------------------- /cmd/dumbqlgen/integration_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/require" 10 | "go.tomakado.io/dumbql" 11 | "go.tomakado.io/dumbql/cmd/dumbqlgen/testdata" 12 | ) 13 | 14 | func TestMain(m *testing.M) { 15 | cmd := exec.Command("go", "generate", "./testdata") 16 | cmd.Stdout = os.Stdout 17 | cmd.Stderr = os.Stderr 18 | 19 | os.Exit(m.Run()) 20 | } 21 | 22 | func TestGeneratedMatcher(t *testing.T) { 23 | user := getTestUser() 24 | 25 | tests := []struct { 26 | name string 27 | query string 28 | want bool 29 | }{ 30 | { 31 | name: "Simple field match", 32 | query: `name = "John Doe"`, 33 | want: true, 34 | }, 35 | { 36 | name: "Field not equal", 37 | query: `name != "Jane Doe"`, 38 | want: true, 39 | }, 40 | { 41 | name: "AND condition", 42 | query: `id = 123 AND email = "john@example.com"`, 43 | want: true, 44 | }, 45 | { 46 | name: "OR condition with one true", 47 | query: `name = "John Doe" OR email = "wrong@example.com"`, 48 | want: true, 49 | }, 50 | { 51 | name: "Nested condition", 52 | query: `(name = "John Doe" AND id = 123) OR email = "wrong@example.com"`, 53 | want: true, 54 | }, 55 | { 56 | name: "NOT condition", 57 | query: `NOT name = "Jane Doe"`, 58 | want: true, 59 | }, 60 | { 61 | name: "Negative match", 62 | query: `name = "Jane Doe" AND email = "john@example.com"`, 63 | want: false, 64 | }, 65 | } 66 | 67 | matcher := testdata.NewTestUserMatcher() 68 | 69 | for _, tc := range tests { 70 | t.Run(tc.name, func(t *testing.T) { 71 | q, err := dumbql.Parse(tc.query) 72 | require.NoError(t, err) 73 | 74 | got := q.Match(&user, matcher) 75 | require.Equal(t, tc.want, got) 76 | }) 77 | } 78 | } 79 | 80 | func getTestUser() testdata.TestUser { 81 | return testdata.TestUser{ 82 | ID: 123, 83 | Name: "John Doe", 84 | Email: "john@example.com", 85 | CreatedAt: time.Now(), 86 | Address: testdata.Address{ 87 | Street: "123 Main St", 88 | City: "Anytown", 89 | State: "CA", 90 | Zip: "12345", 91 | }, 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /cmd/dumbqlgen/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "embed" 6 | "errors" 7 | "flag" 8 | "fmt" 9 | "go/ast" 10 | "go/types" 11 | "os" 12 | "runtime/debug" 13 | "strconv" 14 | "strings" 15 | "text/template" 16 | 17 | "golang.org/x/tools/go/packages" 18 | ) 19 | 20 | //go:embed templates/*.tmpl 21 | var templateFiles embed.FS 22 | 23 | type Config struct { 24 | StructType string 25 | PackagePath string 26 | Output string 27 | PrintVersion bool 28 | PrintHelp bool 29 | } 30 | 31 | type FieldInfo struct { 32 | Name string 33 | TagName string 34 | Type string 35 | Skip bool 36 | } 37 | 38 | type StructInfo struct { 39 | Name string 40 | Package string 41 | Fields []FieldInfo 42 | NestedInfo []StructInfo 43 | } 44 | 45 | func main() { 46 | flag.Usage = func() { 47 | fmt.Fprintln(os.Stderr, "Usage: dumbql [flags]") 48 | fmt.Fprintln(os.Stderr, "More documentation at https://pkg.go.dev/go.tomakado.io/dumbql/cmd/dumbqlgen") 49 | fmt.Fprintln(os.Stderr) 50 | fmt.Fprintln(os.Stderr, "Flags:") 51 | flag.PrintDefaults() 52 | os.Exit(2) //nolint:mnd 53 | } 54 | 55 | var config Config 56 | flag.StringVar(&config.StructType, "type", "", "Struct type to generate router for") 57 | flag.StringVar(&config.PackagePath, "package", ".", "Package path containing the struct") 58 | flag.StringVar(&config.Output, "output", "", "Output file path") 59 | flag.BoolVar(&config.PrintVersion, "version", false, "Print version") 60 | flag.BoolVar(&config.PrintHelp, "help", false, "Print help") 61 | flag.Parse() 62 | 63 | if config.PrintHelp { 64 | flag.Usage() 65 | } 66 | 67 | if config.PrintVersion { 68 | printVersion() 69 | } 70 | 71 | if config.StructType == "" { 72 | handleError(errors.New("missing required flag: -type")) 73 | } 74 | 75 | // If output is not specified, use the current directory with structtype_matcher.gen.go 76 | if config.Output == "" { 77 | config.Output = strings.ToLower(config.StructType) + "_matcher.gen.go" 78 | } 79 | 80 | code, err := generate(config) 81 | if err != nil { 82 | handleError(fmt.Errorf("generate router: %w", err)) 83 | } 84 | 85 | err = os.WriteFile(config.Output, []byte(code), 0o600) //nolint:mnd 86 | if err != nil { 87 | handleError(fmt.Errorf("write output file: %w", err)) 88 | } 89 | } 90 | 91 | func printVersion() { 92 | info, ok := debug.ReadBuildInfo() 93 | if !ok { 94 | fmt.Println("dumbql unknown version") 95 | return 96 | } 97 | 98 | fmt.Printf("dumbql %s\n", info.Main.Version) 99 | fmt.Printf("commit: %s at %s\n", getCommitHash(info), getCommitTime(info)) 100 | fmt.Printf("go version: %s\n", info.GoVersion) 101 | os.Exit(0) 102 | } 103 | 104 | func getCommitHash(info *debug.BuildInfo) string { 105 | for _, setting := range info.Settings { 106 | if setting.Key == "vcs.revision" { 107 | return setting.Value 108 | } 109 | } 110 | 111 | return "(commit unknown)" 112 | } 113 | 114 | func getCommitTime(info *debug.BuildInfo) string { 115 | for _, setting := range info.Settings { 116 | if setting.Key == "vcs.time" { 117 | return setting.Value 118 | } 119 | } 120 | 121 | return "(build time unknown)" 122 | } 123 | 124 | func handleError(err error) { 125 | if err != nil { 126 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 127 | fmt.Fprintln(os.Stderr, "Run dumbqlgen -help for more information") 128 | os.Exit(1) 129 | } 130 | } 131 | 132 | func generate(config Config) (string, error) { 133 | pkgs, err := loadPackages(config.PackagePath) 134 | if err != nil { 135 | return "", fmt.Errorf("failed to load package: %w", err) 136 | } 137 | 138 | if len(pkgs) == 0 { 139 | return "", fmt.Errorf("no packages found at %s", config.PackagePath) 140 | } 141 | 142 | pkg := pkgs[0] 143 | packageName := pkg.Name 144 | 145 | structInfo, err := findStruct(pkg, config.StructType) 146 | if err != nil { 147 | return "", err 148 | } 149 | 150 | tmpl, err := template.ParseFS(templateFiles, "templates/*.tmpl") 151 | if err != nil { 152 | return "", fmt.Errorf("failed to parse template: %w", err) 153 | } 154 | 155 | buildInfo, ok := debug.ReadBuildInfo() 156 | if !ok { 157 | return "", errors.New("failed to read build info") 158 | } 159 | 160 | var buf bytes.Buffer 161 | err = tmpl.ExecuteTemplate(&buf, "router.tmpl", map[string]any{ 162 | "Package": packageName, 163 | "StructInfo": structInfo, 164 | "Version": buildInfo.Main.Version, 165 | }) 166 | if err != nil { 167 | return "", fmt.Errorf("failed to execute template: %w", err) 168 | } 169 | 170 | return buf.String(), nil 171 | } 172 | 173 | func loadPackages(path string) ([]*packages.Package, error) { 174 | cfg := &packages.Config{ 175 | Mode: packages.NeedName | packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo, 176 | } 177 | 178 | pkgs, err := packages.Load(cfg, path) 179 | if err != nil { 180 | return nil, err 181 | } 182 | 183 | return pkgs, nil 184 | } 185 | 186 | func findStruct(pkg *packages.Package, structName string) (StructInfo, error) { 187 | var structInfo StructInfo 188 | structInfo.Name = structName 189 | structInfo.Package = pkg.Name 190 | 191 | // Find the struct declaration 192 | var structType *types.Struct 193 | obj := pkg.Types.Scope().Lookup(structName) 194 | if obj == nil { 195 | return structInfo, fmt.Errorf("struct %s not found in package %s", structName, pkg.Name) 196 | } 197 | 198 | // Check if it's a named type and it's a struct 199 | named, ok := obj.Type().(*types.Named) 200 | if !ok { 201 | return structInfo, fmt.Errorf("%s is not a named type", structName) 202 | } 203 | 204 | st, ok := named.Underlying().(*types.Struct) 205 | if !ok { 206 | return structInfo, fmt.Errorf("%s is not a struct", structName) 207 | } 208 | structType = st 209 | 210 | // Extract field information 211 | for i := range structType.NumFields() { 212 | field := structType.Field(i) 213 | if !field.Exported() { 214 | continue 215 | } 216 | 217 | fieldName := field.Name() 218 | fieldType := field.Type().String() 219 | 220 | // Get the AST node for this field to extract struct tags 221 | var fieldInfo FieldInfo 222 | fieldInfo.Name = fieldName 223 | fieldInfo.TagName = fieldName // Default to field name 224 | fieldInfo.Type = fieldType 225 | 226 | findAndEnrichNode(pkg.Syntax, structName, fieldName, &fieldInfo) 227 | structInfo.Fields = append(structInfo.Fields, fieldInfo) 228 | } 229 | 230 | return structInfo, nil 231 | } 232 | 233 | func findAndEnrichNode(files []*ast.File, structName, fieldName string, fieldInfo *FieldInfo) { 234 | for _, file := range files { 235 | ast.Inspect(file, func(n ast.Node) bool { 236 | typeSpec, ok := n.(*ast.TypeSpec) 237 | if !ok || typeSpec.Name.Name != structName { 238 | return true 239 | } 240 | 241 | structType, ok := typeSpec.Type.(*ast.StructType) 242 | if !ok { 243 | return false 244 | } 245 | 246 | if err := enrichFieldInfo(structType, fieldName, fieldInfo); err != nil { 247 | panic(err) 248 | } 249 | 250 | return false 251 | }) 252 | } 253 | } 254 | 255 | func enrichFieldInfo(structType *ast.StructType, fieldName string, fieldInfo *FieldInfo) error { 256 | for _, field := range structType.Fields.List { 257 | for _, ident := range field.Names { 258 | if ident.Name != fieldName { 259 | continue 260 | } 261 | 262 | if field.Tag == nil { 263 | continue 264 | } 265 | 266 | tag := strings.Trim(field.Tag.Value, "`") 267 | dumbqlTag, err := extractTag(tag, "dumbql") 268 | if err != nil { 269 | return err 270 | } 271 | 272 | if dumbqlTag == "-" { 273 | fieldInfo.Skip = true 274 | break 275 | } 276 | 277 | fieldInfo.TagName = dumbqlTag 278 | 279 | break 280 | } 281 | } 282 | 283 | return nil 284 | } 285 | 286 | func extractTag(tag, key string) (string, error) { 287 | if tag == "" { 288 | return "", nil 289 | } 290 | 291 | for part := range strings.SplitSeq(tag, " ") { 292 | if tagKey, tagValue, found := strings.Cut(part, ":"); found && tagKey == key { 293 | return strconv.Unquote(tagValue) 294 | } 295 | } 296 | 297 | return "", nil 298 | } 299 | -------------------------------------------------------------------------------- /cmd/dumbqlgen/templates/router.tmpl: -------------------------------------------------------------------------------- 1 | // Code generated by dumbqlgen {{.Version}}; DO NOT EDIT. 2 | package {{.Package}} 3 | 4 | import ( 5 | "go.tomakado.io/dumbql/match" 6 | ) 7 | 8 | // {{.StructInfo.Name}}Router is a generated Router implementation for {{.StructInfo.Name}}. 9 | // It implements the match.Router interface. 10 | type {{.StructInfo.Name}}Router struct{} 11 | 12 | // New{{.StructInfo.Name}}Matcher creates a new StructMatcher with a generated router for {{.StructInfo.Name}}. 13 | func New{{.StructInfo.Name}}Matcher() *match.StructMatcher { 14 | return match.NewStructMatcher(&{{.StructInfo.Name}}Router{}) 15 | } 16 | 17 | // Route resolves a field path in the target {{.StructInfo.Name}} and returns the value. 18 | // It supports nested field access using dot notation (e.g., "address.city"). 19 | func (r *{{.StructInfo.Name}}Router) Route(target any, field string) (any, error) { 20 | var ( 21 | cursor = target 22 | err error 23 | ) 24 | 25 | for field := range match.Path(field) { 26 | if field == "" { 27 | return nil, match.ErrFieldNotFound 28 | } 29 | 30 | cursor, err = r.resolveField(target, field) 31 | if err != nil { 32 | return nil, err 33 | } 34 | } 35 | 36 | return cursor, nil 37 | } 38 | 39 | // resolveField handles resolving a direct field (no dots in the name) 40 | func (r *{{.StructInfo.Name}}Router) resolveField(target any, field string) (any, error) { 41 | obj, err := r.normalize{{.StructInfo.Name}}(target) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | switch field { 47 | {{- range .StructInfo.Fields}} 48 | {{- if not .Skip}} 49 | case "{{.TagName}}": 50 | return obj.{{.Name}}, nil 51 | {{- end}} 52 | {{- end}} 53 | default: 54 | return nil, match.ErrFieldNotFound 55 | } 56 | } 57 | 58 | func (r *{{.StructInfo.Name}}Router) normalize{{.StructInfo.Name}}(target any) (*{{.StructInfo.Name}}, error) { 59 | // Normalize the target to a pointer to {{.StructInfo.Name}} 60 | if obj, ok := target.(*{{.StructInfo.Name}}); ok { 61 | if obj == nil { 62 | return nil, match.ErrNotAStruct 63 | } 64 | return obj, nil 65 | } 66 | if obj, ok := target.({{.StructInfo.Name}}); ok { 67 | return &obj, nil 68 | } 69 | return nil, match.ErrNotAStruct 70 | } 71 | -------------------------------------------------------------------------------- /cmd/dumbqlgen/testdata/benchuser_matcher.gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by dumbqlgen (devel); DO NOT EDIT. 2 | package testdata 3 | 4 | import ( 5 | "go.tomakado.io/dumbql/match" 6 | ) 7 | 8 | // BenchUserRouter is a generated Router implementation for BenchUser. 9 | // It implements the match.Router interface. 10 | type BenchUserRouter struct{} 11 | 12 | // NewBenchUserMatcher creates a new StructMatcher with a generated router for BenchUser. 13 | func NewBenchUserMatcher() *match.StructMatcher { 14 | return match.NewStructMatcher(&BenchUserRouter{}) 15 | } 16 | 17 | // Route resolves a field path in the target BenchUser and returns the value. 18 | // It supports nested field access using dot notation (e.g., "address.city"). 19 | func (r *BenchUserRouter) Route(target any, field string) (any, error) { 20 | var ( 21 | cursor = target 22 | err error 23 | ) 24 | 25 | for field := range match.Path(field) { 26 | if field == "" { 27 | return nil, match.ErrFieldNotFound 28 | } 29 | 30 | cursor, err = r.resolveField(target, field) 31 | if err != nil { 32 | return nil, err 33 | } 34 | } 35 | 36 | return cursor, nil 37 | } 38 | 39 | // resolveField handles resolving a direct field (no dots in the name) 40 | func (r *BenchUserRouter) resolveField(target any, field string) (any, error) { 41 | obj, err := r.normalizeBenchUser(target) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | switch field { 47 | case "id": 48 | return obj.ID, nil 49 | case "name": 50 | return obj.Name, nil 51 | case "email": 52 | return obj.Email, nil 53 | case "age": 54 | return obj.Age, nil 55 | case "CreatedAt": 56 | return obj.CreatedAt, nil 57 | case "active": 58 | return obj.Active, nil 59 | default: 60 | return nil, match.ErrFieldNotFound 61 | } 62 | } 63 | 64 | func (r *BenchUserRouter) normalizeBenchUser(target any) (*BenchUser, error) { 65 | // Normalize the target to a pointer to BenchUser 66 | if obj, ok := target.(*BenchUser); ok { 67 | if obj == nil { 68 | return nil, match.ErrNotAStruct 69 | } 70 | return obj, nil 71 | } 72 | if obj, ok := target.(BenchUser); ok { 73 | return &obj, nil 74 | } 75 | return nil, match.ErrNotAStruct 76 | } 77 | -------------------------------------------------------------------------------- /cmd/dumbqlgen/testdata/testuser_matcher.gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by dumbqlgen (devel); DO NOT EDIT. 2 | package testdata 3 | 4 | import ( 5 | "go.tomakado.io/dumbql/match" 6 | ) 7 | 8 | // TestUserRouter is a generated Router implementation for TestUser. 9 | // It implements the match.Router interface. 10 | type TestUserRouter struct{} 11 | 12 | // NewTestUserMatcher creates a new StructMatcher with a generated router for TestUser. 13 | func NewTestUserMatcher() *match.StructMatcher { 14 | return match.NewStructMatcher(&TestUserRouter{}) 15 | } 16 | 17 | // Route resolves a field path in the target TestUser and returns the value. 18 | // It supports nested field access using dot notation (e.g., "address.city"). 19 | func (r *TestUserRouter) Route(target any, field string) (any, error) { 20 | var ( 21 | cursor = target 22 | err error 23 | ) 24 | 25 | for field := range match.Path(field) { 26 | if field == "" { 27 | return nil, match.ErrFieldNotFound 28 | } 29 | 30 | cursor, err = r.resolveField(target, field) 31 | if err != nil { 32 | return nil, err 33 | } 34 | } 35 | 36 | return cursor, nil 37 | } 38 | 39 | // resolveField handles resolving a direct field (no dots in the name) 40 | func (r *TestUserRouter) resolveField(target any, field string) (any, error) { 41 | obj, err := r.normalizeTestUser(target) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | switch field { 47 | case "id": 48 | return obj.ID, nil 49 | case "name": 50 | return obj.Name, nil 51 | case "email": 52 | return obj.Email, nil 53 | case "CreatedAt": 54 | return obj.CreatedAt, nil 55 | case "address": 56 | return obj.Address, nil 57 | default: 58 | return nil, match.ErrFieldNotFound 59 | } 60 | } 61 | 62 | func (r *TestUserRouter) normalizeTestUser(target any) (*TestUser, error) { 63 | // Normalize the target to a pointer to TestUser 64 | if obj, ok := target.(*TestUser); ok { 65 | if obj == nil { 66 | return nil, match.ErrNotAStruct 67 | } 68 | return obj, nil 69 | } 70 | if obj, ok := target.(TestUser); ok { 71 | return &obj, nil 72 | } 73 | return nil, match.ErrNotAStruct 74 | } 75 | -------------------------------------------------------------------------------- /cmd/dumbqlgen/testdata/types.go: -------------------------------------------------------------------------------- 1 | package testdata 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | //go:generate go run go.tomakado.io/dumbql/cmd/dumbqlgen -type TestUser -package . 8 | 9 | type TestUser struct { 10 | ID int64 `dumbql:"id"` 11 | Name string `dumbql:"name"` 12 | Email string `dumbql:"email"` 13 | CreatedAt time.Time 14 | Address Address `dumbql:"address"` 15 | Private bool `dumbql:"-"` 16 | } 17 | 18 | type Address struct { 19 | Street string `dumbql:"street"` 20 | City string `dumbql:"city"` 21 | State string `dumbql:"state"` 22 | Zip string `dumbql:"zip"` 23 | } 24 | 25 | //go:generate go run go.tomakado.io/dumbql/cmd/dumbqlgen -type BenchUser -package . 26 | 27 | type BenchUser struct { 28 | ID int64 `dumbql:"id"` 29 | Name string `dumbql:"name"` 30 | Email string `dumbql:"email"` 31 | Age int `dumbql:"age"` 32 | CreatedAt time.Time 33 | Active bool `dumbql:"active"` 34 | } 35 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | # basic 6 | target: 85% # the required coverage value 7 | threshold: 5% # the leniency in hitting the target 8 | patch: 9 | default: 10 | # basic 11 | target: 80% 12 | threshold: 5% 13 | 14 | ignore: 15 | - "**/*.gen.go" 16 | - "cmd/dumbqlgen/examples" 17 | - "cmd/dumbqlgen/main.go" 18 | -------------------------------------------------------------------------------- /dumbql.go: -------------------------------------------------------------------------------- 1 | // Package dumbql provides simple (dumb) query language and it's parser. 2 | package dumbql 3 | 4 | import ( 5 | "go.tomakado.io/dumbql/query" 6 | "go.tomakado.io/dumbql/schema" 7 | ) 8 | 9 | type Query struct { 10 | query.Expr 11 | } 12 | 13 | // Parse parses the input query string q, returning a Query reference or an error in case of invalid input. 14 | func Parse(q string, opts ...query.Option) (*Query, error) { 15 | res, err := query.Parse("query", []byte(q), opts...) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | return &Query{res.(query.Expr)}, nil 21 | } 22 | 23 | // Validate checks the query against the provided schema, returning a validated expression or an error 24 | // if any rule is violated. Even when error returned Validate can return query AST with invalided nodes dropped. 25 | func (q *Query) Validate(s schema.Schema) (query.Expr, error) { 26 | return q.Expr.Validate(s) 27 | } 28 | 29 | // ToSql converts the Query into an SQL string, returning the SQL string, arguments slice, 30 | // and any potential error encountered. 31 | func (q *Query) ToSql() (string, []any, error) { //nolint:revive 32 | return q.Expr.ToSql() 33 | } 34 | -------------------------------------------------------------------------------- /dumbql_example_test.go: -------------------------------------------------------------------------------- 1 | package dumbql_test 2 | 3 | import ( 4 | "fmt" 5 | 6 | sq "github.com/Masterminds/squirrel" 7 | "go.tomakado.io/dumbql" 8 | "go.tomakado.io/dumbql/schema" 9 | ) 10 | 11 | func ExampleParse() { 12 | const q = `profile.age >= 18 and profile.city = Barcelona and profile.verified = true` 13 | ast, err := dumbql.Parse(q) 14 | if err != nil { 15 | panic(err) 16 | } 17 | 18 | fmt.Println(ast) 19 | // Output: (and (and (>= profile.age 18) (= profile.city "Barcelona")) (= profile.verified true)) 20 | } 21 | 22 | func ExampleQuery_Validate() { 23 | schm := schema.Schema{ 24 | "status": schema.All( 25 | schema.Is[string](), 26 | schema.EqualsOneOf("pending", "approved", "rejected"), 27 | ), 28 | "period_months": schema.Max(int64(3)), 29 | "title": schema.LenInRange(1, 100), 30 | "active": schema.Is[bool](), 31 | } 32 | 33 | // The following query is invalid against the schema: 34 | // - period_months == 4, but max allowed value is 3 35 | // - field `name` is not described in the schema 36 | // 37 | // Invalid parts of the query are dropped. 38 | const q = `status:pending and period_months:4 and active:true and (title:"hello world" or name:"John Doe")` 39 | expr, err := dumbql.Parse(q) 40 | if err != nil { 41 | panic(err) 42 | } 43 | 44 | validated, err := expr.Validate(schm) 45 | fmt.Println(validated) 46 | fmt.Println(err) 47 | // Output: (and (and (= status "pending") (= active true)) (= title "hello world")) 48 | // field "period_months": value must be equal or less than 3, got 4; field "name" not found in schema 49 | } 50 | 51 | func ExampleQuery_ToSql() { 52 | const q = `status:pending and period_months < 4 and is_active:true and (title:"hello world" or name:"John Doe")` 53 | expr, err := dumbql.Parse(q) 54 | if err != nil { 55 | panic(err) 56 | } 57 | 58 | sql, args, err := sq.Select("*"). 59 | From("users"). 60 | Where(expr). 61 | ToSql() 62 | if err != nil { 63 | panic(err) 64 | } 65 | 66 | fmt.Println(sql) 67 | fmt.Println(args) 68 | // nolint:lll 69 | // Output: SELECT * FROM users WHERE (((status = ? AND period_months < ?) AND is_active = ?) AND (title = ? OR name = ?)) 70 | // [pending 4 true hello world John Doe] 71 | } 72 | 73 | func ExampleParse_booleanFields() { 74 | const q = `verified and premium and not banned and (admin or moderator)` 75 | ast, err := dumbql.Parse(q) 76 | if err != nil { 77 | panic(err) 78 | } 79 | 80 | fmt.Println(ast) 81 | //nolint:lll 82 | // Output: (and (and (and (= verified true) (= premium true)) (not (= banned true))) (or (= admin true) (= moderator true))) 83 | } 84 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.tomakado.io/dumbql 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/Masterminds/squirrel v1.5.4 7 | github.com/stretchr/testify v1.10.0 8 | go.uber.org/multierr v1.11.0 9 | golang.org/x/tools v0.32.0 10 | ) 11 | 12 | require ( 13 | github.com/davecgh/go-spew v1.1.1 // indirect 14 | github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect 15 | github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect 16 | github.com/pmezard/go-difflib v1.0.0 // indirect 17 | golang.org/x/mod v0.24.0 // indirect 18 | golang.org/x/sync v0.13.0 // indirect 19 | gopkg.in/yaml.v3 v3.0.1 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= 2 | github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 6 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 7 | github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= 8 | github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= 9 | github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= 10 | github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= 11 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 12 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 13 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 14 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 15 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 16 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 17 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 18 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 19 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 20 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 21 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 22 | golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= 23 | golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= 24 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 25 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 26 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 27 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 28 | -------------------------------------------------------------------------------- /match/errors.go: -------------------------------------------------------------------------------- 1 | package match 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrFieldNotFound = errors.New("field not found") 7 | ErrNotAStruct = errors.New("not a struct") 8 | ) 9 | -------------------------------------------------------------------------------- /match/match_example_test.go: -------------------------------------------------------------------------------- 1 | package match_test 2 | 3 | import ( 4 | "fmt" 5 | 6 | "go.tomakado.io/dumbql/match" 7 | "go.tomakado.io/dumbql/query" 8 | ) 9 | 10 | type MatchUser struct { 11 | ID int64 `dumbql:"id"` 12 | Name string `dumbql:"name"` 13 | Age int64 `dumbql:"age"` 14 | Score float64 `dumbql:"score"` 15 | Location string `dumbql:"location"` 16 | Role string `dumbql:"role"` 17 | Verified bool `dumbql:"verified"` 18 | Premium bool `dumbql:"premium"` 19 | } 20 | 21 | // createSampleUsers returns a slice of sample users for examples. 22 | func createSampleUsers() []MatchUser { 23 | return []MatchUser{ 24 | { 25 | ID: 1, 26 | Name: "John Doe", 27 | Age: 30, 28 | Score: 4.5, 29 | Location: "New York", 30 | Role: "admin", 31 | Verified: true, 32 | Premium: true, 33 | }, 34 | { 35 | ID: 2, 36 | Name: "Jane Smith", 37 | Age: 25, 38 | Score: 3.8, 39 | Location: "Los Angeles", 40 | Role: "user", 41 | Verified: true, 42 | Premium: false, 43 | }, 44 | { 45 | ID: 3, 46 | Name: "Bob Johnson", 47 | Age: 35, 48 | Score: 4.2, 49 | Location: "Chicago", 50 | Role: "user", 51 | Verified: false, 52 | Premium: false, 53 | }, 54 | // This one will be dropped: 55 | { 56 | ID: 4, 57 | Name: "Alice Smith", 58 | Age: 25, 59 | Score: 3.8, 60 | Location: "Los Angeles", 61 | Role: "admin", 62 | Verified: false, 63 | Premium: true, 64 | }, 65 | } 66 | } 67 | 68 | func Example() { 69 | // Define sample users 70 | users := createSampleUsers() 71 | 72 | q := `(age >= 30 and score > 4.0) or (location:"Los Angeles" and role:"user")` 73 | ast, _ := query.Parse("test", []byte(q)) 74 | expr := ast.(query.Expr) 75 | 76 | matcher := &match.StructMatcher{} 77 | 78 | filtered := make([]MatchUser, 0, len(users)) 79 | 80 | for _, user := range users { 81 | if expr.Match(&user, matcher) { 82 | filtered = append(filtered, user) 83 | } 84 | } 85 | 86 | // Print each match on a separate line to avoid long line warnings 87 | for i, u := range filtered { 88 | fmt.Printf("Match %d: %v\n", i+1, u) 89 | } 90 | // Output: 91 | // Match 1: {1 John Doe 30 4.5 New York admin true true} 92 | // Match 2: {2 Jane Smith 25 3.8 Los Angeles user true false} 93 | // Match 3: {3 Bob Johnson 35 4.2 Chicago user false false} 94 | } 95 | 96 | func Example_booleanFields() { 97 | users := []MatchUser{ 98 | { 99 | ID: 1, 100 | Name: "John Doe", 101 | Age: 30, 102 | Score: 4.5, 103 | Location: "New York", 104 | Role: "admin", 105 | Verified: true, 106 | Premium: true, 107 | }, 108 | { 109 | ID: 2, 110 | Name: "Jane Smith", 111 | Age: 25, 112 | Score: 3.8, 113 | Location: "Los Angeles", 114 | Role: "user", 115 | Verified: true, 116 | Premium: false, 117 | }, 118 | { 119 | ID: 3, 120 | Name: "Bob Johnson", 121 | Age: 35, 122 | Score: 4.2, 123 | Location: "Chicago", 124 | Role: "user", 125 | Verified: false, 126 | Premium: false, 127 | }, 128 | } 129 | 130 | // Boolean fields with shorthand syntax 131 | q := `verified and (premium or role:"user")` 132 | ast, _ := query.Parse("test", []byte(q)) 133 | expr := ast.(query.Expr) 134 | 135 | matcher := &match.StructMatcher{} 136 | 137 | filtered := make([]MatchUser, 0, len(users)) 138 | 139 | for _, user := range users { 140 | if expr.Match(&user, matcher) { 141 | filtered = append(filtered, user) 142 | } 143 | } 144 | 145 | // Print each match on a separate line to avoid long line warnings 146 | for i, u := range filtered { 147 | fmt.Printf("Match %d: %v\n", i+1, u) 148 | } 149 | // Output: 150 | // Match 1: {1 John Doe 30 4.5 New York admin true true} 151 | // Match 2: {2 Jane Smith 25 3.8 Los Angeles user true false} 152 | } 153 | -------------------------------------------------------------------------------- /match/path.go: -------------------------------------------------------------------------------- 1 | package match 2 | 3 | import "iter" 4 | 5 | func Path(s string) iter.Seq[string] { 6 | return func(yield func(string) bool) { 7 | start := 0 8 | for i := range len(s) { 9 | if s[i] == '.' { 10 | if !yield(s[start:i]) { 11 | return 12 | } 13 | start = i + 1 14 | } 15 | } 16 | yield(s[start:]) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /match/router.go: -------------------------------------------------------------------------------- 1 | package match 2 | 3 | import ( 4 | "reflect" 5 | ) 6 | 7 | // For high-performance applications, consider using the code generator 8 | // (cmd/dumbqlgen) to create a type-specific Router implementation instead 9 | // of using ReflectRouter. The generated router avoids reflection at runtime, 10 | // providing better performance, especially in hot paths. 11 | // 12 | // Example usage: 13 | // //go:generate dumbqlgen -type User -package . 14 | 15 | // ReflectRouter implements Router for struct targets. 16 | // It supports struct tags using the `dumbql` tag name and nested field access using dot notation. 17 | type ReflectRouter struct{} 18 | 19 | // Route resolves a field path in the target struct and returns the value. 20 | // It supports nested field access using dot notation (e.g., "address.city"). 21 | func (r *ReflectRouter) Route(target any, field string) (any, error) { 22 | var ( 23 | cursor = target 24 | err error 25 | ) 26 | 27 | for field := range Path(field) { 28 | if field == "" { 29 | return nil, ErrFieldNotFound 30 | } 31 | 32 | cursor, err = r.resolveField(cursor, field) 33 | if err != nil { 34 | return nil, err 35 | } 36 | } 37 | 38 | return cursor, nil 39 | } 40 | 41 | func (r *ReflectRouter) resolveField(target any, field string) (any, error) { 42 | v := reflect.ValueOf(target) 43 | if v.Kind() == reflect.Ptr { 44 | if v.IsNil() { 45 | return nil, ErrNotAStruct // Nil pointer, can't resolve field 46 | } 47 | v = v.Elem() 48 | } 49 | 50 | if v.Kind() != reflect.Struct { 51 | return nil, ErrNotAStruct // Not a struct, can't resolve field 52 | } 53 | 54 | t := v.Type() 55 | for i := range t.NumField() { 56 | f := t.Field(i) 57 | 58 | tag := f.Tag.Get("dumbql") 59 | if tag == "-" { 60 | // Field marked with dumbql:"-" is skipped 61 | return nil, ErrFieldNotFound 62 | } 63 | 64 | fname := f.Name 65 | if tag != "" { 66 | fname = tag 67 | } 68 | 69 | if fname == field { 70 | return v.Field(i).Interface(), nil 71 | } 72 | } 73 | 74 | // Field not found 75 | return nil, ErrFieldNotFound 76 | } 77 | -------------------------------------------------------------------------------- /match/struct.go: -------------------------------------------------------------------------------- 1 | package match 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | 7 | "go.tomakado.io/dumbql/query" 8 | ) 9 | 10 | // Router is responsible for resolving a field path to a value in the target object. 11 | // It abstracts the field resolution logic from the matcher implementation. 12 | type Router interface { 13 | // Route resolves a field path in the target object and returns the value. 14 | // The boolean return value indicates whether the field was successfully resolved. 15 | Route(target any, field string) (any, error) 16 | } 17 | 18 | // StructMatcher is a basic implementation of the Matcher interface for evaluating query expressions against structs. 19 | // It supports struct tags using the `dumbql` tag name, which allows you to specify a custom field name. 20 | type StructMatcher struct { 21 | router Router 22 | } 23 | 24 | func NewStructMatcher(router Router) *StructMatcher { 25 | return &StructMatcher{ 26 | router: router, 27 | } 28 | } 29 | 30 | func (m *StructMatcher) lazyInit() { 31 | if m.router == nil { 32 | m.router = &ReflectRouter{} 33 | } 34 | } 35 | 36 | func (m *StructMatcher) MatchAnd(target any, left, right query.Expr) bool { 37 | return left.Match(target, m) && right.Match(target, m) 38 | } 39 | 40 | func (m *StructMatcher) MatchOr(target any, left, right query.Expr) bool { 41 | return left.Match(target, m) || right.Match(target, m) 42 | } 43 | 44 | func (m *StructMatcher) MatchNot(target any, expr query.Expr) bool { 45 | return !expr.Match(target, m) 46 | } 47 | 48 | // MatchField matches a field in the target struct using the provided value and operator. 49 | // It supports struct tags using the `dumbql` tag name and nested field access using dot notation. 50 | // For example: "address.city" to access the city field in the address struct. 51 | func (m *StructMatcher) MatchField(target any, field string, value query.Valuer, op query.FieldOperator) bool { 52 | m.lazyInit() 53 | 54 | fieldValue, err := m.router.Route(target, field) 55 | 56 | switch { 57 | case op == query.Exists: 58 | return err == nil && !isZero(fieldValue) 59 | case err != nil: 60 | return errors.Is(err, ErrFieldNotFound) 61 | default: 62 | return m.MatchValue(fieldValue, value, op) 63 | } 64 | } 65 | 66 | func (m *StructMatcher) MatchValue(target any, value query.Valuer, op query.FieldOperator) bool { 67 | return value.Match(target, op) 68 | } 69 | 70 | func isZero(tv any) bool { 71 | if tv == nil { 72 | return true 73 | } 74 | 75 | v := reflect.ValueOf(tv) 76 | return !v.IsValid() || reflect.DeepEqual(v.Interface(), reflect.Zero(v.Type()).Interface()) 77 | } 78 | -------------------------------------------------------------------------------- /match/struct_example_test.go: -------------------------------------------------------------------------------- 1 | package match_test 2 | 3 | import ( 4 | "fmt" 5 | 6 | "go.tomakado.io/dumbql/match" 7 | "go.tomakado.io/dumbql/query" 8 | ) 9 | 10 | type User struct { 11 | ID int64 `dumbql:"id"` 12 | Name string `dumbql:"name"` 13 | Age int64 `dumbql:"age"` 14 | Score float64 `dumbql:"score"` 15 | Location string `dumbql:"location"` 16 | Role string `dumbql:"role"` 17 | Verified bool `dumbql:"verified"` 18 | Premium bool `dumbql:"premium"` 19 | } 20 | 21 | func ExampleStructMatcher_MatchField_simpleMatching() { 22 | user := &User{ 23 | ID: 1, 24 | Name: "John Doe", 25 | Age: 30, 26 | Score: 4.5, 27 | Location: "New York", 28 | Role: "admin", 29 | Verified: true, 30 | Premium: false, 31 | } 32 | 33 | // Parse a simple equality query 34 | q := `name = "John Doe"` 35 | ast, _ := query.Parse("test", []byte(q)) 36 | expr := ast.(query.Expr) 37 | 38 | // Create a matcher 39 | matcher := &match.StructMatcher{} 40 | result := expr.Match(user, matcher) 41 | 42 | fmt.Printf("%s: %v\n", q, result) 43 | // Output: name = "John Doe": true 44 | } 45 | 46 | func ExampleStructMatcher_MatchField_complexMatching() { 47 | type Address struct { 48 | Street string `dumbql:"street"` 49 | City string `dumbql:"city"` 50 | Country string `dumbql:"country"` 51 | Zip string `dumbql:"zip"` 52 | } 53 | 54 | type UserWithAddress struct { 55 | ID int64 `dumbql:"id"` 56 | Name string `dumbql:"name"` 57 | Age int64 `dumbql:"age"` 58 | Score float64 `dumbql:"score"` 59 | Location string `dumbql:"location"` 60 | Role string `dumbql:"role"` 61 | Verified bool `dumbql:"verified"` 62 | Premium bool `dumbql:"premium"` 63 | Address Address `dumbql:"address"` 64 | } 65 | 66 | user := &UserWithAddress{ 67 | ID: 1, 68 | Name: "John Doe", 69 | Age: 30, 70 | Score: 4.5, 71 | Location: "New York", 72 | Role: "admin", 73 | Verified: true, 74 | Premium: false, 75 | Address: Address{ 76 | Street: "123 Main St", 77 | City: "New York", 78 | Country: "USA", 79 | Zip: "10001", 80 | }, 81 | } 82 | 83 | // Parse a complex query with multiple conditions including nested field traversal 84 | // This demonstrates the nested field access capability using dot notation 85 | q := `age >= 25 and address.city:"New York" and score > 4.0` 86 | ast, _ := query.Parse("test", []byte(q)) 87 | expr := ast.(query.Expr) 88 | 89 | // Create a matcher 90 | matcher := &match.StructMatcher{} 91 | result := expr.Match(user, matcher) 92 | 93 | fmt.Printf("%s: %v\n", q, result) 94 | // Output: age >= 25 and address.city:"New York" and score > 4.0: true 95 | } 96 | 97 | func ExampleStructMatcher_MatchField_numericComparisons() { 98 | user := &User{ 99 | ID: 1, 100 | Name: "John Doe", 101 | Age: 30, 102 | Score: 4.5, 103 | Location: "New York", 104 | Role: "admin", 105 | Verified: true, 106 | Premium: false, 107 | } 108 | 109 | // Test various numeric comparisons 110 | queries := []string{ 111 | `age > 20`, 112 | `age < 40`, 113 | `age >= 30`, 114 | `age <= 30`, 115 | `score > 4.0`, 116 | `score < 5.0`, 117 | } 118 | 119 | matcher := &match.StructMatcher{} 120 | 121 | for _, q := range queries { 122 | ast, _ := query.Parse("test", []byte(q)) 123 | expr := ast.(query.Expr) 124 | result := expr.Match(user, matcher) 125 | fmt.Printf("Query '%s' match result: %v\n", q, result) 126 | } 127 | // Output: Query 'age > 20' match result: true 128 | // Query 'age < 40' match result: true 129 | // Query 'age >= 30' match result: true 130 | // Query 'age <= 30' match result: true 131 | // Query 'score > 4.0' match result: true 132 | // Query 'score < 5.0' match result: true 133 | } 134 | 135 | func ExampleStructMatcher_MatchField_stringOperations() { 136 | user := &User{ 137 | ID: 1, 138 | Name: "John Doe", 139 | Age: 30, 140 | Score: 4.5, 141 | Location: "New York", 142 | Role: "admin", 143 | } 144 | 145 | // Test various string operations 146 | queries := []string{ 147 | `name:"John Doe"`, 148 | `name~"John"`, 149 | `location:"New York"`, 150 | `role:admin`, 151 | } 152 | 153 | matcher := &match.StructMatcher{} 154 | 155 | for _, q := range queries { 156 | ast, _ := query.Parse("test", []byte(q)) 157 | expr := ast.(query.Expr) 158 | result := expr.Match(user, matcher) 159 | fmt.Printf("Query '%s' match result: %v\n", q, result) 160 | } 161 | // Output: 162 | // Query 'name:"John Doe"' match result: true 163 | // Query 'name~"John"' match result: true 164 | // Query 'location:"New York"' match result: true 165 | // Query 'role:admin' match result: true 166 | } 167 | 168 | func ExampleStructMatcher_MatchField_notExpressions() { 169 | user := &User{ 170 | ID: 1, 171 | Name: "John Doe", 172 | Age: 30, 173 | Score: 4.5, 174 | Location: "New York", 175 | Role: "admin", 176 | } 177 | 178 | // Test NOT expressions 179 | queries := []string{ 180 | `not age < 25`, 181 | `not location:"Los Angeles"`, 182 | `not (role:"user" and score < 3.0)`, 183 | } 184 | 185 | matcher := &match.StructMatcher{} 186 | 187 | for _, q := range queries { 188 | ast, _ := query.Parse("test", []byte(q)) 189 | expr := ast.(query.Expr) 190 | result := expr.Match(user, matcher) 191 | fmt.Printf("Query '%s' match result: %v\n", q, result) 192 | } 193 | // Output: 194 | // Query 'not age < 25' match result: true 195 | // Query 'not location:"Los Angeles"' match result: true 196 | // Query 'not (role:"user" and score < 3.0)' match result: true 197 | } 198 | 199 | func ExampleStructMatcher_MatchField_multiMatch() { 200 | users := []User{ 201 | { 202 | ID: 1, 203 | Name: "John Doe", 204 | Age: 30, 205 | Score: 4.5, 206 | Location: "New York", 207 | Role: "admin", 208 | }, 209 | { 210 | ID: 2, 211 | Name: "Jane Smith", 212 | Age: 25, 213 | Score: 3.8, 214 | Location: "Los Angeles", 215 | Role: "user", 216 | }, 217 | { 218 | ID: 3, 219 | Name: "Bob Johnson", 220 | Age: 35, 221 | Score: 4.2, 222 | Location: "Chicago", 223 | Role: "user", 224 | }, 225 | // This one will be dropped: 226 | { 227 | ID: 4, 228 | Name: "Alice Smith", 229 | Age: 25, 230 | Score: 3.8, 231 | Location: "Los Angeles", 232 | Role: "admin", 233 | }, 234 | } 235 | 236 | q := `(age >= 30 and score > 4.0) or (location:"Los Angeles" and role:"user")` 237 | ast, _ := query.Parse("test", []byte(q)) 238 | expr := ast.(query.Expr) 239 | 240 | matcher := &match.StructMatcher{} 241 | 242 | filtered := make([]User, 0, len(users)) 243 | 244 | for _, user := range users { 245 | if expr.Match(&user, matcher) { 246 | filtered = append(filtered, user) 247 | } 248 | } 249 | 250 | // Print each match on a separate line to avoid long line warnings 251 | for i, u := range filtered { 252 | fmt.Printf("Match %d: %v\n", i+1, u) 253 | } 254 | // Output: 255 | // Match 1: {1 John Doe 30 4.5 New York admin false false} 256 | // Match 2: {2 Jane Smith 25 3.8 Los Angeles user false false} 257 | // Match 3: {3 Bob Johnson 35 4.2 Chicago user false false} 258 | } 259 | 260 | func ExampleStructMatcher_MatchField_oneOfExpression() { 261 | user := &User{ 262 | ID: 1, 263 | Name: "John Doe", 264 | Age: 30, 265 | Score: 4.5, 266 | Location: "New York", 267 | Role: "admin", 268 | } 269 | 270 | // Test OneOf expressions 271 | queries := []string{ 272 | `location:["New York", "Los Angeles", "Chicago"]`, 273 | `role:["admin", "superuser"]`, 274 | `age:[25, 30, 35]`, 275 | } 276 | 277 | matcher := &match.StructMatcher{} 278 | 279 | for _, q := range queries { 280 | ast, _ := query.Parse("test", []byte(q)) 281 | expr := ast.(query.Expr) 282 | result := expr.Match(user, matcher) 283 | fmt.Printf("Query '%s' match result: %v\n", q, result) 284 | } 285 | // Output: 286 | // Query 'location:["New York", "Los Angeles", "Chicago"]' match result: true 287 | // Query 'role:["admin", "superuser"]' match result: true 288 | // Query 'age:[25, 30, 35]' match result: true 289 | } 290 | 291 | func ExampleStructMatcher_MatchField_edgeCases() { 292 | user := &User{ 293 | ID: 1, 294 | Name: "John Doe", 295 | Age: 30, 296 | Score: 4.5, 297 | Location: "New York", 298 | Role: "admin", 299 | } 300 | // Test edge cases and special scenarios 301 | queries := []string{ 302 | // Non-existent field 303 | `nonexistent:"value"`, 304 | // Invalid type comparison 305 | `age:"not a number"`, 306 | // Empty string matching 307 | `name:""`, 308 | // Zero value matching 309 | `score:0`, 310 | // Complex nested expression 311 | `(age > 20 and age < 40) and (score >= 4.0 or role:"admin")`, 312 | } 313 | 314 | matcher := &match.StructMatcher{} 315 | 316 | for _, q := range queries { 317 | ast, _ := query.Parse("test", []byte(q)) 318 | expr := ast.(query.Expr) 319 | result := expr.Match(user, matcher) 320 | fmt.Printf("Query '%s' match result: %v\n", q, result) 321 | } 322 | // Output: 323 | // Query 'nonexistent:"value"' match result: true 324 | // Query 'age:"not a number"' match result: false 325 | // Query 'name:""' match result: false 326 | // Query 'score:0' match result: false 327 | // Query '(age > 20 and age < 40) and (score >= 4.0 or role:"admin")' match result: true 328 | } 329 | 330 | func ExampleStructMatcher_MatchField_structTagOmit() { 331 | type User struct { 332 | ID int64 `dumbql:"id"` 333 | Name string `dumbql:"name"` 334 | Password string `dumbql:"-"` // Omitted from querying 335 | Internal bool `dumbql:"-"` // Omitted from querying 336 | Score float64 `dumbql:"score"` 337 | } 338 | 339 | user := &User{ 340 | ID: 1, 341 | Name: "John", 342 | Password: "secret123", 343 | Internal: true, 344 | Score: 4.5, 345 | } 346 | 347 | // Test various queries including omitted fields 348 | queries := []string{ 349 | // Query against visible field 350 | `id:1`, 351 | // Query against omitted field - always matches 352 | `password:"wrong_password"`, 353 | // Query against omitted boolean field - always matches 354 | `internal:false`, 355 | // Combined visible and omitted fields 356 | `id:1 and password:"wrong_password"`, 357 | // Complex query with omitted fields 358 | `(id:1 or score > 4.0) and (password:"wrong" or internal:false)`, 359 | } 360 | 361 | matcher := &match.StructMatcher{} 362 | 363 | for _, q := range queries { 364 | ast, _ := query.Parse("test", []byte(q)) 365 | expr := ast.(query.Expr) 366 | result := expr.Match(user, matcher) 367 | fmt.Printf("Query '%s' match result: %v\n", q, result) 368 | } 369 | // Output: 370 | // Query 'id:1' match result: true 371 | // Query 'password:"wrong_password"' match result: true 372 | // Query 'internal:false' match result: true 373 | // Query 'id:1 and password:"wrong_password"' match result: true 374 | // Query '(id:1 or score > 4.0) and (password:"wrong" or internal:false)' match result: true 375 | } 376 | 377 | func ExampleStructMatcher_MatchField_booleanFields() { 378 | user := &User{ 379 | ID: 1, 380 | Name: "John Doe", 381 | Age: 30, 382 | Score: 4.5, 383 | Location: "New York", 384 | Role: "admin", 385 | Verified: true, 386 | Premium: false, 387 | } 388 | 389 | // Test boolean field expressions 390 | queries := []string{ 391 | // Standard boolean comparison 392 | `verified:true`, 393 | `premium:false`, 394 | // Not equal comparison 395 | `verified!=false`, 396 | `premium!=true`, 397 | // Boolean field shorthand syntax 398 | `verified`, 399 | `not premium`, 400 | // Complex expressions with boolean shorthand 401 | `verified and not premium`, 402 | `verified and role:"admin"`, 403 | `verified and (age > 25 or location:"New York")`, 404 | } 405 | 406 | matcher := &match.StructMatcher{} 407 | 408 | for _, q := range queries { 409 | ast, _ := query.Parse("test", []byte(q)) 410 | expr := ast.(query.Expr) 411 | result := expr.Match(user, matcher) 412 | fmt.Printf("Query '%s' match result: %v\n", q, result) 413 | } 414 | // Output: 415 | // Query 'verified:true' match result: true 416 | // Query 'premium:false' match result: true 417 | // Query 'verified!=false' match result: true 418 | // Query 'premium!=true' match result: true 419 | // Query 'verified' match result: true 420 | // Query 'not premium' match result: true 421 | // Query 'verified and not premium' match result: true 422 | // Query 'verified and role:"admin"' match result: true 423 | // Query 'verified and (age > 25 or location:"New York")' match result: true 424 | } 425 | -------------------------------------------------------------------------------- /match/struct_internal_test.go: -------------------------------------------------------------------------------- 1 | package match 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func Test_isZero(t *testing.T) { //nolint:funlen 11 | tests := []struct { 12 | name string 13 | value any 14 | expect bool 15 | }{ 16 | { 17 | name: "nil value", 18 | value: nil, 19 | expect: true, 20 | }, 21 | { 22 | name: "zero int", 23 | value: 0, 24 | expect: true, 25 | }, 26 | { 27 | name: "non-zero int", 28 | value: 42, 29 | expect: false, 30 | }, 31 | { 32 | name: "zero int64", 33 | value: int64(0), 34 | expect: true, 35 | }, 36 | { 37 | name: "non-zero int64", 38 | value: int64(42), 39 | expect: false, 40 | }, 41 | { 42 | name: "zero float64", 43 | value: 0.0, 44 | expect: true, 45 | }, 46 | { 47 | name: "non-zero float64", 48 | value: 3.14, 49 | expect: false, 50 | }, 51 | { 52 | name: "empty string", 53 | value: "", 54 | expect: true, 55 | }, 56 | { 57 | name: "non-empty string", 58 | value: "hello", 59 | expect: false, 60 | }, 61 | { 62 | name: "false boolean", 63 | value: false, 64 | expect: true, 65 | }, 66 | { 67 | name: "true boolean", 68 | value: true, 69 | expect: false, 70 | }, 71 | { 72 | name: "empty slice", 73 | value: []int{}, 74 | expect: false, // Empty slice is not considered zero by reflect.DeepEqual 75 | }, 76 | { 77 | name: "non-empty slice", 78 | value: []int{1, 2, 3}, 79 | expect: false, 80 | }, 81 | { 82 | name: "empty map", 83 | value: map[string]int{}, 84 | expect: false, // Empty map is not considered zero by reflect.DeepEqual 85 | }, 86 | { 87 | name: "non-empty map", 88 | value: map[string]int{"a": 1}, 89 | expect: false, 90 | }, 91 | { 92 | name: "zero struct", 93 | value: struct{}{}, 94 | expect: true, 95 | }, 96 | { 97 | name: "zero time", 98 | value: time.Time{}, 99 | expect: true, 100 | }, 101 | { 102 | name: "non-zero time", 103 | value: time.Now(), 104 | expect: false, 105 | }, 106 | { 107 | name: "struct with zero fields", 108 | value: struct { 109 | Name string 110 | Age int 111 | }{}, 112 | expect: true, 113 | }, 114 | { 115 | name: "struct with non-zero fields", 116 | value: struct { 117 | Name string 118 | Age int 119 | }{ 120 | Name: "John", 121 | Age: 30, 122 | }, 123 | expect: false, 124 | }, 125 | { 126 | name: "nil pointer", 127 | value: (*int)(nil), 128 | expect: true, 129 | }, 130 | { 131 | name: "pointer to zero value", 132 | value: func() any { 133 | i := 0 134 | return &i 135 | }(), 136 | expect: false, // Not zero because it's a valid pointer 137 | }, 138 | { 139 | name: "pointer to non-zero value", 140 | value: func() any { 141 | i := 42 142 | return &i 143 | }(), 144 | expect: false, 145 | }, 146 | { 147 | name: "invalid reflect value", 148 | value: make(chan int), // Channels are not comparable with DeepEqual 149 | expect: false, // Not zero because it's a valid channel 150 | }, 151 | } 152 | 153 | for _, tt := range tests { 154 | t.Run(tt.name, func(t *testing.T) { 155 | result := isZero(tt.value) 156 | assert.Equal(t, tt.expect, result, "isZero(%v) = %v, want %v", tt.value, result, tt.expect) 157 | }) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /match/struct_test.go: -------------------------------------------------------------------------------- 1 | package match_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "go.tomakado.io/dumbql/match" 8 | "go.tomakado.io/dumbql/query" 9 | ) 10 | 11 | type address struct { 12 | Street string `dumbql:"street"` 13 | City string `dumbql:"city"` 14 | Country string `dumbql:"country"` 15 | Zip string `dumbql:"zip"` 16 | } 17 | 18 | type contact struct { 19 | Email string `dumbql:"email"` 20 | Phone string `dumbql:"phone"` 21 | Address address `dumbql:"address"` 22 | Emergency *person `dumbql:"emergency"` 23 | } 24 | 25 | type person struct { 26 | Name string `dumbql:"name"` 27 | Age int64 `dumbql:"age"` 28 | Height float64 `dumbql:"height"` 29 | IsMember bool 30 | Hidden string `dumbql:"-"` 31 | Contact contact `dumbql:"contact"` 32 | Manager *person `dumbql:"manager"` 33 | } 34 | 35 | func TestStructMatcher_MatchAnd(t *testing.T) { //nolint:funlen 36 | matcher := &match.StructMatcher{} 37 | target := person{Name: "John", Age: 30} 38 | 39 | tests := []struct { 40 | name string 41 | left query.Expr 42 | right query.Expr 43 | want bool 44 | }{ 45 | { 46 | name: "both conditions true", 47 | left: &query.FieldExpr{ 48 | Field: "name", 49 | Op: query.Equal, 50 | Value: &query.StringLiteral{StringValue: "John"}, 51 | }, 52 | right: &query.FieldExpr{ 53 | Field: "age", 54 | Op: query.Equal, 55 | Value: &query.NumberLiteral{NumberValue: 30}, 56 | }, 57 | want: true, 58 | }, 59 | { 60 | name: "left condition false", 61 | left: &query.FieldExpr{ 62 | Field: "name", 63 | Op: query.Equal, 64 | Value: &query.StringLiteral{StringValue: "Jane"}, 65 | }, 66 | right: &query.FieldExpr{ 67 | Field: "age", 68 | Op: query.Equal, 69 | Value: &query.NumberLiteral{NumberValue: 30}, 70 | }, 71 | want: false, 72 | }, 73 | { 74 | name: "right condition false", 75 | left: &query.FieldExpr{ 76 | Field: "name", 77 | Op: query.Equal, 78 | Value: &query.StringLiteral{StringValue: "John"}, 79 | }, 80 | right: &query.FieldExpr{ 81 | Field: "age", 82 | Op: query.Equal, 83 | Value: &query.NumberLiteral{NumberValue: 25}, 84 | }, 85 | want: false, 86 | }, 87 | { 88 | name: "both conditions false", 89 | left: &query.FieldExpr{ 90 | Field: "name", 91 | Op: query.Equal, 92 | Value: &query.StringLiteral{StringValue: "Jane"}, 93 | }, 94 | right: &query.FieldExpr{ 95 | Field: "age", 96 | Op: query.Equal, 97 | Value: &query.NumberLiteral{NumberValue: 25}, 98 | }, 99 | want: false, 100 | }, 101 | } 102 | 103 | for _, test := range tests { 104 | t.Run(test.name, func(t *testing.T) { 105 | result := matcher.MatchAnd(target, test.left, test.right) 106 | assert.Equal(t, test.want, result) 107 | }) 108 | } 109 | } 110 | 111 | func TestStructMatcher_MatchOr(t *testing.T) { //nolint:funlen 112 | matcher := &match.StructMatcher{} 113 | target := person{Name: "John", Age: 30} 114 | 115 | tests := []struct { 116 | name string 117 | left query.Expr 118 | right query.Expr 119 | want bool 120 | }{ 121 | { 122 | name: "both conditions true", 123 | left: &query.FieldExpr{ 124 | Field: "name", 125 | Op: query.Equal, 126 | Value: &query.StringLiteral{StringValue: "John"}, 127 | }, 128 | right: &query.FieldExpr{ 129 | Field: "age", 130 | Op: query.Equal, 131 | Value: &query.NumberLiteral{NumberValue: 30}, 132 | }, 133 | want: true, 134 | }, 135 | { 136 | name: "left condition true only", 137 | left: &query.FieldExpr{ 138 | Field: "name", 139 | Op: query.Equal, 140 | Value: &query.StringLiteral{StringValue: "John"}, 141 | }, 142 | right: &query.FieldExpr{ 143 | Field: "age", 144 | Op: query.Equal, 145 | Value: &query.NumberLiteral{NumberValue: 25}, 146 | }, 147 | want: true, 148 | }, 149 | { 150 | name: "right condition true only", 151 | left: &query.FieldExpr{ 152 | Field: "name", 153 | Op: query.Equal, 154 | Value: &query.StringLiteral{StringValue: "Jane"}, 155 | }, 156 | right: &query.FieldExpr{ 157 | Field: "age", 158 | Op: query.Equal, 159 | Value: &query.NumberLiteral{NumberValue: 30}, 160 | }, 161 | want: true, 162 | }, 163 | { 164 | name: "both conditions false", 165 | left: &query.FieldExpr{ 166 | Field: "name", 167 | Op: query.Equal, 168 | Value: &query.StringLiteral{StringValue: "Jane"}, 169 | }, 170 | right: &query.FieldExpr{ 171 | Field: "age", 172 | Op: query.Equal, 173 | Value: &query.NumberLiteral{NumberValue: 25}, 174 | }, 175 | want: false, 176 | }, 177 | } 178 | 179 | for _, test := range tests { 180 | t.Run(test.name, func(t *testing.T) { 181 | result := matcher.MatchOr(target, test.left, test.right) 182 | assert.Equal(t, test.want, result) 183 | }) 184 | } 185 | } 186 | 187 | func TestStructMatcher_MatchNot(t *testing.T) { 188 | matcher := &match.StructMatcher{} 189 | target := person{Name: "John", Age: 30} 190 | 191 | tests := []struct { 192 | name string 193 | expr query.Expr 194 | want bool 195 | }{ 196 | { 197 | name: "negate true condition", 198 | expr: &query.FieldExpr{ 199 | Field: "name", 200 | Op: query.Equal, 201 | Value: &query.StringLiteral{StringValue: "John"}, 202 | }, 203 | want: false, 204 | }, 205 | { 206 | name: "negate false condition", 207 | expr: &query.FieldExpr{ 208 | Field: "name", 209 | Op: query.Equal, 210 | Value: &query.StringLiteral{StringValue: "Jane"}, 211 | }, 212 | want: true, 213 | }, 214 | } 215 | 216 | for _, test := range tests { 217 | t.Run(test.name, func(t *testing.T) { 218 | result := matcher.MatchNot(target, test.expr) 219 | assert.Equal(t, test.want, result) 220 | }) 221 | } 222 | } 223 | 224 | func TestStructMatcher_MatchField(t *testing.T) { //nolint:funlen 225 | matcher := &match.StructMatcher{} 226 | 227 | // Create null manager for Jane to test nil pointer traversal 228 | managerContact := contact{ 229 | Email: "manager@example.com", 230 | Phone: "987-654-3210", 231 | } 232 | manager := &person{ 233 | Name: "Jane", 234 | Age: 40, 235 | Height: 1.68, 236 | Contact: managerContact, 237 | // Manager is nil 238 | } 239 | 240 | // Create Bob as emergency contact 241 | emergencyContact := &person{ 242 | Name: "Bob", 243 | Age: 35, 244 | Height: 1.80, 245 | Hidden: "sensitive data", // Test field with dumbql:"-" tag 246 | } 247 | 248 | // Create John's contact info 249 | johnContact := contact{ 250 | Email: "john@example.com", 251 | Phone: "123-456-7890", 252 | Emergency: emergencyContact, 253 | Address: address{ 254 | Street: "123 Main St", 255 | City: "Anytown", 256 | Country: "Countryland", 257 | Zip: "12345", 258 | }, 259 | } 260 | 261 | // Create the main test target 262 | target := person{ 263 | Name: "John", 264 | Age: 30, 265 | Height: 1.75, 266 | IsMember: true, 267 | Hidden: "should be hidden", 268 | Contact: johnContact, 269 | Manager: manager, 270 | } 271 | 272 | tests := []struct { 273 | name string 274 | field string 275 | value query.Valuer 276 | op query.FieldOperator 277 | want bool 278 | }{ 279 | // Basic field tests 280 | { 281 | name: "string equal match", 282 | field: "name", 283 | value: &query.StringLiteral{StringValue: "John"}, 284 | op: query.Equal, 285 | want: true, 286 | }, 287 | { 288 | name: "string not equal match", 289 | field: "name", 290 | value: &query.StringLiteral{StringValue: "Jane"}, 291 | op: query.NotEqual, 292 | want: true, 293 | }, 294 | { 295 | name: "integer equal match", 296 | field: "age", 297 | value: &query.NumberLiteral{NumberValue: 30}, 298 | op: query.Equal, 299 | want: true, 300 | }, 301 | { 302 | name: "float greater than match", 303 | field: "height", 304 | value: &query.NumberLiteral{NumberValue: 1.70}, 305 | op: query.GreaterThan, 306 | want: true, 307 | }, 308 | { 309 | name: "non-existent field", 310 | field: "invalid", 311 | value: &query.StringLiteral{StringValue: "test"}, 312 | op: query.Equal, 313 | want: true, 314 | }, 315 | // Field presence tests 316 | { 317 | name: "existing field presence", 318 | field: "name", 319 | value: nil, 320 | op: query.Exists, 321 | want: true, 322 | }, 323 | { 324 | name: "non-existent field presence", 325 | field: "invalid", 326 | value: nil, 327 | op: query.Exists, 328 | want: false, 329 | }, 330 | // Nested field tests 331 | { 332 | name: "one level nesting", 333 | field: "contact.email", 334 | value: &query.StringLiteral{StringValue: "john@example.com"}, 335 | op: query.Equal, 336 | want: true, 337 | }, 338 | { 339 | name: "two level nesting", 340 | field: "contact.address.city", 341 | value: &query.StringLiteral{StringValue: "Anytown"}, 342 | op: query.Equal, 343 | want: true, 344 | }, 345 | { 346 | name: "pointer field access", 347 | field: "manager.name", 348 | value: &query.StringLiteral{StringValue: "Jane"}, 349 | op: query.Equal, 350 | want: true, 351 | }, 352 | { 353 | name: "multiple level with pointer", 354 | field: "contact.emergency.age", 355 | value: &query.NumberLiteral{NumberValue: 35}, 356 | op: query.Equal, 357 | want: true, 358 | }, 359 | { 360 | name: "nested field not equal", 361 | field: "contact.address.country", 362 | value: &query.StringLiteral{StringValue: "Otherland"}, 363 | op: query.NotEqual, 364 | want: true, 365 | }, 366 | { 367 | name: "deep nesting with comparison", 368 | field: "manager.contact.email", 369 | value: &query.StringLiteral{StringValue: "manager"}, 370 | op: query.Like, 371 | want: true, 372 | }, 373 | { 374 | name: "non-existent nested field", 375 | field: "contact.nonexistent", 376 | value: &query.StringLiteral{StringValue: "test"}, 377 | op: query.Equal, 378 | want: true, 379 | }, 380 | { 381 | name: "non-existent deep nested field", 382 | field: "contact.address.nonexistent", 383 | value: &query.StringLiteral{StringValue: "test"}, 384 | op: query.Equal, 385 | want: true, 386 | }, 387 | { 388 | name: "invalid path (non-struct intermediate)", 389 | field: "name.something", 390 | value: &query.StringLiteral{StringValue: "test"}, 391 | op: query.Equal, 392 | want: false, 393 | }, 394 | { 395 | name: "nil pointer in path", 396 | field: "manager.manager.name", // manager.manager is nil 397 | value: &query.StringLiteral{StringValue: "test"}, 398 | op: query.Equal, 399 | want: true, // Should match when hitting nil pointer 400 | }, 401 | { 402 | name: "skipped field with dumbql tag", 403 | field: "hidden.anything", // hidden is tagged with dumbql:"-" 404 | value: &query.StringLiteral{StringValue: "test"}, 405 | op: query.Equal, 406 | want: true, // Should match when encountering skipped field 407 | }, 408 | { 409 | name: "nested skipped field with dumbql tag", 410 | field: "contact.emergency.hidden.field", // hidden is tagged with dumbql:"-" 411 | value: &query.StringLiteral{StringValue: "test"}, 412 | op: query.Equal, 413 | want: true, // Should match when encountering skipped field 414 | }, 415 | { 416 | name: "non-existent field in path", 417 | field: "contact.nonexistent.field", 418 | value: &query.StringLiteral{StringValue: "test"}, 419 | op: query.Equal, 420 | want: true, // Should match when field not found 421 | }, 422 | } 423 | 424 | for _, test := range tests { 425 | t.Run(test.name, func(t *testing.T) { 426 | result := matcher.MatchField(target, test.field, test.value, test.op) 427 | assert.Equal(t, test.want, result) 428 | }) 429 | } 430 | } 431 | 432 | func TestStructMatcher_MatchValue(t *testing.T) { 433 | t.Run("string", testMatchValueString) 434 | t.Run("integer", testMatchValueInteger) 435 | t.Run("float", testMatchValueFloat) 436 | t.Run("type mismatch", testMatchValueTypeMismatch) 437 | } 438 | 439 | func testMatchValueString(t *testing.T) { //nolint:funlen 440 | matcher := &match.StructMatcher{} 441 | tests := []struct { 442 | name string 443 | target any 444 | value query.Valuer 445 | op query.FieldOperator 446 | want bool 447 | }{ 448 | { 449 | name: "equal - match", 450 | target: "hello", 451 | value: &query.StringLiteral{StringValue: "hello"}, 452 | op: query.Equal, 453 | want: true, 454 | }, 455 | { 456 | name: "equal - no match", 457 | target: "hello", 458 | value: &query.StringLiteral{StringValue: "world"}, 459 | op: query.Equal, 460 | want: false, 461 | }, 462 | { 463 | name: "not equal - match", 464 | target: "hello", 465 | value: &query.StringLiteral{StringValue: "world"}, 466 | op: query.NotEqual, 467 | want: true, 468 | }, 469 | { 470 | name: "not equal - no match", 471 | target: "hello", 472 | value: &query.StringLiteral{StringValue: "hello"}, 473 | op: query.NotEqual, 474 | want: false, 475 | }, 476 | { 477 | name: "like - match", 478 | target: "hello world", 479 | value: &query.StringLiteral{StringValue: "world"}, 480 | op: query.Like, 481 | want: true, 482 | }, 483 | { 484 | name: "like - no match", 485 | target: "hello world", 486 | value: &query.StringLiteral{StringValue: "universe"}, 487 | op: query.Like, 488 | want: false, 489 | }, 490 | { 491 | name: "greater than - invalid", 492 | target: "hello", 493 | value: &query.StringLiteral{StringValue: "world"}, 494 | op: query.GreaterThan, 495 | want: false, 496 | }, 497 | { 498 | name: "greater than or equal - invalid", 499 | target: "hello", 500 | value: &query.StringLiteral{StringValue: "world"}, 501 | op: query.GreaterThanOrEqual, 502 | want: false, 503 | }, 504 | { 505 | name: "less than - invalid", 506 | target: "hello", 507 | value: &query.StringLiteral{StringValue: "world"}, 508 | op: query.LessThan, 509 | want: false, 510 | }, 511 | { 512 | name: "less than or equal - invalid", 513 | target: "hello", 514 | value: &query.StringLiteral{StringValue: "world"}, 515 | op: query.LessThanOrEqual, 516 | want: false, 517 | }, 518 | } 519 | 520 | for _, test := range tests { 521 | t.Run(test.name, func(t *testing.T) { 522 | result := matcher.MatchValue(test.target, test.value, test.op) 523 | assert.Equal(t, test.want, result) 524 | }) 525 | } 526 | } 527 | 528 | func testMatchValueInteger(t *testing.T) { //nolint:funlen 529 | matcher := &match.StructMatcher{} 530 | tests := []struct { 531 | name string 532 | target any 533 | value query.Valuer 534 | op query.FieldOperator 535 | want bool 536 | }{ 537 | { 538 | name: "equal - match", 539 | target: int64(42), 540 | value: &query.NumberLiteral{NumberValue: 42}, 541 | op: query.Equal, 542 | want: true, 543 | }, 544 | { 545 | name: "equal - no match", 546 | target: int64(42), 547 | value: &query.NumberLiteral{NumberValue: 24}, 548 | op: query.Equal, 549 | want: false, 550 | }, 551 | { 552 | name: "not equal - match", 553 | target: int64(42), 554 | value: &query.NumberLiteral{NumberValue: 24}, 555 | op: query.NotEqual, 556 | want: true, 557 | }, 558 | { 559 | name: "not equal - no match", 560 | target: int64(42), 561 | value: &query.NumberLiteral{NumberValue: 42}, 562 | op: query.NotEqual, 563 | want: false, 564 | }, 565 | { 566 | name: "greater than - match", 567 | target: int64(42), 568 | value: &query.NumberLiteral{NumberValue: 24}, 569 | op: query.GreaterThan, 570 | want: true, 571 | }, 572 | { 573 | name: "greater than - no match", 574 | target: int64(24), 575 | value: &query.NumberLiteral{NumberValue: 42}, 576 | op: query.GreaterThan, 577 | want: false, 578 | }, 579 | { 580 | name: "greater than or equal - match (greater)", 581 | target: int64(42), 582 | value: &query.NumberLiteral{NumberValue: 24}, 583 | op: query.GreaterThanOrEqual, 584 | want: true, 585 | }, 586 | { 587 | name: "greater than or equal - match (equal)", 588 | target: int64(42), 589 | value: &query.NumberLiteral{NumberValue: 42}, 590 | op: query.GreaterThanOrEqual, 591 | want: true, 592 | }, 593 | { 594 | name: "greater than or equal - no match", 595 | target: int64(24), 596 | value: &query.NumberLiteral{NumberValue: 42}, 597 | op: query.GreaterThanOrEqual, 598 | want: false, 599 | }, 600 | { 601 | name: "less than - match", 602 | target: int64(24), 603 | value: &query.NumberLiteral{NumberValue: 42}, 604 | op: query.LessThan, 605 | want: true, 606 | }, 607 | { 608 | name: "less than - no match", 609 | target: int64(42), 610 | value: &query.NumberLiteral{NumberValue: 24}, 611 | op: query.LessThan, 612 | want: false, 613 | }, 614 | { 615 | name: "less than or equal - match (less)", 616 | target: int64(24), 617 | value: &query.NumberLiteral{NumberValue: 42}, 618 | op: query.LessThanOrEqual, 619 | want: true, 620 | }, 621 | { 622 | name: "less than or equal - match (equal)", 623 | target: int64(42), 624 | value: &query.NumberLiteral{NumberValue: 42}, 625 | op: query.LessThanOrEqual, 626 | want: true, 627 | }, 628 | { 629 | name: "less than or equal - no match", 630 | target: int64(42), 631 | value: &query.NumberLiteral{NumberValue: 24}, 632 | op: query.LessThanOrEqual, 633 | want: false, 634 | }, 635 | { 636 | name: "like - invalid", 637 | target: int64(42), 638 | value: &query.NumberLiteral{NumberValue: 24}, 639 | op: query.Like, 640 | want: false, 641 | }, 642 | } 643 | 644 | for _, test := range tests { 645 | t.Run(test.name, func(t *testing.T) { 646 | result := matcher.MatchValue(test.target, test.value, test.op) 647 | assert.Equal(t, test.want, result) 648 | }) 649 | } 650 | } 651 | 652 | func testMatchValueFloat(t *testing.T) { //nolint:funlen 653 | matcher := &match.StructMatcher{} 654 | tests := []struct { 655 | name string 656 | target any 657 | value query.Valuer 658 | op query.FieldOperator 659 | want bool 660 | }{ 661 | { 662 | name: "equal - match", 663 | target: 3.14, 664 | value: &query.NumberLiteral{NumberValue: 3.14}, 665 | op: query.Equal, 666 | want: true, 667 | }, 668 | { 669 | name: "equal - no match", 670 | target: 3.14, 671 | value: &query.NumberLiteral{NumberValue: 2.718}, 672 | op: query.Equal, 673 | want: false, 674 | }, 675 | { 676 | name: "not equal - match", 677 | target: 3.14, 678 | value: &query.NumberLiteral{NumberValue: 2.718}, 679 | op: query.NotEqual, 680 | want: true, 681 | }, 682 | { 683 | name: "not equal - no match", 684 | target: 3.14, 685 | value: &query.NumberLiteral{NumberValue: 3.14}, 686 | op: query.NotEqual, 687 | want: false, 688 | }, 689 | { 690 | name: "greater than - match", 691 | target: 3.14, 692 | value: &query.NumberLiteral{NumberValue: 2.718}, 693 | op: query.GreaterThan, 694 | want: true, 695 | }, 696 | { 697 | name: "greater than - no match", 698 | target: 2.718, 699 | value: &query.NumberLiteral{NumberValue: 3.14}, 700 | op: query.GreaterThan, 701 | want: false, 702 | }, 703 | { 704 | name: "greater than or equal - match (greater)", 705 | target: 3.14, 706 | value: &query.NumberLiteral{NumberValue: 2.718}, 707 | op: query.GreaterThanOrEqual, 708 | want: true, 709 | }, 710 | { 711 | name: "greater than or equal - match (equal)", 712 | target: 3.14, 713 | value: &query.NumberLiteral{NumberValue: 3.14}, 714 | op: query.GreaterThanOrEqual, 715 | want: true, 716 | }, 717 | { 718 | name: "greater than or equal - no match", 719 | target: 2.718, 720 | value: &query.NumberLiteral{NumberValue: 3.14}, 721 | op: query.GreaterThanOrEqual, 722 | want: false, 723 | }, 724 | { 725 | name: "less than - match", 726 | target: 2.718, 727 | value: &query.NumberLiteral{NumberValue: 3.14}, 728 | op: query.LessThan, 729 | want: true, 730 | }, 731 | { 732 | name: "less than - no match", 733 | target: 3.14, 734 | value: &query.NumberLiteral{NumberValue: 2.718}, 735 | op: query.LessThan, 736 | want: false, 737 | }, 738 | { 739 | name: "less than or equal - match (less)", 740 | target: 2.718, 741 | value: &query.NumberLiteral{NumberValue: 3.14}, 742 | op: query.LessThanOrEqual, 743 | want: true, 744 | }, 745 | { 746 | name: "less than or equal - match (equal)", 747 | target: 3.14, 748 | value: &query.NumberLiteral{NumberValue: 3.14}, 749 | op: query.LessThanOrEqual, 750 | want: true, 751 | }, 752 | { 753 | name: "less than or equal - no match", 754 | target: 3.14, 755 | value: &query.NumberLiteral{NumberValue: 2.718}, 756 | op: query.LessThanOrEqual, 757 | want: false, 758 | }, 759 | { 760 | name: "like - invalid", 761 | target: 3.14, 762 | value: &query.NumberLiteral{NumberValue: 2.718}, 763 | op: query.Like, 764 | want: false, 765 | }, 766 | } 767 | 768 | for _, test := range tests { 769 | t.Run(test.name, func(t *testing.T) { 770 | result := matcher.MatchValue(test.target, test.value, test.op) 771 | assert.Equal(t, test.want, result) 772 | }) 773 | } 774 | } 775 | 776 | func testMatchValueTypeMismatch(t *testing.T) { 777 | matcher := &match.StructMatcher{} 778 | tests := []struct { 779 | name string 780 | target any 781 | value query.Valuer 782 | op query.FieldOperator 783 | want bool 784 | }{ 785 | { 786 | name: "string target with number value", 787 | target: "42", 788 | value: &query.NumberLiteral{NumberValue: 42}, 789 | op: query.Equal, 790 | want: false, 791 | }, 792 | { 793 | name: "integer target with string value", 794 | target: int64(42), 795 | value: &query.StringLiteral{StringValue: "42"}, 796 | op: query.Equal, 797 | want: false, 798 | }, 799 | { 800 | name: "float target with string value", 801 | target: 3.14, 802 | value: &query.StringLiteral{StringValue: "3.14"}, 803 | op: query.Equal, 804 | want: false, 805 | }, 806 | } 807 | 808 | for _, test := range tests { 809 | t.Run(test.name, func(t *testing.T) { 810 | result := matcher.MatchValue(test.target, test.value, test.op) 811 | assert.Equal(t, test.want, result) 812 | }) 813 | } 814 | } 815 | -------------------------------------------------------------------------------- /query/ast.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | sq "github.com/Masterminds/squirrel" 8 | "go.tomakado.io/dumbql/schema" 9 | ) 10 | 11 | //go:generate go run github.com/mna/pigeon@v1.3.0 -optimize-grammar -optimize-parser -o parser.gen.go grammar.peg 12 | 13 | type Expr interface { 14 | fmt.Stringer 15 | sq.Sqlizer 16 | 17 | Match(target any, matcher Matcher) bool 18 | Validate(schema.Schema) (Expr, error) 19 | } 20 | 21 | type Valuer interface { 22 | Value() any 23 | Match(target any, op FieldOperator) bool 24 | } 25 | 26 | // BinaryExpr represents a binary operation (`and`, `or`, `AND`, `OR`) between two expressions. 27 | type BinaryExpr struct { 28 | Left Expr 29 | Op BooleanOperator // `and` or `or` 30 | Right Expr 31 | } 32 | 33 | func (b *BinaryExpr) String() string { 34 | return fmt.Sprintf("(%s %s %s)", b.Op, b.Left, b.Right) 35 | } 36 | 37 | // NotExpr represents a NOT expression. 38 | type NotExpr struct { 39 | Expr Expr 40 | } 41 | 42 | func (n *NotExpr) String() string { 43 | return fmt.Sprintf("(not %s)", n.Expr) 44 | } 45 | 46 | // FieldExpr represents a field query, e.g. status:200. 47 | type FieldExpr struct { 48 | Field Identifier 49 | Op FieldOperator 50 | Value Valuer 51 | } 52 | 53 | func (f *FieldExpr) String() string { 54 | return fmt.Sprintf("(%s %s %v)", f.Op, f.Field, f.Value) 55 | } 56 | 57 | // StringLiteral represents a bare term (a free text search term). 58 | type StringLiteral struct { 59 | StringValue string 60 | } 61 | 62 | func (s *StringLiteral) String() string { return strconv.Quote(s.StringValue) } 63 | func (s *StringLiteral) Value() any { return s.StringValue } 64 | 65 | type NumberLiteral struct { 66 | NumberValue float64 67 | } 68 | 69 | func (n *NumberLiteral) String() string { 70 | // Format with fixed precision for test compatibility 71 | if n.NumberValue == float64(int(n.NumberValue)) { 72 | return fmt.Sprintf("%g", n.NumberValue) 73 | } 74 | return fmt.Sprintf("%.6f", n.NumberValue) 75 | } 76 | func (n *NumberLiteral) Value() any { return n.NumberValue } 77 | 78 | type BoolLiteral struct { 79 | BoolValue bool 80 | } 81 | 82 | func (b *BoolLiteral) String() string { return strconv.FormatBool(b.BoolValue) } 83 | func (b *BoolLiteral) Value() any { return b.BoolValue } 84 | 85 | type Identifier string 86 | 87 | func (i Identifier) Value() any { return string(i) } 88 | func (i Identifier) String() string { return string(i) } 89 | 90 | type OneOfExpr struct { 91 | Values []Valuer 92 | } 93 | 94 | func (o *OneOfExpr) String() string { return fmt.Sprintf("%v", o.Values) } 95 | 96 | func (o *OneOfExpr) Value() any { 97 | vals := make([]any, 0, len(o.Values)) 98 | 99 | for _, v := range o.Values { 100 | vals = append(vals, v.Value()) 101 | } 102 | 103 | return vals 104 | } 105 | 106 | type BooleanOperator uint8 107 | 108 | const ( 109 | And BooleanOperator = iota + 1 110 | Or 111 | ) 112 | 113 | func (c BooleanOperator) String() string { 114 | switch c { 115 | case And: 116 | return "and" 117 | case Or: 118 | return "or" 119 | default: 120 | return "unknown!" 121 | } 122 | } 123 | 124 | type FieldOperator uint8 125 | 126 | const ( 127 | Equal FieldOperator = iota + 1 128 | NotEqual 129 | GreaterThan 130 | GreaterThanOrEqual 131 | LessThan 132 | LessThanOrEqual 133 | Like 134 | Exists 135 | ) 136 | 137 | func (c FieldOperator) String() string { 138 | switch c { 139 | case Equal: 140 | return "=" 141 | case NotEqual: 142 | return "!=" 143 | case GreaterThan: 144 | return ">" 145 | case GreaterThanOrEqual: 146 | return ">=" 147 | case LessThan: 148 | return "<" 149 | case LessThanOrEqual: 150 | return "<=" 151 | case Like: 152 | return "~" 153 | case Exists: 154 | return "exists" 155 | default: 156 | return "unknown!" 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /query/ast_test.go: -------------------------------------------------------------------------------- 1 | package query_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "go.tomakado.io/dumbql/query" 8 | ) 9 | 10 | func TestLiteralValues(t *testing.T) { 11 | t.Run("StringLiteral.Value", func(t *testing.T) { 12 | sl := &query.StringLiteral{StringValue: "hello"} 13 | assert.Equal(t, "hello", sl.Value()) 14 | }) 15 | 16 | t.Run("NumberLiteral.Value", func(t *testing.T) { 17 | nl := &query.NumberLiteral{NumberValue: 42.5} 18 | assert.InDelta(t, 42.5, nl.Value(), 0.0001) 19 | }) 20 | 21 | t.Run("BoolLiteral.Value", func(t *testing.T) { 22 | bl1 := &query.BoolLiteral{BoolValue: true} 23 | assert.Equal(t, true, bl1.Value()) 24 | 25 | bl2 := &query.BoolLiteral{BoolValue: false} 26 | assert.Equal(t, false, bl2.Value()) 27 | }) 28 | 29 | t.Run("Identifier.Value", func(t *testing.T) { 30 | id := query.Identifier("field") 31 | assert.Equal(t, "field", id.Value()) 32 | }) 33 | } 34 | 35 | // Test String methods for operators 36 | func TestOperatorString(t *testing.T) { 37 | t.Run("BooleanOperator.String", func(t *testing.T) { 38 | // Test valid operators 39 | assert.Equal(t, "and", query.And.String()) 40 | assert.Equal(t, "or", query.Or.String()) 41 | 42 | // Test invalid operator (default case) 43 | type CustomBoolOp query.BooleanOperator 44 | invalidOp := query.BooleanOperator(CustomBoolOp(255)) // Invalid operator (max uint8) 45 | assert.Equal(t, "unknown!", invalidOp.String()) 46 | }) 47 | 48 | t.Run("FieldOperator.String", func(t *testing.T) { 49 | // Test all valid operators 50 | assert.Equal(t, "=", query.Equal.String()) 51 | assert.Equal(t, "!=", query.NotEqual.String()) 52 | assert.Equal(t, ">", query.GreaterThan.String()) 53 | assert.Equal(t, ">=", query.GreaterThanOrEqual.String()) 54 | assert.Equal(t, "<", query.LessThan.String()) 55 | assert.Equal(t, "<=", query.LessThanOrEqual.String()) 56 | assert.Equal(t, "~", query.Like.String()) 57 | assert.Equal(t, "exists", query.Exists.String()) 58 | 59 | // Test invalid operator (default case) 60 | type CustomFieldOp query.FieldOperator 61 | invalidOp := query.FieldOperator(CustomFieldOp(255)) // Invalid operator (max uint8) 62 | assert.Equal(t, "unknown!", invalidOp.String()) 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /query/grammar.peg: -------------------------------------------------------------------------------- 1 | { 2 | package query 3 | } 4 | 5 | Expr <- _ e:OrExpr _ { return e, nil } 6 | OrExpr <- left:AndExpr rest:(_ ( OrOp ) _ AndExpr)* { return parseBooleanExpression(left, rest) } 7 | OrOp <- ("OR" / "or") 8 | AndExpr <- left:NotExpr rest:(_ ( op:AndOp ) _ NotExpr)* { return parseBooleanExpression(left, rest) } 9 | AndOp <- ("AND" / "and") 10 | NotExpr <- ("NOT" / "not") _ expr:Primary { return &NotExpr{Expr: expr.(Expr)}, nil } 11 | / Primary 12 | Primary <- ParenExpr / ExistsExpr / FieldExpr / BoolFieldExpr 13 | ParenExpr <- '(' _ expr:Expr _ ')' { return expr.(Expr), nil } 14 | ExistsExpr <- field:Identifier _ ExistsOp { return parseExistsExpression(field) } 15 | ExistsOp <- ("EXISTS" / "exists" / "?") 16 | FieldExpr <- field:Identifier _ op:CmpOp _ value:Value { return parseFieldExpression(field, op, value) } 17 | BoolFieldExpr <- field:Identifier { return parseBoolFieldExpr(field) } 18 | Value <- OneOfExpr / String / Number / Boolean / Identifier 19 | OneOfValue <- String / Number / Boolean / Identifier 20 | Identifier <- AlphaNumeric ("." AlphaNumeric)* { return Identifier(c.text), nil } 21 | AlphaNumeric <- [a-zA-Z_][a-zA-Z0-9_]* 22 | Integer <- '0' / NonZeroDecimalDigit DecimalDigit* 23 | Number <- '-'? Integer ( '.' DecimalDigit+ )? { return parseNumber(c) } 24 | DecimalDigit <- [0-9] 25 | NonZeroDecimalDigit <- [1-9] 26 | String <- '"' StringValue '"' { return parseString(c) } 27 | StringValue <- ( !EscapedChar . / '\\' EscapeSequence )* 28 | EscapedChar <- [\x00-\x1f"\\] 29 | EscapeSequence <- SingleCharEscape / UnicodeEscape 30 | SingleCharEscape <- ["\\/bfnrt] 31 | UnicodeEscape <- 'u' HexDigit HexDigit HexDigit HexDigit 32 | HexDigit <- [0-9a-f]i 33 | Boolean <- ("true" / "false") { return parseBool(c) } 34 | CmpOp <- ( ">=" / ">" / "<=" / "<" / "!:" / "!=" / ":" / "=" / "~" ) 35 | OneOfExpr <- '[' _ values:(OneOfValues)? _ ']' { return parseOneOfExpression(values) } 36 | OneOfValues <- head:OneOfValue tail:(_ ',' _ OneOfValue)* { return parseOneOfValues(head, tail) } 37 | _ <- [ \t\r\n]* 38 | -------------------------------------------------------------------------------- /query/match.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type Matcher interface { 8 | MatchAnd(target any, left, right Expr) bool 9 | MatchOr(target any, left, right Expr) bool 10 | MatchNot(target any, expr Expr) bool 11 | MatchField(target any, field string, value Valuer, op FieldOperator) bool 12 | MatchValue(target any, value Valuer, op FieldOperator) bool 13 | } 14 | 15 | func (b *BinaryExpr) Match(target any, matcher Matcher) bool { 16 | switch b.Op { 17 | case And: 18 | return matcher.MatchAnd(target, b.Left, b.Right) 19 | case Or: 20 | return matcher.MatchOr(target, b.Left, b.Right) 21 | default: 22 | return false 23 | } 24 | } 25 | 26 | func (n *NotExpr) Match(target any, matcher Matcher) bool { 27 | return matcher.MatchNot(target, n.Expr) 28 | } 29 | 30 | func (f *FieldExpr) Match(target any, matcher Matcher) bool { 31 | return matcher.MatchField(target, f.Field.String(), f.Value, f.Op) 32 | } 33 | 34 | func (s *StringLiteral) Match(target any, op FieldOperator) bool { 35 | str, ok := target.(string) 36 | if !ok { 37 | return false 38 | } 39 | 40 | return matchString(str, s.StringValue, op) 41 | } 42 | 43 | func (n *NumberLiteral) Match(target any, op FieldOperator) bool { 44 | // Convert target to float64 regardless of its type 45 | targetFloat, ok := convertToFloat64(target) 46 | if !ok { 47 | return false 48 | } 49 | 50 | return matchNum(targetFloat, n.NumberValue, op) 51 | } 52 | 53 | func (b *BoolLiteral) Match(target any, op FieldOperator) bool { 54 | targetBool, ok := target.(bool) 55 | if !ok { 56 | return false 57 | } 58 | 59 | return matchBool(targetBool, b.BoolValue, op) 60 | } 61 | 62 | // convertToFloat64 converts any numeric type to float64 63 | func convertToFloat64(v any) (float64, bool) { //nolint:cyclop 64 | switch val := v.(type) { 65 | case float64: 66 | return val, true 67 | case float32: 68 | return float64(val), true 69 | case int: 70 | return float64(val), true 71 | case int8: 72 | return float64(val), true 73 | case int16: 74 | return float64(val), true 75 | case int32: 76 | return float64(val), true 77 | case int64: 78 | return float64(val), true 79 | case uint: 80 | return float64(val), true 81 | case uint8: 82 | return float64(val), true 83 | case uint16: 84 | return float64(val), true 85 | case uint32: 86 | return float64(val), true 87 | case uint64: 88 | return float64(val), true 89 | default: 90 | return 0, false 91 | } 92 | } 93 | 94 | func (i Identifier) Match(target any, op FieldOperator) bool { 95 | str, ok := target.(string) 96 | if !ok { 97 | return false 98 | } 99 | 100 | return matchString(str, i.String(), op) 101 | } 102 | 103 | func (o *OneOfExpr) Match(target any, op FieldOperator) bool { 104 | switch op { //nolint:exhaustive 105 | case Equal, Like: 106 | for _, v := range o.Values { 107 | if v.Match(target, op) { 108 | return true 109 | } 110 | } 111 | 112 | return false 113 | 114 | default: 115 | return false 116 | } 117 | } 118 | 119 | func matchString(a, b string, op FieldOperator) bool { 120 | switch op { //nolint:exhaustive 121 | case Equal: 122 | return a == b 123 | case NotEqual: 124 | return a != b 125 | case Like: 126 | return strings.Contains(a, b) 127 | default: 128 | return false 129 | } 130 | } 131 | 132 | func matchNum(a, b float64, op FieldOperator) bool { 133 | switch op { //nolint:exhaustive 134 | case Equal: 135 | return a == b 136 | case NotEqual: 137 | return a != b 138 | case GreaterThan: 139 | return a > b 140 | case GreaterThanOrEqual: 141 | return a >= b 142 | case LessThan: 143 | return a < b 144 | case LessThanOrEqual: 145 | return a <= b 146 | default: 147 | return false 148 | } 149 | } 150 | 151 | func matchBool(a, b bool, op FieldOperator) bool { 152 | switch op { //nolint:exhaustive 153 | case Equal: 154 | return a == b 155 | case NotEqual: 156 | return a != b 157 | default: 158 | return false 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /query/match_internal_test.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestConvertToFloat64(t *testing.T) { //nolint:funlen 10 | tests := []struct { 11 | name string 12 | input any 13 | want float64 14 | wantOk bool 15 | }{ 16 | { 17 | name: "float64", 18 | input: float64(42.5), 19 | want: 42.5, 20 | wantOk: true, 21 | }, 22 | { 23 | name: "float32", 24 | input: float32(42.5), 25 | want: 42.5, 26 | wantOk: true, 27 | }, 28 | { 29 | name: "int", 30 | input: int(42), 31 | want: 42.0, 32 | wantOk: true, 33 | }, 34 | { 35 | name: "int8", 36 | input: int8(42), 37 | want: 42.0, 38 | wantOk: true, 39 | }, 40 | { 41 | name: "int16", 42 | input: int16(42), 43 | want: 42.0, 44 | wantOk: true, 45 | }, 46 | { 47 | name: "int32", 48 | input: int32(42), 49 | want: 42.0, 50 | wantOk: true, 51 | }, 52 | { 53 | name: "int64", 54 | input: int64(42), 55 | want: 42.0, 56 | wantOk: true, 57 | }, 58 | { 59 | name: "uint", 60 | input: uint(42), 61 | want: 42.0, 62 | wantOk: true, 63 | }, 64 | { 65 | name: "uint8", 66 | input: uint8(42), 67 | want: 42.0, 68 | wantOk: true, 69 | }, 70 | { 71 | name: "uint16", 72 | input: uint16(42), 73 | want: 42.0, 74 | wantOk: true, 75 | }, 76 | { 77 | name: "uint32", 78 | input: uint32(42), 79 | want: 42.0, 80 | wantOk: true, 81 | }, 82 | { 83 | name: "uint64", 84 | input: uint64(42), 85 | want: 42.0, 86 | wantOk: true, 87 | }, 88 | { 89 | name: "string - not convertible", 90 | input: "42", 91 | want: 0.0, 92 | wantOk: false, 93 | }, 94 | { 95 | name: "bool - not convertible", 96 | input: true, 97 | want: 0.0, 98 | wantOk: false, 99 | }, 100 | { 101 | name: "nil - not convertible", 102 | input: nil, 103 | want: 0.0, 104 | wantOk: false, 105 | }, 106 | } 107 | 108 | for _, tt := range tests { 109 | t.Run(tt.name, func(t *testing.T) { 110 | got, ok := convertToFloat64(tt.input) 111 | assert.Equal(t, tt.wantOk, ok) 112 | if tt.wantOk { 113 | assert.InDelta(t, tt.want, got, 0.0001) 114 | } 115 | }) 116 | } 117 | } 118 | 119 | func TestNumberLiteral_Match_TypeConversion(t *testing.T) { //nolint:funlen 120 | tests := []struct { 121 | name string 122 | literal *NumberLiteral 123 | target any 124 | operator FieldOperator 125 | want bool 126 | }{ 127 | { 128 | name: "float64 equal int64", 129 | literal: &NumberLiteral{NumberValue: 42.0}, 130 | target: int64(42), 131 | operator: Equal, 132 | want: true, 133 | }, 134 | { 135 | name: "float64 not equal int64", 136 | literal: &NumberLiteral{NumberValue: 42.0}, 137 | target: int64(43), 138 | operator: Equal, 139 | want: false, 140 | }, 141 | { 142 | name: "float64 greater than int64", 143 | literal: &NumberLiteral{NumberValue: 43.0}, 144 | target: int64(42), 145 | operator: GreaterThan, 146 | want: false, // target is not greater than literal 147 | }, 148 | { 149 | name: "float64 less than int64", 150 | literal: &NumberLiteral{NumberValue: 41.0}, 151 | target: int64(42), 152 | operator: LessThan, 153 | want: false, // target is not less than literal 154 | }, 155 | { 156 | name: "float64 greater than or equal int64 (equal)", 157 | literal: &NumberLiteral{NumberValue: 42.0}, 158 | target: int64(42), 159 | operator: GreaterThanOrEqual, 160 | want: true, 161 | }, 162 | { 163 | name: "float64 less than or equal int64 (equal)", 164 | literal: &NumberLiteral{NumberValue: 42.0}, 165 | target: int64(42), 166 | operator: LessThanOrEqual, 167 | want: true, 168 | }, 169 | { 170 | name: "float64 with int", 171 | literal: &NumberLiteral{NumberValue: 42.0}, 172 | target: 42, 173 | operator: Equal, 174 | want: true, 175 | }, 176 | { 177 | name: "float64 with float32", 178 | literal: &NumberLiteral{NumberValue: 42.0}, 179 | target: float32(42.0), 180 | operator: Equal, 181 | want: true, 182 | }, 183 | { 184 | name: "float64 with uint64", 185 | literal: &NumberLiteral{NumberValue: 42.0}, 186 | target: uint64(42), 187 | operator: Equal, 188 | want: true, 189 | }, 190 | { 191 | name: "float64 with non-numeric type", 192 | literal: &NumberLiteral{NumberValue: 42.0}, 193 | target: "42", 194 | operator: Equal, 195 | want: false, 196 | }, 197 | { 198 | name: "float64 with invalid operator", 199 | literal: &NumberLiteral{NumberValue: 42.0}, 200 | target: int64(42), 201 | operator: Like, 202 | want: false, 203 | }, 204 | } 205 | 206 | for _, tt := range tests { 207 | t.Run(tt.name, func(t *testing.T) { 208 | got := tt.literal.Match(tt.target, tt.operator) 209 | assert.Equal(t, tt.want, got) 210 | }) 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /query/match_test.go: -------------------------------------------------------------------------------- 1 | package query_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | "go.tomakado.io/dumbql/match" 9 | "go.tomakado.io/dumbql/query" 10 | ) 11 | 12 | type person struct { 13 | Name string `dumbql:"name"` 14 | Nickname string `dumbql:"nickname"` 15 | Age int64 `dumbql:"age"` 16 | Height float64 `dumbql:"height"` 17 | IsMember bool `dumbql:"is_member"` 18 | } 19 | 20 | func TestBinaryExpr_Match(t *testing.T) { //nolint:funlen 21 | target := person{Name: "John", Age: 30} 22 | matcher := &match.StructMatcher{} 23 | 24 | tests := []struct { 25 | name string 26 | expr *query.BinaryExpr 27 | want bool 28 | }{ 29 | { 30 | name: "AND - both true", 31 | expr: &query.BinaryExpr{ 32 | Left: &query.FieldExpr{ 33 | Field: "name", 34 | Op: query.Equal, 35 | Value: &query.StringLiteral{StringValue: "John"}, 36 | }, 37 | Op: query.And, 38 | Right: &query.FieldExpr{ 39 | Field: "age", 40 | Op: query.Equal, 41 | Value: &query.NumberLiteral{NumberValue: 30}, 42 | }, 43 | }, 44 | want: true, 45 | }, 46 | { 47 | name: "AND - left false", 48 | expr: &query.BinaryExpr{ 49 | Left: &query.FieldExpr{ 50 | Field: "name", 51 | Op: query.Equal, 52 | Value: &query.StringLiteral{StringValue: "Jane"}, 53 | }, 54 | Op: query.And, 55 | Right: &query.FieldExpr{ 56 | Field: "age", 57 | Op: query.Equal, 58 | Value: &query.NumberLiteral{NumberValue: 30}, 59 | }, 60 | }, 61 | want: false, 62 | }, 63 | { 64 | name: "OR - both true", 65 | expr: &query.BinaryExpr{ 66 | Left: &query.FieldExpr{ 67 | Field: "name", 68 | Op: query.Equal, 69 | Value: &query.StringLiteral{StringValue: "John"}, 70 | }, 71 | Op: query.Or, 72 | Right: &query.FieldExpr{ 73 | Field: "age", 74 | Op: query.Equal, 75 | Value: &query.NumberLiteral{NumberValue: 30}, 76 | }, 77 | }, 78 | want: true, 79 | }, 80 | { 81 | name: "OR - one true", 82 | expr: &query.BinaryExpr{ 83 | Left: &query.FieldExpr{ 84 | Field: "name", 85 | Op: query.Equal, 86 | Value: &query.StringLiteral{StringValue: "John"}, 87 | }, 88 | Op: query.Or, 89 | Right: &query.FieldExpr{ 90 | Field: "age", 91 | Op: query.Equal, 92 | Value: &query.NumberLiteral{NumberValue: 25}, 93 | }, 94 | }, 95 | want: true, 96 | }, 97 | { 98 | name: "OR - both false", 99 | expr: &query.BinaryExpr{ 100 | Left: &query.FieldExpr{ 101 | Field: "name", 102 | Op: query.Equal, 103 | Value: &query.StringLiteral{StringValue: "Jane"}, 104 | }, 105 | Op: query.Or, 106 | Right: &query.FieldExpr{ 107 | Field: "age", 108 | Op: query.Equal, 109 | Value: &query.NumberLiteral{NumberValue: 25}, 110 | }, 111 | }, 112 | want: false, 113 | }, 114 | } 115 | 116 | for _, test := range tests { 117 | t.Run(test.name, func(t *testing.T) { 118 | result := test.expr.Match(target, matcher) 119 | assert.Equal(t, test.want, result) 120 | }) 121 | } 122 | } 123 | 124 | func TestNotExpr_Match(t *testing.T) { 125 | target := person{Name: "John", Age: 30} 126 | matcher := &match.StructMatcher{} 127 | 128 | tests := []struct { 129 | name string 130 | expr *query.NotExpr 131 | want bool 132 | }{ 133 | { 134 | name: "negate true condition", 135 | expr: &query.NotExpr{ 136 | Expr: &query.FieldExpr{ 137 | Field: "name", 138 | Op: query.Equal, 139 | Value: &query.StringLiteral{StringValue: "John"}, 140 | }, 141 | }, 142 | want: false, 143 | }, 144 | { 145 | name: "negate false condition", 146 | expr: &query.NotExpr{ 147 | Expr: &query.FieldExpr{ 148 | Field: "name", 149 | Op: query.Equal, 150 | Value: &query.StringLiteral{StringValue: "Jane"}, 151 | }, 152 | }, 153 | want: true, 154 | }, 155 | { 156 | name: "negate AND expression", 157 | expr: &query.NotExpr{ 158 | Expr: &query.BinaryExpr{ 159 | Left: &query.FieldExpr{ 160 | Field: "name", 161 | Op: query.Equal, 162 | Value: &query.StringLiteral{StringValue: "John"}, 163 | }, 164 | Op: query.And, 165 | Right: &query.FieldExpr{ 166 | Field: "age", 167 | Op: query.Equal, 168 | Value: &query.NumberLiteral{NumberValue: 30}, 169 | }, 170 | }, 171 | }, 172 | want: false, 173 | }, 174 | } 175 | 176 | for _, test := range tests { 177 | t.Run(test.name, func(t *testing.T) { 178 | result := test.expr.Match(target, matcher) 179 | assert.Equal(t, test.want, result) 180 | }) 181 | } 182 | } 183 | 184 | func TestFieldExpr_Match(t *testing.T) { //nolint:funlen 185 | target := person{ 186 | Name: "John", 187 | Age: 30, 188 | Height: 1.75, 189 | IsMember: true, 190 | } 191 | matcher := &match.StructMatcher{} 192 | 193 | tests := []struct { 194 | name string 195 | expr *query.FieldExpr 196 | want bool 197 | }{ 198 | { 199 | name: "string equal - match", 200 | expr: &query.FieldExpr{ 201 | Field: "name", 202 | Op: query.Equal, 203 | Value: &query.StringLiteral{StringValue: "John"}, 204 | }, 205 | want: true, 206 | }, 207 | { 208 | name: "string not equal - match", 209 | expr: &query.FieldExpr{ 210 | Field: "name", 211 | Op: query.NotEqual, 212 | Value: &query.StringLiteral{StringValue: "Jane"}, 213 | }, 214 | want: true, 215 | }, 216 | { 217 | name: "integer greater than - match", 218 | expr: &query.FieldExpr{ 219 | Field: "age", 220 | Op: query.GreaterThan, 221 | Value: &query.NumberLiteral{NumberValue: 25}, 222 | }, 223 | want: true, 224 | }, 225 | { 226 | name: "float less than - match", 227 | expr: &query.FieldExpr{ 228 | Field: "height", 229 | Op: query.LessThan, 230 | Value: &query.NumberLiteral{NumberValue: 1.80}, 231 | }, 232 | want: true, 233 | }, 234 | { 235 | name: "boolean equal - match", 236 | expr: &query.FieldExpr{ 237 | Field: "is_member", 238 | Op: query.Equal, 239 | Value: &query.BoolLiteral{BoolValue: true}, 240 | }, 241 | want: true, 242 | }, 243 | { 244 | name: "boolean equal - no match", 245 | expr: &query.FieldExpr{ 246 | Field: "is_member", 247 | Op: query.Equal, 248 | Value: &query.BoolLiteral{BoolValue: false}, 249 | }, 250 | want: false, 251 | }, 252 | { 253 | name: "boolean not equal - match", 254 | expr: &query.FieldExpr{ 255 | Field: "is_member", 256 | Op: query.NotEqual, 257 | Value: &query.BoolLiteral{BoolValue: false}, 258 | }, 259 | want: true, 260 | }, 261 | { 262 | name: "non-existent field", 263 | expr: &query.FieldExpr{ 264 | Field: "invalid", 265 | Op: query.Equal, 266 | Value: &query.StringLiteral{StringValue: "test"}, 267 | }, 268 | want: true, 269 | }, 270 | { 271 | name: "wrong case field", 272 | expr: &query.FieldExpr{ 273 | Field: "Is_Member", // Wrong case compared to the tag 274 | Op: query.Equal, 275 | Value: &query.BoolLiteral{BoolValue: true}, 276 | }, 277 | want: true, // Non-existent fields return true by default 278 | }, 279 | { 280 | name: "type mismatch", 281 | expr: &query.FieldExpr{ 282 | Field: "age", 283 | Op: query.Equal, 284 | Value: &query.StringLiteral{StringValue: "30"}, 285 | }, 286 | want: false, 287 | }, 288 | { 289 | name: "field exists - match", 290 | expr: &query.FieldExpr{ 291 | Field: "name", 292 | Op: query.Exists, 293 | Value: nil, 294 | }, 295 | want: true, 296 | }, 297 | { 298 | name: "field exists with non-zero value - match", 299 | expr: &query.FieldExpr{ 300 | Field: "age", 301 | Op: query.Exists, 302 | Value: nil, 303 | }, 304 | want: true, 305 | }, 306 | { 307 | name: "field exists with zero value - no match", 308 | expr: &query.FieldExpr{ 309 | Field: "nickname", 310 | Op: query.Exists, 311 | Value: nil, 312 | }, 313 | want: false, 314 | }, 315 | { 316 | name: "non-existent field with exists - no match", 317 | expr: &query.FieldExpr{ 318 | Field: "invalid", 319 | Op: query.Exists, 320 | Value: nil, 321 | }, 322 | want: false, 323 | }, 324 | { 325 | name: "wrong case field with exists - no match", 326 | expr: &query.FieldExpr{ 327 | Field: "Is_Member", // Wrong case compared to the tag 328 | Op: query.Exists, 329 | Value: nil, 330 | }, 331 | want: false, 332 | }, 333 | } 334 | 335 | for _, test := range tests { 336 | t.Run(test.name, func(t *testing.T) { 337 | result := test.expr.Match(target, matcher) 338 | assert.Equal(t, test.want, result) 339 | }) 340 | } 341 | } 342 | 343 | func TestBoolLiteral_Match(t *testing.T) { //nolint:funlen 344 | tests := []struct { 345 | name string 346 | bl *query.BoolLiteral 347 | target any 348 | op query.FieldOperator 349 | want bool 350 | }{ 351 | { 352 | name: "true equal true - match", 353 | bl: &query.BoolLiteral{BoolValue: true}, 354 | target: true, 355 | op: query.Equal, 356 | want: true, 357 | }, 358 | { 359 | name: "false equal false - match", 360 | bl: &query.BoolLiteral{BoolValue: false}, 361 | target: false, 362 | op: query.Equal, 363 | want: true, 364 | }, 365 | { 366 | name: "true equal false - no match", 367 | bl: &query.BoolLiteral{BoolValue: true}, 368 | target: false, 369 | op: query.Equal, 370 | want: false, 371 | }, 372 | { 373 | name: "false equal true - no match", 374 | bl: &query.BoolLiteral{BoolValue: false}, 375 | target: true, 376 | op: query.Equal, 377 | want: false, 378 | }, 379 | { 380 | name: "true not equal false - match", 381 | bl: &query.BoolLiteral{BoolValue: true}, 382 | target: false, 383 | op: query.NotEqual, 384 | want: true, 385 | }, 386 | { 387 | name: "false not equal true - match", 388 | bl: &query.BoolLiteral{BoolValue: false}, 389 | target: true, 390 | op: query.NotEqual, 391 | want: true, 392 | }, 393 | { 394 | name: "true not equal true - no match", 395 | bl: &query.BoolLiteral{BoolValue: true}, 396 | target: true, 397 | op: query.NotEqual, 398 | want: false, 399 | }, 400 | { 401 | name: "with non-bool target", 402 | bl: &query.BoolLiteral{BoolValue: true}, 403 | target: "true", 404 | op: query.Equal, 405 | want: false, 406 | }, 407 | { 408 | name: "with invalid operator", 409 | bl: &query.BoolLiteral{BoolValue: true}, 410 | target: true, 411 | op: query.GreaterThan, 412 | want: false, 413 | }, 414 | } 415 | 416 | for _, test := range tests { 417 | t.Run(test.name, func(t *testing.T) { 418 | result := test.bl.Match(test.target, test.op) 419 | assert.Equal(t, test.want, result) 420 | }) 421 | } 422 | } 423 | 424 | func TestIdentifier_Match(t *testing.T) { //nolint:funlen 425 | tests := []struct { 426 | name string 427 | id query.Identifier 428 | target any 429 | op query.FieldOperator 430 | want bool 431 | }{ 432 | { 433 | name: "equal - match", 434 | id: query.Identifier("test"), 435 | target: "test", 436 | op: query.Equal, 437 | want: true, 438 | }, 439 | { 440 | name: "equal - no match", 441 | id: query.Identifier("test"), 442 | target: "other", 443 | op: query.Equal, 444 | want: false, 445 | }, 446 | { 447 | name: "not equal - match", 448 | id: query.Identifier("test"), 449 | target: "other", 450 | op: query.NotEqual, 451 | want: true, 452 | }, 453 | { 454 | name: "not equal - no match", 455 | id: query.Identifier("test"), 456 | target: "test", 457 | op: query.NotEqual, 458 | want: false, 459 | }, 460 | { 461 | name: "like - match", 462 | id: query.Identifier("world"), 463 | target: "hello world", 464 | op: query.Like, 465 | want: true, 466 | }, 467 | { 468 | name: "like - no match", 469 | id: query.Identifier("universe"), 470 | target: "hello world", 471 | op: query.Like, 472 | want: false, 473 | }, 474 | { 475 | name: "with non-string target", 476 | id: query.Identifier("42"), 477 | target: 42, 478 | op: query.Equal, 479 | want: false, 480 | }, 481 | { 482 | name: "with invalid operator", 483 | id: query.Identifier("test"), 484 | target: "test", 485 | op: query.GreaterThan, 486 | want: false, 487 | }, 488 | } 489 | 490 | for _, test := range tests { 491 | t.Run(test.name, func(t *testing.T) { 492 | result := test.id.Match(test.target, test.op) 493 | assert.Equal(t, test.want, result) 494 | }) 495 | } 496 | } 497 | 498 | func TestOneOfExpr_Match(t *testing.T) { //nolint:funlen 499 | tests := []struct { 500 | name string 501 | expr *query.OneOfExpr 502 | target any 503 | op query.FieldOperator 504 | want bool 505 | }{ 506 | { 507 | name: "string equal - match", 508 | expr: &query.OneOfExpr{ 509 | Values: []query.Valuer{ 510 | &query.StringLiteral{StringValue: "apple"}, 511 | &query.StringLiteral{StringValue: "banana"}, 512 | &query.StringLiteral{StringValue: "orange"}, 513 | }, 514 | }, 515 | target: "banana", 516 | op: query.Equal, 517 | want: true, 518 | }, 519 | { 520 | name: "string equal - no match", 521 | expr: &query.OneOfExpr{ 522 | Values: []query.Valuer{ 523 | &query.StringLiteral{StringValue: "apple"}, 524 | &query.StringLiteral{StringValue: "banana"}, 525 | &query.StringLiteral{StringValue: "orange"}, 526 | }, 527 | }, 528 | target: "grape", 529 | op: query.Equal, 530 | want: false, 531 | }, 532 | { 533 | name: "integer equal - match", 534 | expr: &query.OneOfExpr{ 535 | Values: []query.Valuer{ 536 | &query.NumberLiteral{NumberValue: 1}, 537 | &query.NumberLiteral{NumberValue: 2}, 538 | &query.NumberLiteral{NumberValue: 3}, 539 | }, 540 | }, 541 | target: int64(2), 542 | op: query.Equal, 543 | want: true, 544 | }, 545 | { 546 | name: "integer equal - no match", 547 | expr: &query.OneOfExpr{ 548 | Values: []query.Valuer{ 549 | &query.NumberLiteral{NumberValue: 1}, 550 | &query.NumberLiteral{NumberValue: 2}, 551 | &query.NumberLiteral{NumberValue: 3}, 552 | }, 553 | }, 554 | target: int64(4), 555 | op: query.Equal, 556 | want: false, 557 | }, 558 | { 559 | name: "float equal - match", 560 | expr: &query.OneOfExpr{ 561 | Values: []query.Valuer{ 562 | &query.NumberLiteral{NumberValue: 1.1}, 563 | &query.NumberLiteral{NumberValue: 2.2}, 564 | &query.NumberLiteral{NumberValue: 3.3}, 565 | }, 566 | }, 567 | target: 2.2, 568 | op: query.Equal, 569 | want: true, 570 | }, 571 | { 572 | name: "float equal - no match", 573 | expr: &query.OneOfExpr{ 574 | Values: []query.Valuer{ 575 | &query.NumberLiteral{NumberValue: 1.1}, 576 | &query.NumberLiteral{NumberValue: 2.2}, 577 | &query.NumberLiteral{NumberValue: 3.3}, 578 | }, 579 | }, 580 | target: 4.4, 581 | op: query.Equal, 582 | want: false, 583 | }, 584 | { 585 | name: "mixed types", 586 | expr: &query.OneOfExpr{ 587 | Values: []query.Valuer{ 588 | &query.StringLiteral{StringValue: "one"}, 589 | &query.NumberLiteral{NumberValue: 2}, 590 | &query.NumberLiteral{NumberValue: 3.3}, 591 | }, 592 | }, 593 | target: "one", 594 | op: query.Equal, 595 | want: true, 596 | }, 597 | { 598 | name: "boolean equal - match", 599 | expr: &query.OneOfExpr{ 600 | Values: []query.Valuer{ 601 | &query.BoolLiteral{BoolValue: true}, 602 | &query.BoolLiteral{BoolValue: false}, 603 | }, 604 | }, 605 | target: true, 606 | op: query.Equal, 607 | want: true, 608 | }, 609 | { 610 | name: "boolean equal - no match", 611 | expr: &query.OneOfExpr{ 612 | Values: []query.Valuer{ 613 | &query.BoolLiteral{BoolValue: false}, 614 | }, 615 | }, 616 | target: true, 617 | op: query.Equal, 618 | want: false, 619 | }, 620 | { 621 | name: "mixed types with boolean", 622 | expr: &query.OneOfExpr{ 623 | Values: []query.Valuer{ 624 | &query.StringLiteral{StringValue: "one"}, 625 | &query.NumberLiteral{NumberValue: 2}, 626 | &query.BoolLiteral{BoolValue: true}, 627 | }, 628 | }, 629 | target: true, 630 | op: query.Equal, 631 | want: true, 632 | }, 633 | { 634 | name: "empty values", 635 | expr: &query.OneOfExpr{ 636 | Values: []query.Valuer{}, 637 | }, 638 | target: "test", 639 | op: query.Equal, 640 | want: false, 641 | }, 642 | { 643 | name: "nil values", 644 | expr: &query.OneOfExpr{ 645 | Values: nil, 646 | }, 647 | target: "test", 648 | op: query.Equal, 649 | want: false, 650 | }, 651 | { 652 | name: "string like - match", 653 | expr: &query.OneOfExpr{ 654 | Values: []query.Valuer{ 655 | &query.StringLiteral{StringValue: "world"}, 656 | &query.StringLiteral{StringValue: "universe"}, 657 | }, 658 | }, 659 | target: "hello world", 660 | op: query.Like, 661 | want: true, 662 | }, 663 | { 664 | name: "invalid operator", 665 | expr: &query.OneOfExpr{ 666 | Values: []query.Valuer{ 667 | &query.StringLiteral{StringValue: "test"}, 668 | }, 669 | }, 670 | target: "test", 671 | op: query.GreaterThan, 672 | want: false, 673 | }, 674 | { 675 | name: "type mismatch", 676 | expr: &query.OneOfExpr{ 677 | Values: []query.Valuer{ 678 | &query.StringLiteral{StringValue: "42"}, 679 | }, 680 | }, 681 | target: 42, 682 | op: query.Equal, 683 | want: false, 684 | }, 685 | } 686 | 687 | for _, test := range tests { 688 | t.Run(test.name, func(t *testing.T) { 689 | result := test.expr.Match(test.target, test.op) 690 | assert.Equal(t, test.want, result) 691 | }) 692 | } 693 | } 694 | 695 | func TestFieldPresenceWithZeroValues(t *testing.T) { //nolint:funlen 696 | type Record struct { 697 | ID int64 `dumbql:"id"` 698 | Name string `dumbql:"name"` 699 | Description string `dumbql:"description"` // Zero value 700 | Count int64 `dumbql:"count"` // Zero value 701 | IsActive bool `dumbql:"is_active"` // Zero value 702 | Amount float64 `dumbql:"amount"` // Zero value 703 | } 704 | 705 | matcher := &match.StructMatcher{} 706 | record := &Record{ 707 | ID: 1, 708 | Name: "Test Record", 709 | Description: "", // Zero value string 710 | Count: 0, // Zero value int64 711 | IsActive: false, // Zero value bool 712 | Amount: 0.0, // Zero value float64 713 | } 714 | 715 | tests := []struct { 716 | name string 717 | query string 718 | want bool 719 | }{ 720 | { 721 | name: "non-zero field", 722 | query: `id?`, 723 | want: true, 724 | }, 725 | { 726 | name: "another non-zero field", 727 | query: `name?`, 728 | want: true, 729 | }, 730 | { 731 | name: "zero string field", 732 | query: `description?`, 733 | want: false, 734 | }, 735 | { 736 | name: "zero int field", 737 | query: `count?`, 738 | want: false, 739 | }, 740 | { 741 | name: "zero bool field", 742 | query: `is_active?`, 743 | want: false, 744 | }, 745 | { 746 | name: "zero float field", 747 | query: `amount?`, 748 | want: false, 749 | }, 750 | { 751 | name: "non-existent field", 752 | query: `unknown?`, 753 | want: false, 754 | }, 755 | { 756 | name: "complex query with exists operator", 757 | query: `id? and description?`, 758 | want: false, 759 | }, 760 | } 761 | 762 | for _, test := range tests { 763 | t.Run(test.name, func(t *testing.T) { 764 | ast, err := query.Parse("test", []byte(test.query)) 765 | require.NoError(t, err) 766 | expr := ast.(query.Expr) 767 | 768 | got := expr.Match(record, matcher) 769 | assert.Equal(t, test.want, got) 770 | }) 771 | } 772 | } 773 | 774 | func TestStructFieldOmission(t *testing.T) { //nolint:funlen 775 | type User struct { 776 | ID int64 `dumbql:"id"` 777 | Name string `dumbql:"name"` 778 | Password string `dumbql:"-"` // Should always match 779 | Internal bool `dumbql:"-"` // Should always match 780 | Score float64 `dumbql:"score"` 781 | } 782 | 783 | matcher := &match.StructMatcher{} 784 | user := &User{ 785 | ID: 1, 786 | Name: "John", 787 | Password: "secret123", 788 | Internal: true, 789 | Score: 4.5, 790 | } 791 | 792 | tests := []struct { 793 | name string 794 | query string 795 | want bool 796 | }{ 797 | { 798 | name: "visible field", 799 | query: `id:1`, 800 | want: true, 801 | }, 802 | { 803 | name: "multiple visible fields", 804 | query: `id:1 and name:"John" and score:4.5`, 805 | want: true, 806 | }, 807 | { 808 | name: "omitted field - always true", 809 | query: `password:"wrong_password"`, 810 | want: true, 811 | }, 812 | { 813 | name: "another omitted field - always true", 814 | query: `internal:false`, 815 | want: true, 816 | }, 817 | { 818 | name: "visible and omitted fields", 819 | query: `id:1 and password:"wrong_password"`, 820 | want: true, 821 | }, 822 | { 823 | name: "non-existent field", 824 | query: `unknown:"value"`, 825 | want: true, 826 | }, 827 | { 828 | name: "omitted field with exists operator", 829 | query: `password?`, 830 | want: false, // Field exists should check actual field presence 831 | }, 832 | } 833 | 834 | for _, test := range tests { 835 | t.Run(test.name, func(t *testing.T) { 836 | ast, err := query.Parse("test", []byte(test.query)) 837 | require.NoError(t, err) 838 | expr := ast.(query.Expr) 839 | 840 | got := expr.Match(user, matcher) 841 | assert.Equal(t, test.want, got) 842 | }) 843 | } 844 | } 845 | -------------------------------------------------------------------------------- /query/parser_helpers.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | func resolveBooleanOperator(op any) (BooleanOperator, error) { 9 | switch string(op.([]byte)) { 10 | case "AND", "and": 11 | return And, nil 12 | case "OR", "or": 13 | return Or, nil 14 | default: 15 | return 0, fmt.Errorf("unknown conditional operator %q", op) 16 | } 17 | } 18 | 19 | func resolveFieldOperator(op any) (FieldOperator, error) { 20 | switch string(op.([]byte)) { 21 | case ">=": 22 | return GreaterThanOrEqual, nil 23 | case ">": 24 | return GreaterThan, nil 25 | case "<=": 26 | return LessThanOrEqual, nil 27 | case "<": 28 | return LessThan, nil 29 | case "!:", "!=": 30 | return NotEqual, nil 31 | case ":", "=": 32 | return Equal, nil 33 | case "~": 34 | return Like, nil 35 | default: 36 | return 0, fmt.Errorf("unknown compare operator %q", op) 37 | } 38 | } 39 | 40 | func resolveOneOfValueType(val any) Valuer { 41 | switch v := val.(type) { 42 | case Identifier: 43 | return &StringLiteral{StringValue: string(v)} 44 | case string: 45 | return &StringLiteral{StringValue: v} 46 | default: 47 | return v.(Valuer) 48 | } 49 | } 50 | 51 | func parseBooleanExpression(left, rest any) (any, error) { 52 | expr := left 53 | for _, r := range rest.([]any) { 54 | parts := r.([]any) 55 | // parts[1] holds the operator token, parts[3] holds the next AndExpr. 56 | // op := string(parts[1].([]byte)) 57 | op, err := resolveBooleanOperator(parts[1]) 58 | if err != nil { 59 | return nil, err 60 | } 61 | right := parts[3] 62 | expr = &BinaryExpr{ 63 | Left: expr.(Expr), 64 | Op: op, 65 | Right: right.(Expr), 66 | } 67 | } 68 | return expr, nil 69 | } 70 | 71 | func parseFieldExpression(field, op, value any) (any, error) { 72 | opR, err := resolveFieldOperator(op) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | var val any 78 | switch v := value.(type) { 79 | case []byte: 80 | val = &StringLiteral{StringValue: string(v)} 81 | case string: 82 | val = &StringLiteral{StringValue: v} 83 | case Identifier: 84 | val = &StringLiteral{StringValue: string(v)} 85 | default: 86 | val = value 87 | } 88 | 89 | return &FieldExpr{ 90 | Field: field.(Identifier), 91 | Op: opR, 92 | Value: val.(Valuer), 93 | }, nil 94 | } 95 | 96 | func parseNumber(c *current) (any, error) { 97 | val, err := strconv.ParseFloat(string(c.text), 64) 98 | if err != nil { 99 | return nil, fmt.Errorf("invalid number literal: %q", string(c.text)) 100 | } 101 | 102 | return &NumberLiteral{NumberValue: val}, nil 103 | } 104 | 105 | func parseString(c *current) (any, error) { 106 | val, err := strconv.Unquote(string(c.text)) 107 | if err != nil { 108 | return nil, err 109 | } 110 | return &StringLiteral{StringValue: val}, nil 111 | } 112 | 113 | func parseBool(c *current) (any, error) { 114 | val := string(c.text) 115 | boolVal, err := strconv.ParseBool(val) 116 | if err != nil { 117 | return nil, fmt.Errorf("invalid boolean literal: %q", val) 118 | } 119 | return &BoolLiteral{BoolValue: boolVal}, nil 120 | } 121 | 122 | func parseOneOfExpression(values any) (any, error) { 123 | if values == nil || len(values.([]Valuer)) == 0 { 124 | return &OneOfExpr{Values: nil}, nil 125 | } 126 | 127 | return &OneOfExpr{Values: values.([]Valuer)}, nil 128 | } 129 | 130 | func parseOneOfValues(head, tail any) (any, error) { 131 | vals := []Valuer{resolveOneOfValueType(head)} 132 | 133 | for _, t := range tail.([]any) { 134 | // t is an array where index 3 holds the next Value. 135 | val := resolveOneOfValueType(t.([]any)[3]) 136 | vals = append(vals, val) 137 | } 138 | 139 | return vals, nil 140 | } 141 | 142 | func parseExistsExpression(ident any) (any, error) { 143 | return &FieldExpr{ 144 | Field: ident.(Identifier), 145 | Op: Exists, 146 | Value: &BoolLiteral{BoolValue: true}, 147 | }, nil 148 | } 149 | 150 | // parseBoolFieldExpr handles the shorthand syntax for boolean fields 151 | // where a field name alone is interpreted as field = true 152 | func parseBoolFieldExpr(field any) (any, error) { 153 | // Create a FieldExpr with Equal operator and true value 154 | return &FieldExpr{ 155 | Field: field.(Identifier), 156 | Op: Equal, 157 | Value: &BoolLiteral{BoolValue: true}, 158 | }, nil 159 | } 160 | -------------------------------------------------------------------------------- /query/parser_helpers_test.go: -------------------------------------------------------------------------------- 1 | package query_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | "go.tomakado.io/dumbql/query" 9 | ) 10 | 11 | func TestNumberParsing(t *testing.T) { //nolint:funlen 12 | tests := []struct { 13 | name string 14 | input string 15 | want float64 16 | wantErr bool 17 | }{ 18 | { 19 | name: "positive integer", 20 | input: "field:42", 21 | want: 42.0, 22 | wantErr: false, 23 | }, 24 | { 25 | name: "negative integer", 26 | input: "field:-42", 27 | want: -42.0, 28 | wantErr: false, 29 | }, 30 | { 31 | name: "zero", 32 | input: "field:0", 33 | want: 0.0, 34 | wantErr: false, 35 | }, 36 | { 37 | name: "positive float", 38 | input: "field:3.14159", 39 | want: 3.14159, 40 | wantErr: false, 41 | }, 42 | { 43 | name: "negative float", 44 | input: "field:-3.14159", 45 | want: -3.14159, 46 | wantErr: false, 47 | }, 48 | } 49 | 50 | for _, tt := range tests { 51 | t.Run(tt.name, func(t *testing.T) { 52 | // Since we can't directly test parseNumber in the query_test package, 53 | // we'll use the Parse function to parse a simple query with a number 54 | if tt.wantErr { 55 | _, err := query.Parse("test", []byte(tt.input)) 56 | assert.Error(t, err) 57 | return 58 | } 59 | 60 | result, err := query.Parse("test", []byte(tt.input)) 61 | require.NoError(t, err) 62 | 63 | fieldExpr, ok := result.(*query.FieldExpr) 64 | require.True(t, ok, "Expected *query.FieldExpr, got %T", result) 65 | 66 | numLiteral, ok := fieldExpr.Value.(*query.NumberLiteral) 67 | require.True(t, ok, "Expected *query.NumberLiteral, got %T", fieldExpr.Value) 68 | assert.InDelta(t, tt.want, numLiteral.NumberValue, 0.0001) 69 | }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /query/parser_test.go: -------------------------------------------------------------------------------- 1 | package query_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "go.tomakado.io/dumbql/query" 8 | ) 9 | 10 | func TestParser(t *testing.T) { //nolint:funlen 11 | tests := []struct { 12 | input string 13 | want string 14 | }{ 15 | // Simple field expression. 16 | { 17 | input: "status:200", 18 | want: "(= status 200)", 19 | }, 20 | // Floating-point number. 21 | { 22 | input: "eps<0.003", 23 | want: "(< eps 0.003000)", 24 | }, 25 | // Using <= operator. 26 | { 27 | input: "eps<=0.003", 28 | want: "(<= eps 0.003000)", 29 | }, 30 | // Using >= operator. 31 | { 32 | input: "eps>=0.003", 33 | want: "(>= eps 0.003000)", 34 | }, 35 | // Using > operator. 36 | { 37 | input: "eps>0.003", 38 | want: "(> eps 0.003000)", 39 | }, 40 | // Using not-equals with !: operator. 41 | { 42 | input: "eps!:0.003", 43 | want: "(!= eps 0.003000)", 44 | }, 45 | // Combined with AND. 46 | { 47 | input: "status:200 and eps < 0.003", 48 | want: "(and (= status 200) (< eps 0.003000))", 49 | }, 50 | // Combined with OR. 51 | { 52 | input: "status:200 or eps<0.003", 53 | want: "(or (= status 200) (< eps 0.003000))", 54 | }, 55 | // Mixed operators: AND with not-equals. 56 | { 57 | input: "status:200 and eps!=0.003", 58 | want: "(and (= status 200) (!= eps 0.003000))", 59 | }, 60 | // Nested parentheses. 61 | { 62 | input: "((status:200))", 63 | want: "(= status 200)", 64 | }, 65 | // Extra whitespace. 66 | { 67 | input: " status : 200 and eps < 0.003 ", 68 | want: "(and (= status 200) (< eps 0.003000))", 69 | }, 70 | // Uppercase boolean operator. 71 | { 72 | input: "status:200 AND eps<0.003", 73 | want: "(and (= status 200) (< eps 0.003000))", 74 | }, 75 | // Array literal in a field expression. 76 | { 77 | input: "req.fields.ext:[\"jpg\", \"png\"]", 78 | want: "(= req.fields.ext [\"jpg\" \"png\"])", 79 | }, 80 | // Array with a single element. 81 | { 82 | input: "tags:[\"urgent\"]", 83 | want: "(= tags [\"urgent\"])", 84 | }, 85 | // Empty array literal. 86 | { 87 | input: "tags:[]", 88 | want: "(= tags [])", 89 | }, 90 | // A complex expression combining several constructs. 91 | { 92 | input: "status : 200 and eps < 0.003 and (req.fields.ext:[\"jpg\", \"png\"])", 93 | want: "(and (and (= status 200) (< eps 0.003000)) (= req.fields.ext [\"jpg\" \"png\"]))", 94 | }, 95 | // NOT with parentheses. 96 | { 97 | input: "not (status:200)", 98 | want: "(not (= status 200))", 99 | }, 100 | // Boolean true. 101 | { 102 | input: "enabled:true", 103 | want: "(= enabled true)", 104 | }, 105 | // Boolean false. 106 | { 107 | input: "enabled:false", 108 | want: "(= enabled false)", 109 | }, 110 | // Boolean with not equals operator. 111 | { 112 | input: "enabled!=false", 113 | want: "(!= enabled false)", 114 | }, 115 | // Complex query with boolean. 116 | { 117 | input: "status:200 and enabled:true", 118 | want: "(and (= status 200) (= enabled true))", 119 | }, 120 | // Boolean field shorthand syntax. 121 | { 122 | input: "enabled", 123 | want: "(= enabled true)", 124 | }, 125 | // Multiple boolean field shorthand syntax. 126 | { 127 | input: "enabled and verified", 128 | want: "(and (= enabled true) (= verified true))", 129 | }, 130 | // Boolean field shorthand syntax with other expressions. 131 | { 132 | input: "enabled and status:200", 133 | want: "(and (= enabled true) (= status 200))", 134 | }, 135 | // Boolean field shorthand with NOT. 136 | { 137 | input: "not enabled", 138 | want: "(not (= enabled true))", 139 | }, 140 | // Complex query with boolean shorthand. 141 | { 142 | input: "verified and (status:200 or not enabled)", 143 | want: "(and (= verified true) (or (= status 200) (not (= enabled true))))", 144 | }, 145 | // Field presence operator with ? syntax 146 | { 147 | input: "name?", 148 | want: "(exists name true)", 149 | }, 150 | // Field presence operator with 'exists' keyword 151 | { 152 | input: "name exists", 153 | want: "(exists name true)", 154 | }, 155 | // Field presence operator with 'EXISTS' keyword (uppercase) 156 | { 157 | input: "name EXISTS", 158 | want: "(exists name true)", 159 | }, 160 | // Field presence with AND 161 | { 162 | input: "name? and age:30", 163 | want: "(and (exists name true) (= age 30))", 164 | }, 165 | // Field presence with OR 166 | { 167 | input: "name? or verified", 168 | want: "(or (exists name true) (= verified true))", 169 | }, 170 | // Negated field presence 171 | { 172 | input: "not name?", 173 | want: "(not (exists name true))", 174 | }, 175 | // Complex expression with field presence 176 | { 177 | input: "name? and (age>20 or verified)", 178 | want: "(and (exists name true) (or (> age 20) (= verified true)))", 179 | }, 180 | } 181 | 182 | for _, test := range tests { 183 | t.Run(test.input, func(t *testing.T) { 184 | ast, err := query.Parse("input", []byte(test.input)) 185 | require.NoError(t, err, "parsing error for input: %s", test.input) 186 | 187 | require.Equal(t, test.want, ast.(query.Expr).String()) 188 | }) 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /query/sql.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "fmt" 5 | 6 | sq "github.com/Masterminds/squirrel" 7 | ) 8 | 9 | func (s *StringLiteral) ToSql() (string, []any, error) { //nolint:revive 10 | return "?", []any{s.StringValue}, nil 11 | } 12 | 13 | func (n *NumberLiteral) ToSql() (string, []any, error) { //nolint:revive 14 | return "?", []any{n.NumberValue}, nil 15 | } 16 | 17 | func (b *BoolLiteral) ToSql() (string, []any, error) { //nolint:revive 18 | return "?", []any{b.BoolValue}, nil 19 | } 20 | 21 | func (i Identifier) ToSql() (string, []any, error) { //nolint:revive 22 | return string(i), nil, nil 23 | } 24 | 25 | func (o *OneOfExpr) ToSql() (string, []any, error) { //nolint:revive 26 | return "?", []any{o.Value()}, nil 27 | } 28 | 29 | func (b *BinaryExpr) ToSql() (string, []any, error) { //nolint:revive 30 | switch b.Op { 31 | case And: 32 | return sq.And{b.Left, b.Right}.ToSql() 33 | case Or: 34 | return sq.Or{b.Left, b.Right}.ToSql() 35 | } 36 | 37 | return "", nil, fmt.Errorf("unknown operator %q", b.Op) 38 | } 39 | 40 | func (n *NotExpr) ToSql() (string, []any, error) { //nolint:revive 41 | sql, args, err := n.Expr.ToSql() 42 | if err != nil { 43 | return "", nil, err 44 | } 45 | 46 | return sq.Expr("NOT "+sql, args...).ToSql() 47 | } 48 | 49 | func (f *FieldExpr) ToSql() (string, []any, error) { //nolint:revive 50 | field, value := f.Field.String(), f.Value.Value() 51 | 52 | var sqlizer sq.Sqlizer 53 | 54 | switch f.Op { 55 | case Equal: 56 | sqlizer = sq.Eq{field: value} 57 | case NotEqual: 58 | sqlizer = sq.NotEq{field: value} 59 | case GreaterThan: 60 | sqlizer = sq.Gt{field: value} 61 | case GreaterThanOrEqual: 62 | sqlizer = sq.GtOrEq{field: value} 63 | case LessThan: 64 | sqlizer = sq.Lt{field: value} 65 | case LessThanOrEqual: 66 | sqlizer = sq.LtOrEq{field: value} 67 | case Like: 68 | sqlizer = sq.Like{field: value} 69 | case Exists: 70 | sqlizer = sq.NotEq{f.Field.String(): nil} 71 | default: 72 | return "", nil, fmt.Errorf("unknown operator %q", f.Op) 73 | } 74 | 75 | return sqlizer.ToSql() 76 | } 77 | -------------------------------------------------------------------------------- /query/sql_direct_test.go: -------------------------------------------------------------------------------- 1 | package query_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | "go.tomakado.io/dumbql/query" 9 | ) 10 | 11 | // TestSimpleLiteralSql tests the ToSql methods for simple literals 12 | func TestSimpleLiteralSql(t *testing.T) { 13 | t.Run("StringLiteral", func(t *testing.T) { 14 | sl := &query.StringLiteral{StringValue: "hello"} 15 | sql, args, err := sl.ToSql() 16 | require.NoError(t, err) 17 | assert.Equal(t, "?", sql) 18 | assert.Equal(t, []any{"hello"}, args) 19 | }) 20 | 21 | t.Run("NumberLiteral", func(t *testing.T) { 22 | nl := &query.NumberLiteral{NumberValue: 42.5} 23 | sql, args, err := nl.ToSql() 24 | require.NoError(t, err) 25 | assert.Equal(t, "?", sql) 26 | assert.InDelta(t, 42.5, args[0], 0.0001) 27 | }) 28 | 29 | t.Run("BoolLiteral", func(t *testing.T) { 30 | bl := &query.BoolLiteral{BoolValue: true} 31 | sql, args, err := bl.ToSql() 32 | require.NoError(t, err) 33 | assert.Equal(t, "?", sql) 34 | assert.Equal(t, []any{true}, args) 35 | }) 36 | 37 | t.Run("Identifier", func(t *testing.T) { 38 | id := query.Identifier("fieldname") 39 | sql, args, err := id.ToSql() 40 | require.NoError(t, err) 41 | assert.Equal(t, "fieldname", sql) 42 | assert.Empty(t, args) 43 | }) 44 | } 45 | 46 | // TestComplexSqlGeneration tests the ToSql methods for more complex expressions 47 | func TestComplexSqlGeneration(t *testing.T) { 48 | t.Run("OneOfExpr", func(t *testing.T) { 49 | values := []query.Valuer{ 50 | &query.StringLiteral{StringValue: "one"}, 51 | &query.StringLiteral{StringValue: "two"}, 52 | &query.NumberLiteral{NumberValue: 3}, 53 | } 54 | oe := &query.OneOfExpr{Values: values} 55 | sql, args, err := oe.ToSql() 56 | require.NoError(t, err) 57 | assert.Equal(t, "?", sql) 58 | // The Values should be a slice of string/number values 59 | assert.Contains(t, args[0], "one") 60 | assert.Contains(t, args[0], "two") 61 | assert.Contains(t, args[0], float64(3)) 62 | }) 63 | 64 | t.Run("BinaryExpr_OR_operator", func(t *testing.T) { 65 | // Testing the OR operator branch in BinaryExpr.ToSql 66 | left := &query.FieldExpr{ 67 | Field: query.Identifier("status"), 68 | Op: query.Equal, 69 | Value: &query.NumberLiteral{NumberValue: 200}, 70 | } 71 | right := &query.FieldExpr{ 72 | Field: query.Identifier("code"), 73 | Op: query.Equal, 74 | Value: &query.NumberLiteral{NumberValue: 400}, 75 | } 76 | be := &query.BinaryExpr{ 77 | Left: left, 78 | Op: query.Or, 79 | Right: right, 80 | } 81 | sql, args, err := be.ToSql() 82 | require.NoError(t, err) 83 | assert.Contains(t, sql, "OR") 84 | assert.Len(t, args, 2) 85 | }) 86 | } 87 | 88 | // TestSqlErrorHandling tests error handling in the ToSql methods 89 | func TestSqlErrorHandling(t *testing.T) { 90 | t.Run("BinaryExpr_unknown_operator", func(t *testing.T) { 91 | // Test the unknown operator branch 92 | // Create a custom type that embeds BooleanOperator but with a value not defined in the enum 93 | type CustomBoolOp query.BooleanOperator 94 | be := &query.BinaryExpr{ 95 | Left: &query.FieldExpr{}, 96 | Op: query.BooleanOperator(CustomBoolOp(255)), // Invalid operator (max uint8) 97 | Right: &query.FieldExpr{}, 98 | } 99 | _, _, err := be.ToSql() 100 | require.Error(t, err) 101 | assert.Contains(t, err.Error(), "unknown operator") 102 | }) 103 | 104 | t.Run("NotExpr_error_handling", func(t *testing.T) { 105 | // Test error handling in NotExpr.ToSql 106 | // Use a BinaryExpr with an invalid operator to generate an error 107 | type CustomBoolOp query.BooleanOperator 108 | invalidExpr := &query.BinaryExpr{ 109 | Left: &query.FieldExpr{}, 110 | Op: query.BooleanOperator(CustomBoolOp(255)), // Invalid operator (max uint8) 111 | Right: &query.FieldExpr{}, 112 | } 113 | ne := &query.NotExpr{ 114 | Expr: invalidExpr, 115 | } 116 | _, _, err := ne.ToSql() 117 | require.Error(t, err) 118 | }) 119 | 120 | t.Run("FieldExpr_unknown_operator", func(t *testing.T) { 121 | // Test the unknown operator branch in FieldExpr.ToSql 122 | type CustomFieldOp query.FieldOperator 123 | fe := &query.FieldExpr{ 124 | Field: query.Identifier("status"), 125 | Op: query.FieldOperator(CustomFieldOp(255)), // Invalid operator (max uint8) 126 | Value: &query.NumberLiteral{NumberValue: 200}, 127 | } 128 | _, _, err := fe.ToSql() 129 | require.Error(t, err) 130 | assert.Contains(t, err.Error(), "unknown operator") 131 | }) 132 | } 133 | -------------------------------------------------------------------------------- /query/sql_test.go: -------------------------------------------------------------------------------- 1 | package query_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "go.tomakado.io/dumbql/query" 8 | 9 | sq "github.com/Masterminds/squirrel" 10 | ) 11 | 12 | func TestToSql(t *testing.T) { //nolint:funlen 13 | tests := []struct { 14 | input string 15 | want string 16 | wantArgs []any 17 | }{ 18 | { 19 | // Simple equality using colon (converted to "=") 20 | input: "status:200", 21 | want: "SELECT * FROM dummy_table WHERE status = ?", 22 | wantArgs: []any{float64(200)}, 23 | }, 24 | { 25 | // Floating-point comparison with ">" 26 | input: "eps>0.003", 27 | want: "SELECT * FROM dummy_table WHERE eps > ?", 28 | wantArgs: []any{0.003}, 29 | }, 30 | { 31 | // Boolean AND between two conditions. 32 | input: "status:200 and eps < 0.003", 33 | want: "SELECT * FROM dummy_table WHERE (status = ? AND eps < ?)", 34 | wantArgs: []any{float64(200), 0.003}, 35 | }, 36 | { 37 | // Boolean OR between two conditions. 38 | input: "status:200 or eps < 0.003", 39 | want: "SELECT * FROM dummy_table WHERE (status = ? OR eps < ?)", 40 | wantArgs: []any{float64(200), 0.003}, 41 | }, 42 | { 43 | // NOT operator applied to a field expression. 44 | input: "not status:200", 45 | want: "SELECT * FROM dummy_table WHERE NOT status = ?", 46 | wantArgs: []any{float64(200)}, 47 | }, 48 | { 49 | // Parenthesized expression. 50 | input: "(status:200 and eps<0.003)", 51 | want: "SELECT * FROM dummy_table WHERE (status = ? AND eps < ?)", 52 | wantArgs: []any{float64(200), 0.003}, 53 | }, 54 | { 55 | // Array literal conversion (using IN). 56 | input: "req.fields.ext:[\"jpg\", \"png\"]", 57 | want: "SELECT * FROM dummy_table WHERE req.fields.ext IN (?,?)", 58 | wantArgs: []any{"jpg", "png"}, 59 | }, 60 | { 61 | // Complex expression combining AND and a parenthesized array literal. 62 | input: "status:200 and eps<0.003 and (req.fields.ext:[\"jpg\", \"png\"])", 63 | want: "SELECT * FROM dummy_table WHERE ((status = ? AND eps < ?) AND req.fields.ext IN (?,?))", 64 | wantArgs: []any{float64(200), 0.003, "jpg", "png"}, 65 | }, 66 | { 67 | // Greater than or equal operator. 68 | input: "cmp>=100", 69 | want: "SELECT * FROM dummy_table WHERE cmp >= ?", 70 | wantArgs: []any{float64(100)}, 71 | }, 72 | { 73 | // Less than or equal operator. 74 | input: "price<=50", 75 | want: "SELECT * FROM dummy_table WHERE price <= ?", 76 | wantArgs: []any{float64(50)}, 77 | }, 78 | { 79 | // Nested NOT with a parenthesized expression. 80 | input: "not (status:200 and eps < 0.003)", 81 | want: "SELECT * FROM dummy_table WHERE NOT (status = ? AND eps < ?)", 82 | wantArgs: []any{float64(200), 0.003}, 83 | }, 84 | { 85 | input: `name~"John"`, 86 | want: "SELECT * FROM dummy_table WHERE name LIKE ?", 87 | wantArgs: []any{ 88 | "John", 89 | }, 90 | }, 91 | { 92 | // Boolean true value 93 | input: "is_active:true", 94 | want: "SELECT * FROM dummy_table WHERE is_active = ?", 95 | wantArgs: []any{true}, 96 | }, 97 | { 98 | // Boolean false value 99 | input: "is_deleted:false", 100 | want: "SELECT * FROM dummy_table WHERE is_deleted = ?", 101 | wantArgs: []any{false}, 102 | }, 103 | { 104 | // Boolean with not equals 105 | input: "is_enabled!=false", 106 | want: "SELECT * FROM dummy_table WHERE is_enabled <> ?", 107 | wantArgs: []any{false}, 108 | }, 109 | { 110 | // Boolean shorthand syntax 111 | input: "is_active", 112 | want: "SELECT * FROM dummy_table WHERE is_active = ?", 113 | wantArgs: []any{true}, 114 | }, 115 | { 116 | // Field presence operator with ? syntax 117 | input: "name?", 118 | want: "SELECT * FROM dummy_table WHERE name IS NOT NULL", 119 | wantArgs: []any{}, 120 | }, 121 | { 122 | // Field presence operator with EXISTS keyword 123 | input: "name exists", 124 | want: "SELECT * FROM dummy_table WHERE name IS NOT NULL", 125 | wantArgs: []any{}, 126 | }, 127 | { 128 | // Negated field presence 129 | input: "not name?", 130 | want: "SELECT * FROM dummy_table WHERE NOT name IS NOT NULL", 131 | wantArgs: []any{}, 132 | }, 133 | { 134 | // Field presence with AND 135 | input: "name? and age>20", 136 | want: "SELECT * FROM dummy_table WHERE (name IS NOT NULL AND age > ?)", 137 | wantArgs: []any{float64(20)}, 138 | }, 139 | { 140 | // Complex expression with field presence 141 | input: "name? and (age>20 or active)", 142 | want: "SELECT * FROM dummy_table WHERE (name IS NOT NULL AND (age > ? OR active = ?))", 143 | wantArgs: []any{float64(20), true}, 144 | }, 145 | } 146 | 147 | for _, test := range tests { 148 | t.Run(test.input, func(t *testing.T) { 149 | ast, err := query.Parse("test", []byte(test.input)) 150 | require.NoError(t, err) 151 | require.NotNil(t, ast) 152 | 153 | expr, ok := ast.(query.Expr) 154 | require.True(t, ok) 155 | 156 | got, gotArgs, err := sq.Select("*").From("dummy_table").Where(expr).ToSql() 157 | require.NoError(t, err, "Unexpected error for input: %s", test.input) 158 | require.Equal(t, test.want, got, "Mismatch for input: %s", test.input) 159 | require.ElementsMatch(t, test.wantArgs, gotArgs) 160 | }) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /query/validation.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "fmt" 5 | 6 | "go.tomakado.io/dumbql/schema" 7 | "go.uber.org/multierr" 8 | ) 9 | 10 | // Validate checks if the binary expression is valid against the schema. 11 | // If either the left or right expression is invalid, the only valid expression is returned. 12 | func (b *BinaryExpr) Validate(schema schema.Schema) (Expr, error) { 13 | left, err := b.Left.Validate(schema) 14 | 15 | right, rightErr := b.Right.Validate(schema) 16 | if rightErr != nil { 17 | err = multierr.Append(err, rightErr) 18 | if right == nil { 19 | return left, err 20 | } 21 | } 22 | 23 | if left == nil { 24 | return right, err 25 | } 26 | 27 | return &BinaryExpr{ 28 | Left: left, 29 | Op: b.Op, 30 | Right: right, 31 | }, err 32 | } 33 | 34 | // Validate checks if the not expression is valid against the schema. 35 | func (n *NotExpr) Validate(schema schema.Schema) (Expr, error) { 36 | expr, err := n.Expr.Validate(schema) 37 | if err != nil { 38 | if expr == nil { 39 | return nil, err 40 | } 41 | 42 | return expr, err 43 | } 44 | 45 | return n, nil 46 | } 47 | 48 | // Validate checks if the field expression is valid against the corresponding schema rule. 49 | func (f *FieldExpr) Validate(schm schema.Schema) (Expr, error) { 50 | field := schema.Field(f.Field) 51 | 52 | rule, ok := schm[field] 53 | if !ok { 54 | return nil, fmt.Errorf("field %q not found in schema", f.Field) 55 | } 56 | 57 | oneOf, isOneOf := f.Value.(*OneOfExpr) 58 | if !isOneOf { 59 | if err := rule(field, f.Value.Value()); err != nil { 60 | return nil, err 61 | } 62 | return f, nil 63 | } 64 | 65 | var ( 66 | values = make([]Valuer, 0, len(oneOf.Values)) 67 | err error 68 | ) 69 | 70 | for _, v := range oneOf.Values { 71 | if ruleErr := rule(field, v.Value()); ruleErr != nil { 72 | err = multierr.Append(err, ruleErr) 73 | continue 74 | } 75 | values = append(values, v) 76 | } 77 | 78 | return &FieldExpr{ 79 | Field: f.Field, 80 | Op: f.Op, 81 | Value: &OneOfExpr{Values: values}, 82 | }, err 83 | } 84 | -------------------------------------------------------------------------------- /query/validation_test.go: -------------------------------------------------------------------------------- 1 | package query_test 2 | 3 | import ( 4 | "errors" 5 | "math" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "go.tomakado.io/dumbql/query" 11 | "go.tomakado.io/dumbql/schema" 12 | ) 13 | 14 | func TestBinaryExpr_Validate(t *testing.T) { //nolint:funlen 15 | t.Run("positive", func(t *testing.T) { 16 | schm := schema.Schema{ 17 | "left": schema.Any(), 18 | "right": schema.Any(), 19 | } 20 | 21 | expr := &query.BinaryExpr{ 22 | Left: &query.FieldExpr{ 23 | Field: "left", 24 | Op: query.Equal, 25 | Value: &query.NumberLiteral{NumberValue: 42}, 26 | }, 27 | Op: query.And, 28 | Right: &query.FieldExpr{ 29 | Field: "right", 30 | Op: query.Equal, 31 | Value: &query.NumberLiteral{NumberValue: math.Pi}, 32 | }, 33 | } 34 | 35 | got, err := expr.Validate(schm) 36 | require.NoError(t, err) 37 | 38 | binaryExpr, isBinaryExpr := got.(*query.BinaryExpr) 39 | require.True(t, isBinaryExpr) 40 | 41 | leftFieldExpr, isLeftFieldExpr := binaryExpr.Left.(*query.FieldExpr) 42 | require.True(t, isLeftFieldExpr) 43 | 44 | rightFieldExpr, isRightFieldExpr := binaryExpr.Right.(*query.FieldExpr) 45 | require.True(t, isRightFieldExpr) 46 | 47 | leftNumberLiteral, isLeftNumberLiteral := leftFieldExpr.Value.(*query.NumberLiteral) 48 | require.True(t, isLeftNumberLiteral) 49 | 50 | rightNumberLiteral, isRightNumberLiteral := rightFieldExpr.Value.(*query.NumberLiteral) 51 | require.True(t, isRightNumberLiteral) 52 | 53 | require.InDelta(t, 42.0, leftNumberLiteral.NumberValue, 0.0001) 54 | require.InDelta(t, math.Pi, rightNumberLiteral.NumberValue, 0.01) 55 | }) 56 | 57 | t.Run("negative", func(t *testing.T) { 58 | t.Run("left rule error", func(t *testing.T) { 59 | schm := schema.Schema{ 60 | "left": ruleError, 61 | "right": schema.Any(), 62 | } 63 | 64 | expr := &query.BinaryExpr{ 65 | Left: &query.FieldExpr{ 66 | Field: "left", 67 | Op: query.Equal, 68 | Value: &query.NumberLiteral{NumberValue: 42}, 69 | }, 70 | Op: query.And, 71 | Right: &query.FieldExpr{ 72 | Field: "right", 73 | Op: query.Equal, 74 | Value: &query.NumberLiteral{NumberValue: math.Pi}, 75 | }, 76 | } 77 | 78 | got, err := expr.Validate(schm) 79 | require.Error(t, err) 80 | 81 | fieldExpr, isFieldExpr := got.(*query.FieldExpr) 82 | require.True(t, isFieldExpr) 83 | 84 | numberLiteral, isNumberLiteral := fieldExpr.Value.(*query.NumberLiteral) 85 | require.True(t, isNumberLiteral) 86 | 87 | require.InDelta(t, math.Pi, numberLiteral.NumberValue, 0.01) 88 | }) 89 | 90 | t.Run("right rule error", func(t *testing.T) { 91 | schm := schema.Schema{ 92 | "left": schema.Any(), 93 | "right": ruleError, 94 | } 95 | 96 | expr := &query.BinaryExpr{ 97 | Left: &query.FieldExpr{ 98 | Field: "left", 99 | Op: query.Equal, 100 | Value: &query.NumberLiteral{NumberValue: 42}, 101 | }, 102 | Op: query.And, 103 | Right: &query.FieldExpr{ 104 | Field: "right", 105 | Op: query.Equal, 106 | Value: &query.NumberLiteral{NumberValue: math.Pi}, 107 | }, 108 | } 109 | 110 | got, err := expr.Validate(schm) 111 | require.Error(t, err) 112 | 113 | fieldExpr, isFieldExpr := got.(*query.FieldExpr) 114 | require.True(t, isFieldExpr) 115 | 116 | numberLiteral, isNumberLiteral := fieldExpr.Value.(*query.NumberLiteral) 117 | require.True(t, isNumberLiteral) 118 | 119 | require.InDelta(t, 42.0, numberLiteral.NumberValue, 0.0001) 120 | }) 121 | 122 | t.Run("left and right rule error", func(t *testing.T) { 123 | schm := schema.Schema{ 124 | "left": ruleError, 125 | "right": ruleError, 126 | } 127 | 128 | expr := &query.BinaryExpr{ 129 | Left: &query.FieldExpr{ 130 | Field: "left", 131 | Op: query.Equal, 132 | Value: &query.NumberLiteral{NumberValue: 42}, 133 | }, 134 | Right: &query.FieldExpr{ 135 | Field: "right", 136 | Op: query.Equal, 137 | Value: &query.NumberLiteral{NumberValue: math.Pi}, 138 | }, 139 | } 140 | 141 | got, err := expr.Validate(schm) 142 | require.Error(t, err) 143 | require.Nil(t, got) 144 | }) 145 | 146 | t.Run("unknown field", func(t *testing.T) { 147 | schm := schema.Schema{} 148 | 149 | expr := &query.BinaryExpr{ 150 | Left: &query.FieldExpr{ 151 | Field: "left", 152 | Op: query.Equal, 153 | Value: &query.NumberLiteral{NumberValue: 42}, 154 | }, 155 | Right: &query.FieldExpr{ 156 | Field: "right", 157 | Op: query.Equal, 158 | Value: &query.NumberLiteral{NumberValue: math.Pi}, 159 | }, 160 | } 161 | 162 | got, err := expr.Validate(schm) 163 | require.Error(t, err) 164 | require.Nil(t, got) 165 | }) 166 | }) 167 | } 168 | 169 | func TestNotExpr_Validate(t *testing.T) { //nolint:funlen 170 | t.Run("positive", func(t *testing.T) { 171 | schm := schema.Schema{ 172 | "field": schema.Any(), 173 | } 174 | 175 | expr := &query.NotExpr{ 176 | Expr: &query.FieldExpr{ 177 | Field: "field", 178 | Op: query.Equal, 179 | Value: &query.NumberLiteral{NumberValue: 42}, 180 | }, 181 | } 182 | 183 | got, err := expr.Validate(schm) 184 | require.NoError(t, err) 185 | 186 | notExpr, isNotExpr := got.(*query.NotExpr) 187 | require.True(t, isNotExpr) 188 | 189 | fieldExpr, isFieldExpr := notExpr.Expr.(*query.FieldExpr) 190 | require.True(t, isFieldExpr) 191 | 192 | numberLiteral, isNumberLiteral := fieldExpr.Value.(*query.NumberLiteral) 193 | require.True(t, isNumberLiteral) 194 | 195 | require.InDelta(t, 42.0, numberLiteral.NumberValue, 0.0001) 196 | }) 197 | 198 | t.Run("negative", func(t *testing.T) { 199 | t.Run("rule error", func(t *testing.T) { 200 | schm := schema.Schema{ 201 | "field": ruleError, 202 | } 203 | 204 | expr := &query.NotExpr{ 205 | Expr: &query.FieldExpr{ 206 | Field: "field", 207 | Op: query.Equal, 208 | Value: &query.NumberLiteral{NumberValue: 42}, 209 | }, 210 | } 211 | 212 | got, err := expr.Validate(schm) 213 | require.Error(t, err) 214 | require.Nil(t, got) 215 | }) 216 | 217 | t.Run("unknown field", func(t *testing.T) { 218 | schm := schema.Schema{} 219 | 220 | expr := &query.NotExpr{ 221 | Expr: &query.FieldExpr{ 222 | Field: "field", 223 | Op: query.Equal, 224 | Value: &query.NumberLiteral{NumberValue: 42}, 225 | }, 226 | } 227 | 228 | got, err := expr.Validate(schm) 229 | require.Error(t, err) 230 | require.Nil(t, got) 231 | }) 232 | }) 233 | } 234 | 235 | func TestFieldExpr_Validate(t *testing.T) { //nolint:funlen 236 | t.Run("positive", func(t *testing.T) { 237 | t.Run("primitive value", func(t *testing.T) { 238 | schm := schema.Schema{ 239 | "field": schema.Any(), 240 | } 241 | 242 | expr := &query.FieldExpr{ 243 | Field: "field", 244 | Op: query.Equal, 245 | Value: &query.NumberLiteral{NumberValue: 42}, 246 | } 247 | 248 | got, err := expr.Validate(schm) 249 | require.NoError(t, err) 250 | 251 | fieldExpr, isFieldExpr := got.(*query.FieldExpr) 252 | require.True(t, isFieldExpr) 253 | 254 | numberLiteral, isNumberLiteral := fieldExpr.Value.(*query.NumberLiteral) 255 | require.True(t, isNumberLiteral) 256 | 257 | require.InDelta(t, 42.0, numberLiteral.NumberValue, 0.0001) 258 | }) 259 | 260 | t.Run("one of", func(t *testing.T) { 261 | schm := schema.Schema{ 262 | "field": schema.Any(), 263 | } 264 | 265 | expr := &query.FieldExpr{ 266 | Field: "field", 267 | Op: query.Equal, 268 | Value: &query.OneOfExpr{ 269 | Values: []query.Valuer{ 270 | &query.NumberLiteral{NumberValue: 42}, 271 | &query.NumberLiteral{NumberValue: math.Pi}, 272 | }, 273 | }, 274 | } 275 | 276 | got, err := expr.Validate(schm) 277 | require.NoError(t, err) 278 | 279 | fieldExpr, isFieldExpr := got.(*query.FieldExpr) 280 | require.True(t, isFieldExpr) 281 | 282 | oneOfExpr, isOneOfExpr := fieldExpr.Value.(*query.OneOfExpr) 283 | require.True(t, isOneOfExpr) 284 | 285 | number1Literal, isNumber1Literal := oneOfExpr.Values[0].(*query.NumberLiteral) 286 | require.True(t, isNumber1Literal) 287 | 288 | number2Literal, isNumber2Literal := oneOfExpr.Values[1].(*query.NumberLiteral) 289 | require.True(t, isNumber2Literal) 290 | 291 | require.InDelta(t, 42.0, number1Literal.NumberValue, 0.0001) 292 | require.InDelta(t, math.Pi, number2Literal.NumberValue, 0.01) 293 | }) 294 | }) 295 | 296 | t.Run("negative", func(t *testing.T) { 297 | t.Run("primitive value rule error", func(t *testing.T) { 298 | schm := schema.Schema{ 299 | "field": ruleError, 300 | } 301 | 302 | expr := &query.FieldExpr{ 303 | Field: "field", 304 | Op: query.Equal, 305 | Value: &query.NumberLiteral{NumberValue: 42}, 306 | } 307 | 308 | got, err := expr.Validate(schm) 309 | require.Error(t, err) 310 | require.Nil(t, got) 311 | }) 312 | 313 | t.Run("one of rule error", func(t *testing.T) { 314 | schm := schema.Schema{ 315 | "field": schema.All( 316 | schema.Is[float64](), 317 | schema.EqualsOneOf(42.0), 318 | ), 319 | } 320 | 321 | expr := &query.FieldExpr{ 322 | Field: "field", 323 | Op: query.Equal, 324 | Value: &query.OneOfExpr{ 325 | Values: []query.Valuer{ 326 | &query.NumberLiteral{NumberValue: 42}, // This will pass 327 | &query.NumberLiteral{NumberValue: 99}, // This will fail validation 328 | }, 329 | }, 330 | } 331 | 332 | got, err := expr.Validate(schm) 333 | require.Error(t, err) 334 | 335 | fieldExpr, isFieldExpr := got.(*query.FieldExpr) 336 | require.True(t, isFieldExpr) 337 | 338 | oneOfExpr, isOneOfExpr := fieldExpr.Value.(*query.OneOfExpr) 339 | require.True(t, isOneOfExpr) 340 | 341 | assert.Len(t, oneOfExpr.Values, 1) 342 | 343 | numberLiteral, isNumberLiteral := oneOfExpr.Values[0].(*query.NumberLiteral) 344 | require.True(t, isNumberLiteral) 345 | require.InDelta(t, 42.0, numberLiteral.NumberValue, 0.0001) 346 | }) 347 | 348 | t.Run("unknown field", func(t *testing.T) { 349 | schm := schema.Schema{} 350 | 351 | expr := &query.FieldExpr{ 352 | Field: "field", 353 | Op: query.Equal, 354 | Value: &query.NumberLiteral{NumberValue: 42}, 355 | } 356 | 357 | got, err := expr.Validate(schm) 358 | require.Error(t, err) 359 | require.Nil(t, got) 360 | }) 361 | }) 362 | } 363 | 364 | func ruleError(schema.Field, any) error { 365 | return errors.New("rule error") 366 | } 367 | -------------------------------------------------------------------------------- /schema/rules.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import "fmt" 4 | 5 | func Any(rules ...RuleFunc) RuleFunc { 6 | return func(field Field, value any) error { 7 | var err error 8 | for _, rule := range rules { 9 | if err = rule(field, value); err == nil { 10 | return nil 11 | } 12 | } 13 | return err 14 | } 15 | } 16 | 17 | func All(rules ...RuleFunc) RuleFunc { 18 | return func(field Field, value any) error { 19 | for _, rule := range rules { 20 | if err := rule(field, value); err != nil { 21 | return err 22 | } 23 | } 24 | return nil 25 | } 26 | } 27 | 28 | func InRange[T Numeric](min, max T) RuleFunc { //nolint:revive,gocognit,cyclop 29 | return func(field Field, value any) error { 30 | // Special case for float64 value being compared with int64 min/max 31 | if fv, ok := value.(float64); ok { //nolint:nestif 32 | if ivMin, ok := any(min).(int64); ok { 33 | if ivMax, ok := any(max).(int64); ok { 34 | if fv < float64(ivMin) || fv > float64(ivMax) { 35 | return fmt.Errorf("field %q: value must be in range [%v, %v], got %v", field, min, max, fv) 36 | } 37 | return nil 38 | } 39 | } 40 | } 41 | 42 | // Special case for int64 value being compared with float64 min/max 43 | if iv, ok := value.(int64); ok { //nolint:nestif 44 | if fvMin, ok := any(min).(float64); ok { 45 | if fvMax, ok := any(max).(float64); ok { 46 | if float64(iv) < fvMin || float64(iv) > fvMax { 47 | return fmt.Errorf("field %q: value must be in range [%v, %v], got %v", field, min, max, iv) 48 | } 49 | return nil 50 | } 51 | } 52 | } 53 | 54 | // Regular case for matching types 55 | if v, ok := value.(T); ok { 56 | if v < min || v > max { 57 | return fmt.Errorf("field %q: value must be in range [%v, %v], got %v", field, min, max, v) 58 | } 59 | return nil 60 | } 61 | return fmt.Errorf("field %q: value must be %T, got %T", field, min, value) 62 | } 63 | } 64 | 65 | func Min[T Numeric](min T) RuleFunc { //nolint:revive 66 | return func(field Field, value any) error { 67 | // Special case for float64 value being compared with int64 min 68 | if fv, ok := value.(float64); ok { 69 | if iv, ok := any(min).(int64); ok { 70 | if fv < float64(iv) { 71 | return fmt.Errorf("field %q: value must be equal or greater than %v, got %v", field, min, fv) 72 | } 73 | return nil 74 | } 75 | } 76 | 77 | // Special case for int64 value being compared with float64 min 78 | if iv, ok := value.(int64); ok { 79 | if fv, ok := any(min).(float64); ok { 80 | if float64(iv) < fv { 81 | return fmt.Errorf("field %q: value must be equal or greater than %v, got %v", field, min, iv) 82 | } 83 | return nil 84 | } 85 | } 86 | 87 | // Regular case for matching types 88 | if v, ok := value.(T); ok { 89 | if v < min { 90 | return fmt.Errorf("field %q: value must be equal or greater than %v, got %v", field, min, v) 91 | } 92 | return nil 93 | } 94 | return fmt.Errorf("field %q: value must be %T, got %T", field, min, value) 95 | } 96 | } 97 | 98 | func Max[T Numeric](max T) RuleFunc { //nolint:revive 99 | return func(field Field, value any) error { 100 | // Special case for float64 value being compared with int64 max 101 | if fv, ok := value.(float64); ok { 102 | if iv, ok := any(max).(int64); ok { 103 | if fv > float64(iv) { 104 | return fmt.Errorf("field %q: value must be equal or less than %v, got %v", field, max, fv) 105 | } 106 | return nil 107 | } 108 | } 109 | 110 | // Special case for int64 value being compared with float64 max 111 | if iv, ok := value.(int64); ok { 112 | if fv, ok := any(max).(float64); ok { 113 | if float64(iv) > fv { 114 | return fmt.Errorf("field %q: value must be equal or less than %v, got %v", field, max, iv) 115 | } 116 | return nil 117 | } 118 | } 119 | 120 | // Regular case for matching types 121 | if v, ok := value.(T); ok { 122 | if v > max { 123 | return fmt.Errorf("field %q: value must be equal or less than %v, got %v", field, max, v) 124 | } 125 | return nil 126 | } 127 | return fmt.Errorf("field %q: value must be %T, got %T", field, max, value) 128 | } 129 | } 130 | 131 | func LenInRange(min, max int) RuleFunc { //nolint:revive 132 | return func(field Field, value any) error { 133 | if v, ok := value.(string); ok { 134 | if len(v) < min || len(v) > max { 135 | return fmt.Errorf("field %q: len must be in range [%d, %d], got %d", field, min, max, len(v)) 136 | } 137 | return nil 138 | } 139 | return fmt.Errorf("field %q: value must be string, got %v", field, value) 140 | } 141 | } 142 | 143 | func MinLen(min int) RuleFunc { //nolint:revive 144 | return func(field Field, value any) error { 145 | if v, ok := value.(string); ok { 146 | if len(v) < min { 147 | return fmt.Errorf("field %q: len must be greater than %d, got %d", field, min, len(v)) 148 | } 149 | return nil 150 | } 151 | return fmt.Errorf("field %q: value must be string, got %v", field, value) 152 | } 153 | } 154 | 155 | func MaxLen(max int) RuleFunc { //nolint:revive 156 | return func(field Field, value any) error { 157 | if v, ok := value.(string); ok { 158 | if len(v) > max { 159 | return fmt.Errorf("field %q: value must be less than %d, got %d", field, max, len(v)) 160 | } 161 | return nil 162 | } 163 | return fmt.Errorf("field %q: value must be string, got %v", field, value) 164 | } 165 | } 166 | 167 | func Is[T ValueType]() RuleFunc { 168 | return func(field Field, value any) error { 169 | if v, ok := value.(T); !ok { 170 | return fmt.Errorf("field %q: value must be %T, got %T", field, v, value) 171 | } 172 | return nil 173 | } 174 | } 175 | 176 | func EqualsOneOf(values ...any) RuleFunc { 177 | return func(field Field, value any) error { 178 | for _, v := range values { 179 | if v == value { 180 | return nil 181 | } 182 | } 183 | return fmt.Errorf("field %q: value must be one of %v, got %v", field, values, value) 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /schema/rules_internal_test.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestInRange_CrossTypeComparisons(t *testing.T) { //nolint:funlen 10 | tests := []struct { 11 | name string 12 | min any 13 | max any 14 | value any 15 | wantErr bool 16 | }{ 17 | { 18 | name: "float64 value with int64 bounds - within range", 19 | min: int64(10), 20 | max: int64(20), 21 | value: float64(15.5), 22 | wantErr: false, 23 | }, 24 | { 25 | name: "float64 value with int64 bounds - below range", 26 | min: int64(10), 27 | max: int64(20), 28 | value: float64(5.5), 29 | wantErr: true, 30 | }, 31 | { 32 | name: "float64 value with int64 bounds - above range", 33 | min: int64(10), 34 | max: int64(20), 35 | value: float64(25.5), 36 | wantErr: true, 37 | }, 38 | { 39 | name: "float64 value with int64 bounds - equal to min", 40 | min: int64(10), 41 | max: int64(20), 42 | value: float64(10.0), 43 | wantErr: false, 44 | }, 45 | { 46 | name: "float64 value with int64 bounds - equal to max", 47 | min: int64(10), 48 | max: int64(20), 49 | value: float64(20.0), 50 | wantErr: false, 51 | }, 52 | { 53 | name: "int64 value with float64 bounds - within range", 54 | min: float64(10.5), 55 | max: float64(20.5), 56 | value: int64(15), 57 | wantErr: false, 58 | }, 59 | { 60 | name: "int64 value with float64 bounds - below range", 61 | min: float64(10.5), 62 | max: float64(20.5), 63 | value: int64(5), 64 | wantErr: true, 65 | }, 66 | { 67 | name: "int64 value with float64 bounds - above range", 68 | min: float64(10.5), 69 | max: float64(20.5), 70 | value: int64(25), 71 | wantErr: true, 72 | }, 73 | { 74 | name: "int64 value with float64 bounds - between min and integer", 75 | min: float64(10.5), 76 | max: float64(20.5), 77 | value: int64(10), 78 | wantErr: true, 79 | }, 80 | { 81 | name: "int64 value with float64 bounds - between integer and max", 82 | min: float64(10.5), 83 | max: float64(20.5), 84 | value: int64(21), 85 | wantErr: true, 86 | }, 87 | } 88 | 89 | for _, tt := range tests { 90 | t.Run(tt.name, func(t *testing.T) { 91 | var rule RuleFunc 92 | 93 | switch minValue := tt.min.(type) { 94 | case int64: 95 | rule = InRange[int64](minValue, tt.max.(int64)) 96 | case float64: 97 | rule = InRange[float64](minValue, tt.max.(float64)) 98 | } 99 | 100 | err := rule("test_field", tt.value) 101 | if tt.wantErr { 102 | assert.Error(t, err) 103 | } else { 104 | assert.NoError(t, err) 105 | } 106 | }) 107 | } 108 | } 109 | 110 | func TestMin_CrossTypeComparisons(t *testing.T) { //nolint:funlen 111 | tests := []struct { 112 | name string 113 | min any 114 | value any 115 | wantErr bool 116 | }{ 117 | { 118 | name: "float64 value with int64 min - above min", 119 | min: int64(10), 120 | value: float64(15.5), 121 | wantErr: false, 122 | }, 123 | { 124 | name: "float64 value with int64 min - below min", 125 | min: int64(10), 126 | value: float64(5.5), 127 | wantErr: true, 128 | }, 129 | { 130 | name: "float64 value with int64 min - equal to min", 131 | min: int64(10), 132 | value: float64(10.0), 133 | wantErr: false, 134 | }, 135 | { 136 | name: "int64 value with float64 min - above min", 137 | min: float64(10.5), 138 | value: int64(15), 139 | wantErr: false, 140 | }, 141 | { 142 | name: "int64 value with float64 min - below min", 143 | min: float64(10.5), 144 | value: int64(5), 145 | wantErr: true, 146 | }, 147 | { 148 | name: "int64 value with float64 min - between min and integer", 149 | min: float64(10.5), 150 | value: int64(10), 151 | wantErr: true, 152 | }, 153 | } 154 | 155 | for _, tt := range tests { 156 | t.Run(tt.name, func(t *testing.T) { 157 | var rule RuleFunc 158 | 159 | switch minValue := tt.min.(type) { 160 | case int64: 161 | rule = Min[int64](minValue) 162 | case float64: 163 | rule = Min[float64](minValue) 164 | } 165 | 166 | err := rule("test_field", tt.value) 167 | if tt.wantErr { 168 | assert.Error(t, err) 169 | } else { 170 | assert.NoError(t, err) 171 | } 172 | }) 173 | } 174 | } 175 | 176 | func TestMax_CrossTypeComparisons(t *testing.T) { //nolint:funlen 177 | tests := []struct { 178 | name string 179 | max any 180 | value any 181 | wantErr bool 182 | }{ 183 | { 184 | name: "float64 value with int64 max - below max", 185 | max: int64(20), 186 | value: float64(15.5), 187 | wantErr: false, 188 | }, 189 | { 190 | name: "float64 value with int64 max - above max", 191 | max: int64(20), 192 | value: float64(25.5), 193 | wantErr: true, 194 | }, 195 | { 196 | name: "float64 value with int64 max - equal to max", 197 | max: int64(20), 198 | value: float64(20.0), 199 | wantErr: false, 200 | }, 201 | { 202 | name: "int64 value with float64 max - below max", 203 | max: float64(20.5), 204 | value: int64(15), 205 | wantErr: false, 206 | }, 207 | { 208 | name: "int64 value with float64 max - above max", 209 | max: float64(20.5), 210 | value: int64(25), 211 | wantErr: true, 212 | }, 213 | { 214 | name: "int64 value with float64 max - between integer and max", 215 | max: float64(20.5), 216 | value: int64(21), 217 | wantErr: true, 218 | }, 219 | } 220 | 221 | for _, tt := range tests { 222 | t.Run(tt.name, func(t *testing.T) { 223 | var rule RuleFunc 224 | 225 | switch maxValue := tt.max.(type) { 226 | case int64: 227 | rule = Max[int64](maxValue) 228 | case float64: 229 | rule = Max[float64](maxValue) 230 | } 231 | 232 | err := rule("test_field", tt.value) 233 | if tt.wantErr { 234 | assert.Error(t, err) 235 | } else { 236 | assert.NoError(t, err) 237 | } 238 | }) 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /schema/rules_test.go: -------------------------------------------------------------------------------- 1 | package schema_test 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "go.tomakado.io/dumbql/schema" 11 | ) 12 | 13 | func TestAny(t *testing.T) { 14 | t.Run("positive", func(t *testing.T) { 15 | rule := schema.Any(schema.Is[float64](), schema.Is[string]()) 16 | require.NoError(t, rule("positive_int", float64(42))) 17 | require.NoError(t, rule("positive_string", "Hello, world!")) 18 | }) 19 | 20 | t.Run("negative", func(t *testing.T) { 21 | rule := schema.Any(schema.Is[float64](), schema.Is[string]()) 22 | require.Error(t, rule("negative", true)) 23 | }) 24 | } 25 | 26 | func TestAll(t *testing.T) { 27 | t.Run("positive", func(t *testing.T) { 28 | rule := schema.All(schema.Is[float64](), schema.Min[float64](42)) 29 | require.NoError(t, rule("positive", float64(42))) 30 | }) 31 | 32 | t.Run("negative", func(t *testing.T) { 33 | rule := schema.All(schema.Is[float64](), schema.Min[float64](42)) 34 | require.Error(t, rule("negative", float64(41))) 35 | }) 36 | } 37 | 38 | func TestInRange(t *testing.T) { 39 | t.Run("int64", func(t *testing.T) { 40 | t.Run("positive", func(t *testing.T) { 41 | rule := schema.InRange[int64](5, 10) 42 | require.NoError(t, rule("positive", int64(7))) 43 | }) 44 | 45 | t.Run("negative", func(t *testing.T) { 46 | rule := schema.InRange[int64](5, 10) 47 | require.Error(t, rule("negative", int64(42))) 48 | }) 49 | }) 50 | 51 | t.Run("float64", func(t *testing.T) { 52 | t.Run("positive", func(t *testing.T) { 53 | rule := schema.InRange[float64](5.0, 10.0) 54 | require.NoError(t, rule("positive", 7.5)) 55 | }) 56 | 57 | t.Run("negative", func(t *testing.T) { 58 | rule := schema.InRange[float64](5.0, 10.0) 59 | require.Error(t, rule("negative", 42.0)) 60 | }) 61 | }) 62 | } 63 | 64 | func TestMin(t *testing.T) { 65 | t.Run("int64", func(t *testing.T) { 66 | t.Run("positive", func(t *testing.T) { 67 | rule := schema.Min[int64](42) 68 | require.NoError(t, rule("positive", int64(42))) 69 | // Test our new feature - float64 values should work with int64 min 70 | require.NoError(t, rule("positive_float", 42.5)) 71 | }) 72 | 73 | t.Run("negative", func(t *testing.T) { 74 | t.Run("wrong value", func(t *testing.T) { 75 | rule := schema.Min[int64](42) 76 | require.Error(t, rule("negative", int64(41))) 77 | // Even with our new feature, lower values should fail 78 | require.Error(t, rule("negative_float", 41.5)) 79 | }) 80 | 81 | t.Run("wrong type", func(t *testing.T) { 82 | rule := schema.Min[int64](42) 83 | // With our improved implementation, float64 values work with int64 minimums 84 | require.NoError(t, rule("float_works", 42.42)) 85 | // But other non-numeric types should still fail 86 | require.Error(t, rule("string_fails", "42")) 87 | }) 88 | }) 89 | }) 90 | 91 | t.Run("float64", func(t *testing.T) { 92 | t.Run("positive", func(t *testing.T) { 93 | rule := schema.Min[float64](42.42) 94 | require.NoError(t, rule("positive", 42.42)) 95 | }) 96 | 97 | t.Run("negative", func(t *testing.T) { 98 | t.Run("wrong value", func(t *testing.T) { 99 | rule := schema.Min[float64](42.42) 100 | require.Error(t, rule("negative", 42.41)) 101 | }) 102 | 103 | t.Run("wrong type", func(t *testing.T) { 104 | rule := schema.Min[float64](42.42) 105 | // With our improved implementation, int64 values work with float64 minimums 106 | require.NoError(t, rule("integer_works", int64(43))) 107 | // But other non-numeric types should still fail 108 | require.Error(t, rule("string_fails", "42.42")) 109 | }) 110 | }) 111 | }) 112 | } 113 | 114 | func TestMax(t *testing.T) { 115 | t.Run("int64", func(t *testing.T) { 116 | t.Run("positive", func(t *testing.T) { 117 | rule := schema.Max[int64](42) 118 | require.NoError(t, rule("positive", int64(42))) 119 | // Test our new feature - float64 values should work with int64 max 120 | require.NoError(t, rule("positive_float", 41.5)) 121 | }) 122 | 123 | t.Run("negative", func(t *testing.T) { 124 | rule := schema.Max[int64](42) 125 | require.Error(t, rule("negative", int64(43))) 126 | // Even with our new feature, higher values should fail 127 | require.Error(t, rule("negative_float", 42.5)) 128 | }) 129 | }) 130 | 131 | t.Run("float64", func(t *testing.T) { 132 | t.Run("positive", func(t *testing.T) { 133 | rule := schema.Max[float64](42.42) 134 | require.NoError(t, rule("positive", 42.42)) 135 | }) 136 | 137 | t.Run("negative", func(t *testing.T) { 138 | rule := schema.Max[float64](42.42) 139 | require.Error(t, rule("negative", 42.43)) 140 | }) 141 | }) 142 | } 143 | 144 | func TestLenInRange(t *testing.T) { 145 | t.Run("positive", func(t *testing.T) { 146 | rule := schema.LenInRange(5, 10) 147 | require.NoError(t, rule("positive", "hello")) 148 | }) 149 | 150 | t.Run("negative", func(t *testing.T) { 151 | t.Run("wrong len", func(t *testing.T) { 152 | rule := schema.LenInRange(5, 10) 153 | require.Error(t, rule("negative", "hi")) 154 | }) 155 | 156 | t.Run("wrong type", func(t *testing.T) { 157 | rule := schema.LenInRange(5, 10) 158 | require.Error(t, rule("negative", 42)) 159 | }) 160 | }) 161 | } 162 | 163 | func TestMinLen(t *testing.T) { 164 | t.Run("positive", func(t *testing.T) { 165 | rule := schema.MinLen(5) 166 | require.NoError(t, rule("positive", "hello, world!")) 167 | }) 168 | 169 | t.Run("negative", func(t *testing.T) { 170 | t.Run("wrong len", func(t *testing.T) { 171 | rule := schema.MinLen(5) 172 | require.Error(t, rule("negative", "hi")) 173 | }) 174 | 175 | t.Run("wrong type", func(t *testing.T) { 176 | rule := schema.MinLen(5) 177 | require.Error(t, rule("negative", 42)) 178 | }) 179 | }) 180 | } 181 | 182 | func TestMaxLen(t *testing.T) { 183 | t.Run("positive", func(t *testing.T) { 184 | rule := schema.MaxLen(5) 185 | require.NoError(t, rule("positive", "hello")) 186 | }) 187 | 188 | t.Run("negative", func(t *testing.T) { 189 | t.Run("wrong len", func(t *testing.T) { 190 | rule := schema.MaxLen(5) 191 | require.Error(t, rule("negative_len", "hello, world!")) 192 | }) 193 | 194 | t.Run("wrong type", func(t *testing.T) { 195 | rule := schema.MaxLen(5) 196 | require.Error(t, rule("negative_type", 42)) 197 | }) 198 | }) 199 | } 200 | 201 | func TestIs(t *testing.T) { 202 | t.Run("string", func(t *testing.T) { 203 | t.Run("positive", func(t *testing.T) { 204 | rule := schema.Is[string]() 205 | require.NoError(t, rule("string_positive", "Hello, world!")) 206 | }) 207 | 208 | t.Run("negative", func(t *testing.T) { 209 | rule := schema.Is[string]() 210 | require.Error(t, rule("string_negative", 42)) 211 | }) 212 | }) 213 | 214 | t.Run("int64", func(t *testing.T) { 215 | t.Run("positive", func(t *testing.T) { 216 | rule := schema.Is[int64]() 217 | require.NoError(t, rule("int64_positive", int64(42))) 218 | }) 219 | 220 | t.Run("negative", func(t *testing.T) { 221 | rule := schema.Is[int64]() 222 | require.Error(t, rule("int64_negative", "Hello, world!")) 223 | }) 224 | }) 225 | 226 | t.Run("float64", func(t *testing.T) { 227 | t.Run("positive", func(t *testing.T) { 228 | rule := schema.Is[float64]() 229 | require.NoError(t, rule("float64_positive", 42.42)) 230 | }) 231 | 232 | t.Run("negative", func(t *testing.T) { 233 | rule := schema.Is[float64]() 234 | require.Error(t, rule("float64_negative", "Hello, world!")) 235 | }) 236 | }) 237 | } 238 | 239 | func TestEqualsOneOf(t *testing.T) { 240 | values := []any{"positive", "hello", "world", 42.0, 0.75} 241 | 242 | t.Run("positive", func(t *testing.T) { 243 | rule := schema.EqualsOneOf(values...) 244 | 245 | for i, value := range values { 246 | field := schema.Field(fmt.Sprintf("positive_%d", i)) 247 | assert.NoError(t, rule(field, value)) 248 | } 249 | }) 250 | 251 | t.Run("negative", func(t *testing.T) { 252 | rule := schema.EqualsOneOf(values...) 253 | require.Error(t, rule("negative", math.Pi)) 254 | }) 255 | } 256 | -------------------------------------------------------------------------------- /schema/schema.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | type Field string 4 | 5 | // RuleFunc defines a function type for validating a field value and returning an error if validation fails. 6 | type RuleFunc func(field Field, value any) error 7 | 8 | // Schema is a set of Field to RuleFunc pairs which defines constraints for the query validation. 9 | type Schema map[Field]RuleFunc 10 | 11 | type ValueType interface { 12 | string | Numeric | bool 13 | } 14 | 15 | type Numeric interface { 16 | float64 | int64 17 | } 18 | --------------------------------------------------------------------------------