From c2a063fb42cb832f8e7388a53fa504a2e9878062 Mon Sep 17 00:00:00 2001 From: Ildar Karymov Date: Tue, 11 Feb 2025 19:26:50 +0100 Subject: [PATCH] Refactor and implement `dumbql:"-"` (omit) struct tag --- README.md | 4 +- match/match_example_test.go | 73 ++ match/struct.go | 67 ++ .../struct_example_test.go | 85 ++- match/struct_test.go | 650 ++++++++++++++++ query/match.go | 56 -- query/match_test.go | 711 ++---------------- 7 files changed, 932 insertions(+), 714 deletions(-) create mode 100644 match/match_example_test.go create mode 100644 match/struct.go rename match_example_test.go => match/struct_example_test.go (72%) create mode 100644 match/struct_test.go diff --git a/README.md b/README.md index f4d88a0..af3ac96 100644 --- a/README.md +++ b/README.md @@ -29,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 @@ -143,6 +144,7 @@ import ( "fmt" "github.com/defer-panic/dumbql" + "github.com/defer-panic/dumbql/match" "github.com/defer-panic/dumbql/query" ) @@ -196,7 +198,7 @@ func main() { ast, _ := query.Parse("test", []byte(q)) expr := ast.(query.Expr) - matcher := &query.DefaultMatcher{} + matcher := &match.StructMatcher{} filtered := make([]User, 0, len(users)) diff --git a/match/match_example_test.go b/match/match_example_test.go new file mode 100644 index 0000000..7e8e654 --- /dev/null +++ b/match/match_example_test.go @@ -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}] +} diff --git a/match/struct.go b/match/struct.go new file mode 100644 index 0000000..f1b6dd0 --- /dev/null +++ b/match/struct.go @@ -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) +} diff --git a/match_example_test.go b/match/struct_example_test.go similarity index 72% rename from match_example_test.go rename to match/struct_example_test.go index a47bae2..9146277 100644 --- a/match_example_test.go +++ b/match/struct_example_test.go @@ -1,8 +1,9 @@ -package dumbql_test +package match_test import ( "fmt" + "github.com/defer-panic/dumbql/match" "github.com/defer-panic/dumbql/query" ) @@ -15,7 +16,7 @@ type User struct { Role string `dumbql:"role"` } -func Example_simpleMatching() { +func ExampleStructMatcher_MatchField_simpleMatching() { user := &User{ ID: 1, Name: "John Doe", @@ -31,14 +32,14 @@ func Example_simpleMatching() { expr := ast.(query.Expr) // Create a matcher - matcher := &query.DefaultMatcher{} + matcher := &match.StructMatcher{} result := expr.Match(user, matcher) fmt.Printf("%s: %v\n", q, result) // Output: name = "John Doe": true } -func Example_complexMatching() { +func ExampleStructMatcher_MatchField_complexMatching() { user := &User{ ID: 1, Name: "John Doe", @@ -54,14 +55,14 @@ func Example_complexMatching() { expr := ast.(query.Expr) // Create a matcher - matcher := &query.DefaultMatcher{} + matcher := &match.StructMatcher{} result := expr.Match(user, matcher) fmt.Printf("%s: %v\n", q, result) // Output: age >= 25 and location:["New York", "Los Angeles"] and score > 4.0: true } -func Example_numericComparisons() { +func ExampleStructMatcher_MatchField_numericComparisons() { user := &User{ ID: 1, Name: "John Doe", @@ -81,7 +82,7 @@ func Example_numericComparisons() { `score < 5.0`, } - matcher := &query.DefaultMatcher{} + matcher := &match.StructMatcher{} for _, q := range queries { ast, _ := query.Parse("test", []byte(q)) @@ -97,7 +98,7 @@ func Example_numericComparisons() { // Query 'score < 5.0' match result: true } -func Example_stringOperations() { +func ExampleStructMatcher_MatchField_stringOperations() { user := &User{ ID: 1, Name: "John Doe", @@ -115,7 +116,7 @@ func Example_stringOperations() { `role:admin`, } - matcher := &query.DefaultMatcher{} + matcher := &match.StructMatcher{} for _, q := range queries { ast, _ := query.Parse("test", []byte(q)) @@ -130,7 +131,7 @@ func Example_stringOperations() { // Query 'role:admin' match result: true } -func Example_notExpressions() { +func ExampleStructMatcher_MatchField_notExpressions() { user := &User{ ID: 1, Name: "John Doe", @@ -147,7 +148,7 @@ func Example_notExpressions() { `not (role:"user" and score < 3.0)`, } - matcher := &query.DefaultMatcher{} + matcher := &match.StructMatcher{} for _, q := range queries { ast, _ := query.Parse("test", []byte(q)) @@ -161,7 +162,7 @@ func Example_notExpressions() { // Query 'not (role:"user" and score < 3.0)' match result: true } -func Example_multiMatch() { +func ExampleStructMatcher_MatchField_multiMatch() { users := []User{ { ID: 1, @@ -202,7 +203,7 @@ func Example_multiMatch() { ast, _ := query.Parse("test", []byte(q)) expr := ast.(query.Expr) - matcher := &query.DefaultMatcher{} + matcher := &match.StructMatcher{} filtered := make([]User, 0, len(users)) @@ -217,7 +218,7 @@ func Example_multiMatch() { // [{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}] } -func Example_oneOfExpression() { +func ExampleStructMatcher_MatchField_oneOfExpression() { user := &User{ ID: 1, Name: "John Doe", @@ -234,7 +235,7 @@ func Example_oneOfExpression() { `age:[25, 30, 35]`, } - matcher := &query.DefaultMatcher{} + matcher := &match.StructMatcher{} for _, q := range queries { ast, _ := query.Parse("test", []byte(q)) @@ -248,7 +249,7 @@ func Example_oneOfExpression() { // Query 'age:[25, 30, 35]' match result: true } -func Example_edgeCases() { +func ExampleStructMatcher_MatchField_edgeCases() { user := &User{ ID: 1, Name: "John Doe", @@ -271,7 +272,7 @@ func Example_edgeCases() { `(age > 20 and age < 40) and (score >= 4.0 or role:"admin")`, } - matcher := &query.DefaultMatcher{} + matcher := &match.StructMatcher{} for _, q := range queries { ast, _ := query.Parse("test", []byte(q)) @@ -279,11 +280,57 @@ func Example_edgeCases() { result := expr.Match(user, matcher) fmt.Printf("Query '%s' match result: %v\n", q, result) } - // Output: - // Query 'nonexistent:"value"' match result: false + // Query 'nonexistent:"value"' match result: true // Query 'age:"not a number"' match result: false // Query 'name:""' match result: false // Query 'score:0' match result: false // Query '(age > 20 and age < 40) and (score >= 4.0 or role:"admin")' match result: true } + +func ExampleStructMatcher_MatchField_structTagOmit() { + type User struct { + ID int64 `dumbql:"id"` + Name string `dumbql:"name"` + Password string `dumbql:"-"` // Omitted from querying + Internal bool `dumbql:"-"` // Omitted from querying + Score float64 `dumbql:"score"` + } + + user := &User{ + ID: 1, + Name: "John", + Password: "secret123", + Internal: true, + Score: 4.5, + } + + // Test various queries including omitted fields + queries := []string{ + // Query against visible field + `id:1`, + // Query against omitted field - always matches + `password:"wrong_password"`, + // Query against omitted boolean field - always matches + `internal:false`, + // Combined visible and omitted fields + `id:1 and password:"wrong_password"`, + // Complex query with omitted fields + `(id:1 or score > 4.0) and (password:"wrong" or internal:false)`, + } + + matcher := &match.StructMatcher{} + + for _, q := range queries { + ast, _ := query.Parse("test", []byte(q)) + expr := ast.(query.Expr) + result := expr.Match(user, matcher) + fmt.Printf("Query '%s' match result: %v\n", q, result) + } + // Output: + // Query 'id:1' match result: true + // Query 'password:"wrong_password"' match result: true + // Query 'internal:false' match result: true + // Query 'id:1 and password:"wrong_password"' match result: true + // Query '(id:1 or score > 4.0) and (password:"wrong" or internal:false)' match result: true +} diff --git a/match/struct_test.go b/match/struct_test.go new file mode 100644 index 0000000..490e155 --- /dev/null +++ b/match/struct_test.go @@ -0,0 +1,650 @@ +package match_test + +import ( + "testing" + + "github.com/defer-panic/dumbql/match" + "github.com/defer-panic/dumbql/query" + "github.com/stretchr/testify/assert" +) + +type person struct { + Name string `dumbql:"name"` + Age int64 `dumbql:"age"` + Height float64 `dumbql:"height"` + IsMember bool +} + +func TestStructMatcher_MatchAnd(t *testing.T) { //nolint:funlen + matcher := &match.StructMatcher{} + target := person{Name: "John", Age: 30} + + tests := []struct { + name string + left query.Expr + right query.Expr + want bool + }{ + { + name: "both conditions true", + left: &query.FieldExpr{ + Field: "name", + Op: query.Equal, + Value: &query.StringLiteral{StringValue: "John"}, + }, + right: &query.FieldExpr{ + Field: "age", + Op: query.Equal, + Value: &query.IntegerLiteral{IntegerValue: 30}, + }, + want: true, + }, + { + name: "left condition false", + left: &query.FieldExpr{ + Field: "name", + Op: query.Equal, + Value: &query.StringLiteral{StringValue: "Jane"}, + }, + right: &query.FieldExpr{ + Field: "age", + Op: query.Equal, + Value: &query.IntegerLiteral{IntegerValue: 30}, + }, + want: false, + }, + { + name: "right condition false", + left: &query.FieldExpr{ + Field: "name", + Op: query.Equal, + Value: &query.StringLiteral{StringValue: "John"}, + }, + right: &query.FieldExpr{ + Field: "age", + Op: query.Equal, + Value: &query.IntegerLiteral{IntegerValue: 25}, + }, + want: false, + }, + { + name: "both conditions false", + left: &query.FieldExpr{ + Field: "name", + Op: query.Equal, + Value: &query.StringLiteral{StringValue: "Jane"}, + }, + right: &query.FieldExpr{ + Field: "age", + Op: query.Equal, + Value: &query.IntegerLiteral{IntegerValue: 25}, + }, + want: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := matcher.MatchAnd(target, test.left, test.right) + assert.Equal(t, test.want, result) + }) + } +} + +func TestStructMatcher_MatchOr(t *testing.T) { //nolint:funlen + matcher := &match.StructMatcher{} + target := person{Name: "John", Age: 30} + + tests := []struct { + name string + left query.Expr + right query.Expr + want bool + }{ + { + name: "both conditions true", + left: &query.FieldExpr{ + Field: "name", + Op: query.Equal, + Value: &query.StringLiteral{StringValue: "John"}, + }, + right: &query.FieldExpr{ + Field: "age", + Op: query.Equal, + Value: &query.IntegerLiteral{IntegerValue: 30}, + }, + want: true, + }, + { + name: "left condition true only", + left: &query.FieldExpr{ + Field: "name", + Op: query.Equal, + Value: &query.StringLiteral{StringValue: "John"}, + }, + right: &query.FieldExpr{ + Field: "age", + Op: query.Equal, + Value: &query.IntegerLiteral{IntegerValue: 25}, + }, + want: true, + }, + { + name: "right condition true only", + left: &query.FieldExpr{ + Field: "name", + Op: query.Equal, + Value: &query.StringLiteral{StringValue: "Jane"}, + }, + right: &query.FieldExpr{ + Field: "age", + Op: query.Equal, + Value: &query.IntegerLiteral{IntegerValue: 30}, + }, + want: true, + }, + { + name: "both conditions false", + left: &query.FieldExpr{ + Field: "name", + Op: query.Equal, + Value: &query.StringLiteral{StringValue: "Jane"}, + }, + right: &query.FieldExpr{ + Field: "age", + Op: query.Equal, + Value: &query.IntegerLiteral{IntegerValue: 25}, + }, + want: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := matcher.MatchOr(target, test.left, test.right) + assert.Equal(t, test.want, result) + }) + } +} + +func TestStructMatcher_MatchNot(t *testing.T) { + matcher := &match.StructMatcher{} + target := person{Name: "John", Age: 30} + + tests := []struct { + name string + expr query.Expr + want bool + }{ + { + name: "negate true condition", + expr: &query.FieldExpr{ + Field: "name", + Op: query.Equal, + Value: &query.StringLiteral{StringValue: "John"}, + }, + want: false, + }, + { + name: "negate false condition", + expr: &query.FieldExpr{ + Field: "name", + Op: query.Equal, + Value: &query.StringLiteral{StringValue: "Jane"}, + }, + want: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := matcher.MatchNot(target, test.expr) + assert.Equal(t, test.want, result) + }) + } +} + +func TestStructMatcher_MatchField(t *testing.T) { + matcher := &match.StructMatcher{} + target := person{ + Name: "John", + Age: 30, + Height: 1.75, + IsMember: true, + } + + tests := []struct { + name string + field string + value query.Valuer + op query.FieldOperator + want bool + }{ + { + name: "string equal match", + field: "name", + value: &query.StringLiteral{StringValue: "John"}, + op: query.Equal, + want: true, + }, + { + name: "string not equal match", + field: "name", + value: &query.StringLiteral{StringValue: "Jane"}, + op: query.NotEqual, + want: true, + }, + { + name: "integer equal match", + field: "age", + value: &query.IntegerLiteral{IntegerValue: 30}, + op: query.Equal, + want: true, + }, + { + name: "float greater than match", + field: "height", + value: &query.NumberLiteral{NumberValue: 1.70}, + op: query.GreaterThan, + want: true, + }, + { + name: "non-existent field", + field: "invalid", + value: &query.StringLiteral{StringValue: "test"}, + op: query.Equal, + want: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := matcher.MatchField(target, test.field, test.value, test.op) + assert.Equal(t, test.want, result) + }) + } +} + +func TestStructMatcher_MatchValue(t *testing.T) { + t.Run("string", testMatchValueString) + t.Run("integer", testMatchValueInteger) + t.Run("float", testMatchValueFloat) + t.Run("type mismatch", testMatchValueTypeMismatch) +} + +func testMatchValueString(t *testing.T) { //nolint:funlen + matcher := &match.StructMatcher{} + tests := []struct { + name string + target any + value query.Valuer + op query.FieldOperator + want bool + }{ + { + name: "equal - match", + target: "hello", + value: &query.StringLiteral{StringValue: "hello"}, + op: query.Equal, + want: true, + }, + { + name: "equal - no match", + target: "hello", + value: &query.StringLiteral{StringValue: "world"}, + op: query.Equal, + want: false, + }, + { + name: "not equal - match", + target: "hello", + value: &query.StringLiteral{StringValue: "world"}, + op: query.NotEqual, + want: true, + }, + { + name: "not equal - no match", + target: "hello", + value: &query.StringLiteral{StringValue: "hello"}, + op: query.NotEqual, + want: false, + }, + { + name: "like - match", + target: "hello world", + value: &query.StringLiteral{StringValue: "world"}, + op: query.Like, + want: true, + }, + { + name: "like - no match", + target: "hello world", + value: &query.StringLiteral{StringValue: "universe"}, + op: query.Like, + want: false, + }, + { + name: "greater than - invalid", + target: "hello", + value: &query.StringLiteral{StringValue: "world"}, + op: query.GreaterThan, + want: false, + }, + { + name: "greater than or equal - invalid", + target: "hello", + value: &query.StringLiteral{StringValue: "world"}, + op: query.GreaterThanOrEqual, + want: false, + }, + { + name: "less than - invalid", + target: "hello", + value: &query.StringLiteral{StringValue: "world"}, + op: query.LessThan, + want: false, + }, + { + name: "less than or equal - invalid", + target: "hello", + value: &query.StringLiteral{StringValue: "world"}, + op: query.LessThanOrEqual, + want: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := matcher.MatchValue(test.target, test.value, test.op) + assert.Equal(t, test.want, result) + }) + } +} + +func testMatchValueInteger(t *testing.T) { //nolint:funlen + matcher := &match.StructMatcher{} + tests := []struct { + name string + target any + value query.Valuer + op query.FieldOperator + want bool + }{ + { + name: "equal - match", + target: int64(42), + value: &query.IntegerLiteral{IntegerValue: 42}, + op: query.Equal, + want: true, + }, + { + name: "equal - no match", + target: int64(42), + value: &query.IntegerLiteral{IntegerValue: 24}, + op: query.Equal, + want: false, + }, + { + name: "not equal - match", + target: int64(42), + value: &query.IntegerLiteral{IntegerValue: 24}, + op: query.NotEqual, + want: true, + }, + { + name: "not equal - no match", + target: int64(42), + value: &query.IntegerLiteral{IntegerValue: 42}, + op: query.NotEqual, + want: false, + }, + { + name: "greater than - match", + target: int64(42), + value: &query.IntegerLiteral{IntegerValue: 24}, + op: query.GreaterThan, + want: true, + }, + { + name: "greater than - no match", + target: int64(24), + value: &query.IntegerLiteral{IntegerValue: 42}, + op: query.GreaterThan, + want: false, + }, + { + name: "greater than or equal - match (greater)", + target: int64(42), + value: &query.IntegerLiteral{IntegerValue: 24}, + op: query.GreaterThanOrEqual, + want: true, + }, + { + name: "greater than or equal - match (equal)", + target: int64(42), + value: &query.IntegerLiteral{IntegerValue: 42}, + op: query.GreaterThanOrEqual, + want: true, + }, + { + name: "greater than or equal - no match", + target: int64(24), + value: &query.IntegerLiteral{IntegerValue: 42}, + op: query.GreaterThanOrEqual, + want: false, + }, + { + name: "less than - match", + target: int64(24), + value: &query.IntegerLiteral{IntegerValue: 42}, + op: query.LessThan, + want: true, + }, + { + name: "less than - no match", + target: int64(42), + value: &query.IntegerLiteral{IntegerValue: 24}, + op: query.LessThan, + want: false, + }, + { + name: "less than or equal - match (less)", + target: int64(24), + value: &query.IntegerLiteral{IntegerValue: 42}, + op: query.LessThanOrEqual, + want: true, + }, + { + name: "less than or equal - match (equal)", + target: int64(42), + value: &query.IntegerLiteral{IntegerValue: 42}, + op: query.LessThanOrEqual, + want: true, + }, + { + name: "less than or equal - no match", + target: int64(42), + value: &query.IntegerLiteral{IntegerValue: 24}, + op: query.LessThanOrEqual, + want: false, + }, + { + name: "like - invalid", + target: int64(42), + value: &query.IntegerLiteral{IntegerValue: 24}, + op: query.Like, + want: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := matcher.MatchValue(test.target, test.value, test.op) + assert.Equal(t, test.want, result) + }) + } +} + +func testMatchValueFloat(t *testing.T) { //nolint:funlen + matcher := &match.StructMatcher{} + tests := []struct { + name string + target any + value query.Valuer + op query.FieldOperator + want bool + }{ + { + name: "equal - match", + target: 3.14, + value: &query.NumberLiteral{NumberValue: 3.14}, + op: query.Equal, + want: true, + }, + { + name: "equal - no match", + target: 3.14, + value: &query.NumberLiteral{NumberValue: 2.718}, + op: query.Equal, + want: false, + }, + { + name: "not equal - match", + target: 3.14, + value: &query.NumberLiteral{NumberValue: 2.718}, + op: query.NotEqual, + want: true, + }, + { + name: "not equal - no match", + target: 3.14, + value: &query.NumberLiteral{NumberValue: 3.14}, + op: query.NotEqual, + want: false, + }, + { + name: "greater than - match", + target: 3.14, + value: &query.NumberLiteral{NumberValue: 2.718}, + op: query.GreaterThan, + want: true, + }, + { + name: "greater than - no match", + target: 2.718, + value: &query.NumberLiteral{NumberValue: 3.14}, + op: query.GreaterThan, + want: false, + }, + { + name: "greater than or equal - match (greater)", + target: 3.14, + value: &query.NumberLiteral{NumberValue: 2.718}, + op: query.GreaterThanOrEqual, + want: true, + }, + { + name: "greater than or equal - match (equal)", + target: 3.14, + value: &query.NumberLiteral{NumberValue: 3.14}, + op: query.GreaterThanOrEqual, + want: true, + }, + { + name: "greater than or equal - no match", + target: 2.718, + value: &query.NumberLiteral{NumberValue: 3.14}, + op: query.GreaterThanOrEqual, + want: false, + }, + { + name: "less than - match", + target: 2.718, + value: &query.NumberLiteral{NumberValue: 3.14}, + op: query.LessThan, + want: true, + }, + { + name: "less than - no match", + target: 3.14, + value: &query.NumberLiteral{NumberValue: 2.718}, + op: query.LessThan, + want: false, + }, + { + name: "less than or equal - match (less)", + target: 2.718, + value: &query.NumberLiteral{NumberValue: 3.14}, + op: query.LessThanOrEqual, + want: true, + }, + { + name: "less than or equal - match (equal)", + target: 3.14, + value: &query.NumberLiteral{NumberValue: 3.14}, + op: query.LessThanOrEqual, + want: true, + }, + { + name: "less than or equal - no match", + target: 3.14, + value: &query.NumberLiteral{NumberValue: 2.718}, + op: query.LessThanOrEqual, + want: false, + }, + { + name: "like - invalid", + target: 3.14, + value: &query.NumberLiteral{NumberValue: 2.718}, + op: query.Like, + want: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := matcher.MatchValue(test.target, test.value, test.op) + assert.Equal(t, test.want, result) + }) + } +} + +func testMatchValueTypeMismatch(t *testing.T) { + matcher := &match.StructMatcher{} + tests := []struct { + name string + target any + value query.Valuer + op query.FieldOperator + want bool + }{ + { + name: "string target with integer value", + target: "42", + value: &query.IntegerLiteral{IntegerValue: 42}, + op: query.Equal, + want: false, + }, + { + name: "integer target with float value", + target: int64(42), + value: &query.NumberLiteral{NumberValue: 42.0}, + op: query.Equal, + want: false, + }, + { + name: "float target with string value", + target: 3.14, + value: &query.StringLiteral{StringValue: "3.14"}, + op: query.Equal, + want: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := matcher.MatchValue(test.target, test.value, test.op) + assert.Equal(t, test.want, result) + }) + } +} diff --git a/query/match.go b/query/match.go index 3beeece..2ff5acd 100644 --- a/query/match.go +++ b/query/match.go @@ -1,7 +1,6 @@ package query import ( - "reflect" "strings" ) @@ -13,61 +12,6 @@ type Matcher interface { MatchValue(target any, value Valuer, op FieldOperator) bool } -// DefaultMatcher 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 DefaultMatcher struct{} - -func (m *DefaultMatcher) MatchAnd(target any, left, right Expr) bool { - return left.Match(target, m) && right.Match(target, m) -} - -func (m *DefaultMatcher) MatchOr(target any, left, right Expr) bool { - return left.Match(target, m) || right.Match(target, m) -} - -func (m *DefaultMatcher) MatchNot(target any, expr 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 *DefaultMatcher) MatchField(target any, field string, value Valuer, op 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) - - fname := f.Name - if f.Tag.Get("dumbql") != "" { - fname = f.Tag.Get("dumbql") - } - - 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 false -} - -func (m *DefaultMatcher) MatchValue(target any, value Valuer, op FieldOperator) bool { - return value.Match(target, op) -} - func (b *BinaryExpr) Match(target any, matcher Matcher) bool { switch b.Op { case And: diff --git a/query/match_test.go b/query/match_test.go index cff5aa6..cd14b04 100644 --- a/query/match_test.go +++ b/query/match_test.go @@ -3,8 +3,10 @@ package query_test import ( "testing" + "github.com/defer-panic/dumbql/match" "github.com/defer-panic/dumbql/query" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type person struct { @@ -14,643 +16,9 @@ type person struct { IsMember bool } -func TestDefaultMatcher_MatchAnd(t *testing.T) { //nolint:funlen - matcher := &query.DefaultMatcher{} - target := person{Name: "John", Age: 30} - - tests := []struct { - name string - left query.Expr - right query.Expr - want bool - }{ - { - name: "both conditions true", - left: &query.FieldExpr{ - Field: "name", - Op: query.Equal, - Value: &query.StringLiteral{StringValue: "John"}, - }, - right: &query.FieldExpr{ - Field: "age", - Op: query.Equal, - Value: &query.IntegerLiteral{IntegerValue: 30}, - }, - want: true, - }, - { - name: "left condition false", - left: &query.FieldExpr{ - Field: "name", - Op: query.Equal, - Value: &query.StringLiteral{StringValue: "Jane"}, - }, - right: &query.FieldExpr{ - Field: "age", - Op: query.Equal, - Value: &query.IntegerLiteral{IntegerValue: 30}, - }, - want: false, - }, - { - name: "right condition false", - left: &query.FieldExpr{ - Field: "name", - Op: query.Equal, - Value: &query.StringLiteral{StringValue: "John"}, - }, - right: &query.FieldExpr{ - Field: "age", - Op: query.Equal, - Value: &query.IntegerLiteral{IntegerValue: 25}, - }, - want: false, - }, - { - name: "both conditions false", - left: &query.FieldExpr{ - Field: "name", - Op: query.Equal, - Value: &query.StringLiteral{StringValue: "Jane"}, - }, - right: &query.FieldExpr{ - Field: "age", - Op: query.Equal, - Value: &query.IntegerLiteral{IntegerValue: 25}, - }, - want: false, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - result := matcher.MatchAnd(target, test.left, test.right) - assert.Equal(t, test.want, result) - }) - } -} - -func TestDefaultMatcher_MatchOr(t *testing.T) { //nolint:funlen - matcher := &query.DefaultMatcher{} - target := person{Name: "John", Age: 30} - - tests := []struct { - name string - left query.Expr - right query.Expr - want bool - }{ - { - name: "both conditions true", - left: &query.FieldExpr{ - Field: "name", - Op: query.Equal, - Value: &query.StringLiteral{StringValue: "John"}, - }, - right: &query.FieldExpr{ - Field: "age", - Op: query.Equal, - Value: &query.IntegerLiteral{IntegerValue: 30}, - }, - want: true, - }, - { - name: "left condition true only", - left: &query.FieldExpr{ - Field: "name", - Op: query.Equal, - Value: &query.StringLiteral{StringValue: "John"}, - }, - right: &query.FieldExpr{ - Field: "age", - Op: query.Equal, - Value: &query.IntegerLiteral{IntegerValue: 25}, - }, - want: true, - }, - { - name: "right condition true only", - left: &query.FieldExpr{ - Field: "name", - Op: query.Equal, - Value: &query.StringLiteral{StringValue: "Jane"}, - }, - right: &query.FieldExpr{ - Field: "age", - Op: query.Equal, - Value: &query.IntegerLiteral{IntegerValue: 30}, - }, - want: true, - }, - { - name: "both conditions false", - left: &query.FieldExpr{ - Field: "name", - Op: query.Equal, - Value: &query.StringLiteral{StringValue: "Jane"}, - }, - right: &query.FieldExpr{ - Field: "age", - Op: query.Equal, - Value: &query.IntegerLiteral{IntegerValue: 25}, - }, - want: false, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - result := matcher.MatchOr(target, test.left, test.right) - assert.Equal(t, test.want, result) - }) - } -} - -func TestDefaultMatcher_MatchNot(t *testing.T) { - matcher := &query.DefaultMatcher{} - target := person{Name: "John", Age: 30} - - tests := []struct { - name string - expr query.Expr - want bool - }{ - { - name: "negate true condition", - expr: &query.FieldExpr{ - Field: "name", - Op: query.Equal, - Value: &query.StringLiteral{StringValue: "John"}, - }, - want: false, - }, - { - name: "negate false condition", - expr: &query.FieldExpr{ - Field: "name", - Op: query.Equal, - Value: &query.StringLiteral{StringValue: "Jane"}, - }, - want: true, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - result := matcher.MatchNot(target, test.expr) - assert.Equal(t, test.want, result) - }) - } -} - -func TestDefaultMatcher_MatchField(t *testing.T) { - matcher := &query.DefaultMatcher{} - target := person{ - Name: "John", - Age: 30, - Height: 1.75, - IsMember: true, - } - - tests := []struct { - name string - field string - value query.Valuer - op query.FieldOperator - want bool - }{ - { - name: "string equal match", - field: "name", - value: &query.StringLiteral{StringValue: "John"}, - op: query.Equal, - want: true, - }, - { - name: "string not equal match", - field: "name", - value: &query.StringLiteral{StringValue: "Jane"}, - op: query.NotEqual, - want: true, - }, - { - name: "integer equal match", - field: "age", - value: &query.IntegerLiteral{IntegerValue: 30}, - op: query.Equal, - want: true, - }, - { - name: "float greater than match", - field: "height", - value: &query.NumberLiteral{NumberValue: 1.70}, - op: query.GreaterThan, - want: true, - }, - { - name: "non-existent field", - field: "invalid", - value: &query.StringLiteral{StringValue: "test"}, - op: query.Equal, - want: false, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - result := matcher.MatchField(target, test.field, test.value, test.op) - assert.Equal(t, test.want, result) - }) - } -} - -func TestDefaultMatcher_MatchValue(t *testing.T) { - t.Run("string", testMatchValueString) - t.Run("integer", testMatchValueInteger) - t.Run("float", testMatchValueFloat) - t.Run("type mismatch", testMatchValueTypeMismatch) -} - -func testMatchValueString(t *testing.T) { //nolint:funlen - matcher := &query.DefaultMatcher{} - tests := []struct { - name string - target any - value query.Valuer - op query.FieldOperator - want bool - }{ - { - name: "equal - match", - target: "hello", - value: &query.StringLiteral{StringValue: "hello"}, - op: query.Equal, - want: true, - }, - { - name: "equal - no match", - target: "hello", - value: &query.StringLiteral{StringValue: "world"}, - op: query.Equal, - want: false, - }, - { - name: "not equal - match", - target: "hello", - value: &query.StringLiteral{StringValue: "world"}, - op: query.NotEqual, - want: true, - }, - { - name: "not equal - no match", - target: "hello", - value: &query.StringLiteral{StringValue: "hello"}, - op: query.NotEqual, - want: false, - }, - { - name: "like - match", - target: "hello world", - value: &query.StringLiteral{StringValue: "world"}, - op: query.Like, - want: true, - }, - { - name: "like - no match", - target: "hello world", - value: &query.StringLiteral{StringValue: "universe"}, - op: query.Like, - want: false, - }, - { - name: "greater than - invalid", - target: "hello", - value: &query.StringLiteral{StringValue: "world"}, - op: query.GreaterThan, - want: false, - }, - { - name: "greater than or equal - invalid", - target: "hello", - value: &query.StringLiteral{StringValue: "world"}, - op: query.GreaterThanOrEqual, - want: false, - }, - { - name: "less than - invalid", - target: "hello", - value: &query.StringLiteral{StringValue: "world"}, - op: query.LessThan, - want: false, - }, - { - name: "less than or equal - invalid", - target: "hello", - value: &query.StringLiteral{StringValue: "world"}, - op: query.LessThanOrEqual, - want: false, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - result := matcher.MatchValue(test.target, test.value, test.op) - assert.Equal(t, test.want, result) - }) - } -} - -func testMatchValueInteger(t *testing.T) { //nolint:funlen - matcher := &query.DefaultMatcher{} - tests := []struct { - name string - target any - value query.Valuer - op query.FieldOperator - want bool - }{ - { - name: "equal - match", - target: int64(42), - value: &query.IntegerLiteral{IntegerValue: 42}, - op: query.Equal, - want: true, - }, - { - name: "equal - no match", - target: int64(42), - value: &query.IntegerLiteral{IntegerValue: 24}, - op: query.Equal, - want: false, - }, - { - name: "not equal - match", - target: int64(42), - value: &query.IntegerLiteral{IntegerValue: 24}, - op: query.NotEqual, - want: true, - }, - { - name: "not equal - no match", - target: int64(42), - value: &query.IntegerLiteral{IntegerValue: 42}, - op: query.NotEqual, - want: false, - }, - { - name: "greater than - match", - target: int64(42), - value: &query.IntegerLiteral{IntegerValue: 24}, - op: query.GreaterThan, - want: true, - }, - { - name: "greater than - no match", - target: int64(24), - value: &query.IntegerLiteral{IntegerValue: 42}, - op: query.GreaterThan, - want: false, - }, - { - name: "greater than or equal - match (greater)", - target: int64(42), - value: &query.IntegerLiteral{IntegerValue: 24}, - op: query.GreaterThanOrEqual, - want: true, - }, - { - name: "greater than or equal - match (equal)", - target: int64(42), - value: &query.IntegerLiteral{IntegerValue: 42}, - op: query.GreaterThanOrEqual, - want: true, - }, - { - name: "greater than or equal - no match", - target: int64(24), - value: &query.IntegerLiteral{IntegerValue: 42}, - op: query.GreaterThanOrEqual, - want: false, - }, - { - name: "less than - match", - target: int64(24), - value: &query.IntegerLiteral{IntegerValue: 42}, - op: query.LessThan, - want: true, - }, - { - name: "less than - no match", - target: int64(42), - value: &query.IntegerLiteral{IntegerValue: 24}, - op: query.LessThan, - want: false, - }, - { - name: "less than or equal - match (less)", - target: int64(24), - value: &query.IntegerLiteral{IntegerValue: 42}, - op: query.LessThanOrEqual, - want: true, - }, - { - name: "less than or equal - match (equal)", - target: int64(42), - value: &query.IntegerLiteral{IntegerValue: 42}, - op: query.LessThanOrEqual, - want: true, - }, - { - name: "less than or equal - no match", - target: int64(42), - value: &query.IntegerLiteral{IntegerValue: 24}, - op: query.LessThanOrEqual, - want: false, - }, - { - name: "like - invalid", - target: int64(42), - value: &query.IntegerLiteral{IntegerValue: 24}, - op: query.Like, - want: false, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - result := matcher.MatchValue(test.target, test.value, test.op) - assert.Equal(t, test.want, result) - }) - } -} - -func testMatchValueFloat(t *testing.T) { //nolint:funlen - matcher := &query.DefaultMatcher{} - tests := []struct { - name string - target any - value query.Valuer - op query.FieldOperator - want bool - }{ - { - name: "equal - match", - target: 3.14, - value: &query.NumberLiteral{NumberValue: 3.14}, - op: query.Equal, - want: true, - }, - { - name: "equal - no match", - target: 3.14, - value: &query.NumberLiteral{NumberValue: 2.718}, - op: query.Equal, - want: false, - }, - { - name: "not equal - match", - target: 3.14, - value: &query.NumberLiteral{NumberValue: 2.718}, - op: query.NotEqual, - want: true, - }, - { - name: "not equal - no match", - target: 3.14, - value: &query.NumberLiteral{NumberValue: 3.14}, - op: query.NotEqual, - want: false, - }, - { - name: "greater than - match", - target: 3.14, - value: &query.NumberLiteral{NumberValue: 2.718}, - op: query.GreaterThan, - want: true, - }, - { - name: "greater than - no match", - target: 2.718, - value: &query.NumberLiteral{NumberValue: 3.14}, - op: query.GreaterThan, - want: false, - }, - { - name: "greater than or equal - match (greater)", - target: 3.14, - value: &query.NumberLiteral{NumberValue: 2.718}, - op: query.GreaterThanOrEqual, - want: true, - }, - { - name: "greater than or equal - match (equal)", - target: 3.14, - value: &query.NumberLiteral{NumberValue: 3.14}, - op: query.GreaterThanOrEqual, - want: true, - }, - { - name: "greater than or equal - no match", - target: 2.718, - value: &query.NumberLiteral{NumberValue: 3.14}, - op: query.GreaterThanOrEqual, - want: false, - }, - { - name: "less than - match", - target: 2.718, - value: &query.NumberLiteral{NumberValue: 3.14}, - op: query.LessThan, - want: true, - }, - { - name: "less than - no match", - target: 3.14, - value: &query.NumberLiteral{NumberValue: 2.718}, - op: query.LessThan, - want: false, - }, - { - name: "less than or equal - match (less)", - target: 2.718, - value: &query.NumberLiteral{NumberValue: 3.14}, - op: query.LessThanOrEqual, - want: true, - }, - { - name: "less than or equal - match (equal)", - target: 3.14, - value: &query.NumberLiteral{NumberValue: 3.14}, - op: query.LessThanOrEqual, - want: true, - }, - { - name: "less than or equal - no match", - target: 3.14, - value: &query.NumberLiteral{NumberValue: 2.718}, - op: query.LessThanOrEqual, - want: false, - }, - { - name: "like - invalid", - target: 3.14, - value: &query.NumberLiteral{NumberValue: 2.718}, - op: query.Like, - want: false, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - result := matcher.MatchValue(test.target, test.value, test.op) - assert.Equal(t, test.want, result) - }) - } -} - -func testMatchValueTypeMismatch(t *testing.T) { - matcher := &query.DefaultMatcher{} - tests := []struct { - name string - target any - value query.Valuer - op query.FieldOperator - want bool - }{ - { - name: "string target with integer value", - target: "42", - value: &query.IntegerLiteral{IntegerValue: 42}, - op: query.Equal, - want: false, - }, - { - name: "integer target with float value", - target: int64(42), - value: &query.NumberLiteral{NumberValue: 42.0}, - op: query.Equal, - want: false, - }, - { - name: "float target with string value", - target: 3.14, - value: &query.StringLiteral{StringValue: "3.14"}, - op: query.Equal, - want: false, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - result := matcher.MatchValue(test.target, test.value, test.op) - assert.Equal(t, test.want, result) - }) - } -} - func TestBinaryExpr_Match(t *testing.T) { //nolint:funlen target := person{Name: "John", Age: 30} - matcher := &query.DefaultMatcher{} + matcher := &match.StructMatcher{} tests := []struct { name string @@ -754,7 +122,7 @@ func TestBinaryExpr_Match(t *testing.T) { //nolint:funlen func TestNotExpr_Match(t *testing.T) { target := person{Name: "John", Age: 30} - matcher := &query.DefaultMatcher{} + matcher := &match.StructMatcher{} tests := []struct { name string @@ -819,7 +187,7 @@ func TestFieldExpr_Match(t *testing.T) { //nolint:funlen Height: 1.75, IsMember: true, } - matcher := &query.DefaultMatcher{} + matcher := &match.StructMatcher{} tests := []struct { name string @@ -869,7 +237,7 @@ func TestFieldExpr_Match(t *testing.T) { //nolint:funlen Op: query.Equal, Value: &query.StringLiteral{StringValue: "test"}, }, - want: false, + want: true, }, { name: "field without dumbql tag", @@ -1133,3 +501,70 @@ func TestOneOfExpr_Match(t *testing.T) { //nolint:funlen }) } } + +func TestStructFieldOmission(t *testing.T) { //nolint:funlen + type User struct { + ID int64 `dumbql:"id"` + Name string `dumbql:"name"` + Password string `dumbql:"-"` // Should always match + Internal bool `dumbql:"-"` // Should always match + Score float64 `dumbql:"score"` + } + + matcher := &match.StructMatcher{} + user := &User{ + ID: 1, + Name: "John", + Password: "secret123", + Internal: true, + Score: 4.5, + } + + tests := []struct { + name string + query string + want bool + }{ + { + name: "visible field", + query: `id:1`, + want: true, + }, + { + name: "multiple visible fields", + query: `id:1 and name:"John" and score:4.5`, + want: true, + }, + { + name: "omitted field - always true", + query: `password:"wrong_password"`, + want: true, + }, + { + name: "another omitted field - always true", + query: `internal:false`, + want: true, + }, + { + name: "visible and omitted fields", + query: `id:1 and password:"wrong_password"`, + want: true, + }, + { + name: "non-existent field", + query: `unknown:"value"`, + want: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ast, err := query.Parse("test", []byte(test.query)) + require.NoError(t, err) + expr := ast.(query.Expr) + + got := expr.Match(user, matcher) + assert.Equal(t, test.want, got) + }) + } +}