Skip to content
This repository has been archived by the owner on Feb 11, 2025. It is now read-only.

Commit

Permalink
Implement struct matching (#3)
Browse files Browse the repository at this point in the history
  • Loading branch information
tomakado authored Feb 11, 2025
1 parent 4663ece commit e1b4713
Show file tree
Hide file tree
Showing 13 changed files with 1,904 additions and 101 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
- name: unit-tests
run: |
go test ./... -coverprofile=coverage.out
cat coverage.out | grep -v "query/parser.gen.go" > coverage_filtered.out
cat coverage.out | grep -v "query/parser.gen.go" | grep -v "query/ast.go:81" > coverage_filtered.out
go tool cover -func=coverage_filtered.out
- name: golangci-lint
Expand Down
2 changes: 1 addition & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ linters:
enable:
- cyclop
- decorder
- dupl
- exhaustive
- fatcontext
- funlen
Expand All @@ -29,6 +28,7 @@ linters:
- reassign
- recvcheck
- revive
- testifylint
- testpackage
- tparallel
- unconvert
Expand Down
83 changes: 82 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Simple (dumb) query language and parser for Go.
* [Simple parse](#simple-parse)
* [Validation against schema](#validation-against-schema)
* [Convert to SQL](#convert-to-sql)
* [Match against structs](#match-against-structs)
* [Query syntax](#query-syntax)
* [Field expression](#field-expression)
* [Field expression operators](#field-expression-operators)
Expand All @@ -28,6 +29,7 @@ Simple (dumb) query language and parser for Go.
- One-of/In expressions (`occupation = [designer, "ux analyst"]`)
- Schema validation
- Drop-in usage with [squirrel](https://github.com/Masterminds/squirrel) query builder or SQL drivers directly
- Struct matching with `dumbql` struct tag

## Examples

Expand Down Expand Up @@ -129,11 +131,90 @@ func main() {
// SELECT * FROM users WHERE ((status = ? AND period_months < ?) AND (title = ? OR name = ?))
// [pending 4 hello world John Doe]
}

```

See [dumbql_example_test.go](dumbql_example_test.go)

### Match against structs

```go
package main

import (
"fmt"

"github.com/defer-panic/dumbql"
"github.com/defer-panic/dumbql/match"
"github.com/defer-panic/dumbql/query"
)

type User struct {
ID int64 `dumbql:"id"`
Name string `dumbql:"name"`
Age int64 `dumbql:"age"`
Score float64 `dumbql:"score"`
Location string `dumbql:"location"`
Role string `dumbql:"role"`
}

func main() {
users := []User{
{
ID: 1,
Name: "John Doe",
Age: 30,
Score: 4.5,
Location: "New York",
Role: "admin",
},
{
ID: 2,
Name: "Jane Smith",
Age: 25,
Score: 3.8,
Location: "Los Angeles",
Role: "user",
},
{
ID: 3,
Name: "Bob Johnson",
Age: 35,
Score: 4.2,
Location: "Chicago",
Role: "user",
},
// This one will be dropped:
{
ID: 4,
Name: "Alice Smith",
Age: 25,
Score: 3.8,
Location: "Los Angeles",
Role: "admin",
},
}

q := `(age >= 30 and score > 4.0) or (location:"Los Angeles" and role:"user")`
ast, _ := query.Parse("test", []byte(q))
expr := ast.(query.Expr)

matcher := &match.StructMatcher{}

filtered := make([]User, 0, len(users))

for _, user := range users {
if expr.Match(&user, matcher) {
filtered = append(filtered, user)
}
}

fmt.Println(filtered)
// [{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}]
}
```

See [match_example_test.go](match_example_test.go) for more examples.

## Query syntax

This section is a non-formal description of DumbQL syntax. For strict description see [grammar file](query/grammar.peg).
Expand Down
2 changes: 1 addition & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ tasks:
desc: "Run unit tests"
cmds:
- go test ./... -coverprofile=coverage.out
- cat coverage.out | grep -v "query/parser.gen.go" > coverage_filtered.out
- cat coverage.out | grep -v "query/parser.gen.go" | grep -v "query/ast.go:81" > coverage_filtered.out
- go tool cover -func=coverage_filtered.out

# Codegen
Expand Down
92 changes: 0 additions & 92 deletions dumbql.go
Original file line number Diff line number Diff line change
@@ -1,96 +1,4 @@
// Package dumbql provides simple (dumb) query language and it's parser.
//
// # Features
//
// - Field expressions
// - Boolean expressions
// - One-of/In expressions
// - Schema validation
// - Drop-in usage with github.com/Masterminds/squirrel or SQL drivers directly
//
// # Query syntax
//
// The section below is a non-formal description of DumbQL syntax.
//
// Strict rules are expressed in grammar form:
//
// Expr <- _ e:OrExpr _
// OrExpr <- left:AndExpr rest:(_ ( OrOp ) _ AndExpr)*
// OrOp <- ("OR" / "or")
// AndExpr <- left:NotExpr rest:(_ ( op:AndOp ) _ NotExpr)*
// AndOp <- ("AND" / "and")
// NotExpr <- ("NOT" / "not") _ expr:Primary
// / Primary
// Primary <- ParenExpr / FieldExpr
// ParenExpr <- '(' _ expr:Expr _ ')'
// FieldExpr <- field:Identifier _ op:CmpOp _ value:Value
// Value <- OneOfExpr / String / Number / Identifier
// OneOfValue <- String / Number / Identifier
// Identifier <- AlphaNumeric ("." AlphaNumeric)*
// AlphaNumeric <- [a-zA-Z_][a-zA-Z0-9_]*
// Integer <- '0' / NonZeroDecimalDigit DecimalDigit*
// Number <- '-'? Integer ( '.' DecimalDigit+ )?
// DecimalDigit <- [0-9]
// NonZeroDecimalDigit <- [1-9]
// String <- '"' StringValue '"'
// StringValue <- ( !EscapedChar . / '\\' EscapeSequence )*
// EscapedChar <- [\x00-\x1f"\\]
// EscapeSequence <- SingleCharEscape / UnicodeEscape
// SingleCharEscape <- ["\\/bfnrt]
// UnicodeEscape <- 'u' HexDigit HexDigit HexDigit HexDigit
// HexDigit <- [0-9a-f]i
// CmpOp <- ( ">=" / ">" / "<=" / "<" / "!:" / "!=" / ":" / "=" / "~" )
// OneOfExpr <- '[' _ values:(OneOfValues)? _ ']'
// OneOfValues <- head:OneOfValue tail:(_ ',' _ OneOfValue)*
// _ <- [ \t\r\n]*
//
// # Field expression
//
// Field name & value pair divided by operator. Field name is any alphanumeric identifier (with underscore),
// value can be string, int64 or floa64.
// One-of expression is also supported (see below).
//
// <field_name> <operator> <value>
//
// for example
//
// period_months < 4
//
// # Field expression operators
//
// | Operator | Meaning | Supported types |
// |----------------------|-------------------------------|------------------------------|
// | `:` or `=` | Equal, one of | `int64`, `float64`, `string` |
// | `!=` or `!:` | Not equal | `int64`, `float64`, `string` |
// | `~` | “Like” or “contains” operator | `string` |
// | `>`, `>=`, `<`, `<=` | Comparison | `int64`, `float64` |
//
// # Boolean operators
//
// Multiple field expression can be combined into boolean expressions with `and` (`AND`) or `or` (`OR`) operators:
//
// status:pending and period_months < 4 and (title:"hello world" or name:"John Doe")
//
// # “One of” expression
//
// Sometimes instead of multiple `and`/`or` clauses against the same field:
//
// occupation = designer or occupation = "ux analyst"
//
// it's more convenient to use equivalent “one of” expressions:
//
// occupation: [designer, "ux analyst"]
//
// # Numbers
//
// If number does not have digits after `.` it's treated as integer and stored as int64. And it's float64 otherwise.
//
// # Strings
//
// String is a sequence on Unicode characters surrounded by double quotes (`"`). In some cases like single word
// it's possible to write string value without double quotes.
//
//status:pending and period_months < 4 and (title:"hello world" or name:"John Doe")
package dumbql

import (
Expand Down
73 changes: 73 additions & 0 deletions match/match_example_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package match_test

import (
"fmt"

"github.com/defer-panic/dumbql/match"
"github.com/defer-panic/dumbql/query"
)

type MatchUser struct {
ID int64 `dumbql:"id"`
Name string `dumbql:"name"`
Age int64 `dumbql:"age"`
Score float64 `dumbql:"score"`
Location string `dumbql:"location"`
Role string `dumbql:"role"`
}

func Example() {
users := []MatchUser{
{
ID: 1,
Name: "John Doe",
Age: 30,
Score: 4.5,
Location: "New York",
Role: "admin",
},
{
ID: 2,
Name: "Jane Smith",
Age: 25,
Score: 3.8,
Location: "Los Angeles",
Role: "user",
},
{
ID: 3,
Name: "Bob Johnson",
Age: 35,
Score: 4.2,
Location: "Chicago",
Role: "user",
},
// This one will be dropped:
{
ID: 4,
Name: "Alice Smith",
Age: 25,
Score: 3.8,
Location: "Los Angeles",
Role: "admin",
},
}

q := `(age >= 30 and score > 4.0) or (location:"Los Angeles" and role:"user")`
ast, _ := query.Parse("test", []byte(q))
expr := ast.(query.Expr)

matcher := &match.StructMatcher{}

filtered := make([]MatchUser, 0, len(users))

for _, user := range users {
if expr.Match(&user, matcher) {
filtered = append(filtered, user)
}
}

fmt.Println(filtered)
// Output:
// [{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}]
}
67 changes: 67 additions & 0 deletions match/struct.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package match

import (
"reflect"

"github.com/defer-panic/dumbql/query"
)

// StructMatcher is a basic implementation of the Matcher interface for evaluating query expressions against structs.
// It supports struct tags using the `dumbql` tag name, which allows you to specify a custom field name.
type StructMatcher struct{}

func (m *StructMatcher) MatchAnd(target any, left, right query.Expr) bool {
return left.Match(target, m) && right.Match(target, m)
}

func (m *StructMatcher) MatchOr(target any, left, right query.Expr) bool {
return left.Match(target, m) || right.Match(target, m)
}

func (m *StructMatcher) MatchNot(target any, expr query.Expr) bool {
return !expr.Match(target, m)
}

// MatchField matches a field in the target struct using the provided value and operator. It supports struct tags using
// the `dumbql` tag name, which allows you to specify a custom field name. If struct tag is not provided, it will use
// the field name as is.
func (m *StructMatcher) MatchField(target any, field string, value query.Valuer, op query.FieldOperator) bool {
t := reflect.TypeOf(target)

if t.Kind() == reflect.Ptr {
t = t.Elem()
}

if t.Kind() != reflect.Struct {
return false
}

for i := 0; i < t.NumField(); i++ {
f := t.Field(i)

tag := f.Tag.Get("dumbql")
if tag == "-" {
return true // Field marked with dumbql:"-" always match (in other words does not affect the result)
}

fname := f.Name
if tag != "" {
fname = tag
}

if fname == field {
v := reflect.ValueOf(target)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}

return m.MatchValue(v.Field(i).Interface(), value, op)
}
}

return true
}

func (m *StructMatcher) MatchValue(target any, value query.Valuer, op query.FieldOperator) bool {
return value.Match(target, op)
}
Loading

0 comments on commit e1b4713

Please sign in to comment.