├── .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 |    [](https://goreportcard.com/report/go.tomakado.io/dumbql) [](https://github.com/tomakado/dumbql/actions/workflows/main.yml) [](https://codecov.io/gh/tomakado/dumbql) [](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 |
--------------------------------------------------------------------------------