From ee90b26dfa490efb89a856e71284b785e0c96f36 Mon Sep 17 00:00:00 2001 From: AaronS Date: Sun, 22 Jun 2025 21:20:21 -0700 Subject: [PATCH 1/3] * added support for Go modules * added support for SQL Still need to adjust readme --- CHANGELOG.md | 3 + README.md | 54 +++++++++++---- go.mod | 3 + rsql.go | 103 +++++++++++++++++++++++++++ rsql_test.go | 191 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 342 insertions(+), 12 deletions(-) create mode 100644 go.mod diff --git a/CHANGELOG.md b/CHANGELOG.md index 71f5c75..014be9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +* added support for Go modules +* added support for SQL ## [0.4.0] - 2021-08-01 ### Changed diff --git a/README.md b/README.md index d628192..a9b2136 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -go-rsql -======= +# go-rsql + +## overview -# overview RSQL is a query language for parametrized filtering of entries in APIs. It is based on FIQL (Feed Item Query Language) – an URI-friendly syntax for expressing filters across the entries in an Atom Feed. FIQL is great for use in URI; there are no unsafe characters, so URL encoding is not required. @@ -11,9 +11,10 @@ so RSQL also provides a friendlier syntax for logical operators and some compari This is a small RSQL helper library, written in golang. It can be used to parse a RSQL string and turn it into a database query string. -Currently, only mongodb is supported out of the box (however it is very easy to extend the parser if needed). +Currently, mongodb and SQL is supported out of the box. It is very easy to create a new parser if needed. + +## basic usage (mongodb) -# basic usage ```go package main @@ -38,9 +39,35 @@ func main(){ } ``` +## basic usage (SQL) + +```go +package main -# supported operators -The library supports the following basic operators by default: +import ( + +"github.com/rbicker/go-rsql" +"log" +) + +func main(){ + parser, err := rsql.NewParser(rsql.SQL()) + if err != nil { + log.Fatalf("error while creating parser: %s", err) + } + s := `status=="A",qty=lt=30` + res, err := parser.Process(s) + if err != nil { + log.Fatalf("error while parsing: %s", err) + } + log.Println(res) + // { "$or": [ { "status": "A" }, { "qty": { "$lt": 30 } } ] } +} +``` + +## supported operators + +go-rsql supports the following basic operators by default: | Basic Operator | Description | |----------------|---------------------| @@ -53,7 +80,7 @@ The library supports the following basic operators by default: | =in= | In | | =out= | Not in | -The following table lists two joining operators: +go-rsql supports two joining operators: | Composite Operator | Description | |--------------------|---------------------| @@ -61,9 +88,10 @@ The following table lists two joining operators: | , | Logical OR | -# advanced usage +## advanced usage + +### custom operators -## custom operators The library makes it easy to define custom operators: ```go package main @@ -126,7 +154,8 @@ func main(){ } ``` -## transform keys +### transform keys + If your database key naming scheme is different from the one used in your rsql statements, you can add functions to transform your keys. ```go @@ -156,7 +185,8 @@ func main() { } ``` -## define allowed or forbidden keys +### define allowed or forbidden keys + ```go package main diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e8cd861 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/rbicker/go-rsql + +go 1.24 \ No newline at end of file diff --git a/rsql.go b/rsql.go index 93ff61f..6fc00bf 100644 --- a/rsql.go +++ b/rsql.go @@ -140,6 +140,109 @@ func Mongo() func(parser *Parser) error { } } +// SQL adds the default SQL operators to the parser +func SQL() func(parser *Parser) error { + return func(parser *Parser) error { + // operators + var operators = []Operator{ + { + "==", + func(key, value string) string { + return fmt.Sprintf(`%s = %s`, key, value) + }, + }, + { + "!=", + func(key, value string) string { + return fmt.Sprintf(`%s <> %s`, key, value) + }, + }, + { + "=gt=", + func(key, value string) string { + return fmt.Sprintf(`%s > %s`, key, value) + }, + }, + { + "=ge=", + func(key, value string) string { + return fmt.Sprintf(`%s >= %s`, key, value) + }, + }, + { + "=lt=", + func(key, value string) string { + return fmt.Sprintf(`%s < %s`, key, value) + }, + }, + { + "=le=", + func(key, value string) string { + return fmt.Sprintf(`%s <= %s`, key, value) + }, + }, + { + "=in=", + func(key, value string) string { + // remove parentheses + value = value[1 : len(value)-1] + return fmt.Sprintf(`%s IN (%s)`, key, value) + }, + }, + { + "=out=", + func(key, value string) string { + // remove parentheses + value = value[1 : len(value)-1] + return fmt.Sprintf(`%s NOT IN (%s)`, key, value) + }, + }, + } + parser.operators = append(parser.operators, operators...) + // AND formatter + parser.andFormatter = func(ss []string) string { + if len(ss) == 0 { + return "" + } + if len(ss) == 1 { + return ss[0] + } + + // Add parentheses around expressions that contain AND or OR + for i, s := range ss { + if strings.Contains(s, " AND ") || strings.Contains(s, " OR ") { + if !strings.HasPrefix(s, "(") || !strings.HasSuffix(s, ")") { + ss[i] = "(" + s + ")" + } + } + } + + return strings.Join(ss, " AND ") + } + // OR formatter + parser.orFormatter = func(ss []string) string { + if len(ss) == 0 { + return "" + } + if len(ss) == 1 { + return ss[0] + } + + // Add parentheses around expressions that contain AND or OR + for i, s := range ss { + if strings.Contains(s, " AND ") || strings.Contains(s, " OR ") { + if !strings.HasPrefix(s, "(") || !strings.HasSuffix(s, ")") { + ss[i] = "(" + s + ")" + } + } + } + + return strings.Join(ss, " OR ") + } + return nil + } +} + // WithOperator adds custom operators to the parser func WithOperators(operators ...Operator) func(parser *Parser) error { return func(parser *Parser) error { diff --git a/rsql_test.go b/rsql_test.go index 8af9530..64110e6 100644 --- a/rsql_test.go +++ b/rsql_test.go @@ -389,6 +389,197 @@ func TestParser_ProcessMongo(t *testing.T) { } } +func TestParser_ProcessSQL(t *testing.T) { + tests := []struct { + name string + s string + options []func(*ProcessOptions) error + customOperators []Operator + keyTransformers []func(s string) string + want string + wantErr bool + }{ + { + name: "empty", + s: "", + want: "", + }, + { + name: "==", + s: "a==1", + want: `a = 1`, + }, + { + name: "!=", + s: "a!=1", + want: `a <> 1`, + }, + { + name: "=gt=", + s: "a=gt=1", + want: `a > 1`, + }, + { + name: "=ge=", + s: "a=ge=1", + want: `a >= 1`, + }, + { + name: "=lt=", + s: "a=lt=1", + want: `a < 1`, + }, + { + name: "=le=", + s: "a=le=1", + want: `a <= 1`, + }, + { + name: "=in=", + s: "a=in=(1,2,3)", + want: `a IN (1,2,3)`, + }, + { + name: "=out=", + s: "a=out=(1,2,3)", + want: `a NOT IN (1,2,3)`, + }, + { + name: "(a==1)", + s: "(a==1)", + want: `a = 1`, + }, + { + name: "a==1;b==2", + s: "a==1;b==2", + want: `a = 1 AND b = 2`, + }, + { + name: "a==1,b==2", + s: "a==1,b==2", + want: `a = 1 OR b = 2`, + }, + { + name: "a==1;b==2,c==1", + s: "a==1;b==2,c==1", + want: `(a = 1 AND b = 2) OR c = 1`, + }, + { + name: "a==1,b==2;c==1", + s: "a==1,b==2;c==1", + want: `a = 1 OR (b = 2 AND c = 1)`, + }, + { + name: "(a==1;b==2),c=gt=5", + s: "(a==1;b==2),c=gt=5", + want: `(a = 1 AND b = 2) OR c > 5`, + }, + { + name: "c==1,(a==1;b==2)", + s: "c==1,(a==1;b==2)", + want: `c = 1 OR (a = 1 AND b = 2)`, + }, + { + name: "a==1;(b==1,c==2)", + s: "a==1;(b==1,c==2)", + want: `a = 1 AND (b = 1 OR c = 2)`, + }, + { + name: "(a==1,b==1);(c==1,d==2)", + s: "(a==1,b==1);(c==1,d==2)", + want: `(a = 1 OR b = 1) AND (c = 1 OR d = 2)`, + }, + { + name: "custom operator: =like=", + s: "a=like=c*", + customOperators: []Operator{ + { + Operator: "=like=", + Formatter: func(key, value string) string { + value = strings.ReplaceAll(value, "*", "%") + return fmt.Sprintf(`%s LIKE '%s'`, key, value) + }, + }, + }, + want: `a LIKE 'c%'`, + }, + { + name: "all keys allowed", + s: "a==1", + options: []func(*ProcessOptions) error{}, + wantErr: false, + want: `a = 1`, + }, + { + name: "key allowed", + s: "a==1", + options: []func(*ProcessOptions) error{ + SetAllowedKeys([]string{"a"}), + }, + wantErr: false, + want: `a = 1`, + }, + { + name: "key not allowed", + s: "a==1", + options: []func(*ProcessOptions) error{ + SetAllowedKeys([]string{"b"}), + }, + wantErr: true, + want: "", + }, + { + name: "key forbidden", + s: "a==1", + options: []func(*ProcessOptions) error{ + SetForbiddenKeys([]string{"a"}), + }, + wantErr: true, + want: "", + }, + { + name: "key not forbidden", + s: "a==1", + options: []func(*ProcessOptions) error{ + SetForbiddenKeys([]string{"b"}), + }, + wantErr: false, + want: `a = 1`, + }, + { + name: "uppercase key transformer", + s: "a==1", + keyTransformers: []func(s string) string{ + func(s string) string { + return strings.ToUpper(s) + }, + }, + wantErr: false, + want: `A = 1`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var opts []func(*Parser) error + opts = append(opts, SQL()) + opts = append(opts, WithOperators(tt.customOperators...)) + opts = append(opts, WithKeyTransformers(tt.keyTransformers...)) + parser, err := NewParser(opts...) + if err != nil { + t.Fatalf("error while creating parser: %s", err) + } + got, err := parser.Process(tt.s, tt.options...) + if (err != nil) != tt.wantErr { + t.Errorf("Process() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("Process() got = %v, want %v", got, tt.want) + } + }) + } +} + func Test_findParts(t *testing.T) { tests := []struct { name string From 8dabe6cb06c7145712365afd4d7b7b10c03a561e Mon Sep 17 00:00:00 2001 From: AaronS Date: Sun, 22 Jun 2025 21:45:43 -0700 Subject: [PATCH 2/3] finished the sql parser example --- README.md | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a9b2136..11dc522 100644 --- a/README.md +++ b/README.md @@ -45,12 +45,12 @@ func main(){ package main import ( + "log" -"github.com/rbicker/go-rsql" -"log" + "github.com/rbicker/go-rsql" ) -func main(){ +func main() { parser, err := rsql.NewParser(rsql.SQL()) if err != nil { log.Fatalf("error while creating parser: %s", err) @@ -61,7 +61,21 @@ func main(){ log.Fatalf("error while parsing: %s", err) } log.Println(res) - // { "$or": [ { "status": "A" }, { "qty": { "$lt": 30 } } ] } + // status = "A" OR qty < 30 + + // example use + // The 1=1 allows us to add or not add more conditions. It shouldn't affect + // query run times. + qry := "SELECT * FROM books WHERE 1=1" + // This example is simplified. but in a real world example you may not + // have a url query string and res will be empty. + if res != "" { + // The parentheses may not be necessary but they help ensure the order + // of operations. + qry += " AND (" + res + ")" + } + + log.Println(qry) } ``` From ec4f2e3ad5cb6b0fd50f84448b8b91060afc1078 Mon Sep 17 00:00:00 2001 From: AaronS Date: Mon, 23 Jun 2025 17:44:28 -0700 Subject: [PATCH 3/3] simplified example --- README.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 11dc522..a46147a 100644 --- a/README.md +++ b/README.md @@ -64,18 +64,15 @@ func main() { // status = "A" OR qty < 30 // example use - // The 1=1 allows us to add or not add more conditions. It shouldn't affect - // query run times. - qry := "SELECT * FROM books WHERE 1=1" + qry := "SELECT * FROM books" // This example is simplified. but in a real world example you may not // have a url query string and res will be empty. if res != "" { - // The parentheses may not be necessary but they help ensure the order - // of operations. - qry += " AND (" + res + ")" + qry += " WHERE " + res } log.Println(qry) + // SELECT * FROM books WHERE status = "A" OR qty < 30 } ```