Last active
November 17, 2023 16:08
-
-
Save mirusky/be624c9d0966e4b55f51cb02ac9613b7 to your computer and use it in GitHub Desktop.
Golang RQL parser
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package main | |
import ( | |
"bytes" | |
"encoding/json" | |
"fmt" | |
"strings" | |
) | |
var ( | |
prefix = "$" | |
// A sorting expression can be optionally prefixed with + or - to control the | |
// sorting direction, ascending or descending. For example, '+field' or '-field'. | |
// If the predicate is missing or empty then it defaults to '+' | |
sortDirection = map[byte]string{ | |
'+': "asc", | |
'-': "desc", | |
} | |
opFormat = map[Op]string{ | |
EQ: "=", | |
NEQ: "<>", | |
LT: "<", | |
GT: ">", | |
LTE: "<=", | |
GTE: ">=", | |
LIKE: "LIKE", | |
OR: "OR", | |
AND: "AND", | |
} | |
) | |
// Operators that support by rql. | |
const ( | |
EQ = Op("eq") // = | |
NEQ = Op("neq") // <> | |
LT = Op("lt") // < | |
GT = Op("gt") // > | |
LTE = Op("lte") // <= | |
GTE = Op("gte") // >= | |
LIKE = Op("like") // LIKE "PATTERN" | |
OR = Op("or") // disjunction | |
AND = Op("and") // conjunction | |
) | |
type Query struct { | |
Limit int `json:"limit,omitempty"` | |
Offset int `json:"offset,omitempty"` | |
Select []string `json:"select,omitempty"` | |
Sort []string `json:"sort,omitempty"` | |
Filter map[string]interface{} `json:"filter,omitempty"` | |
} | |
type Params struct { | |
Limit int | |
Offset int | |
Select string | |
Sort string | |
FilterExp string | |
FilterArgs []interface{} | |
} | |
// Op is a filter operator used by rql. | |
type Op string | |
// SQL returns the SQL representation of the operator. | |
func (o Op) SQL() string { | |
return opFormat[o] | |
} | |
type parseState struct { | |
*bytes.Buffer // query builder | |
values []interface{} // query values | |
} | |
func (p *parseState) relOp(op Op, terms []interface{}) { | |
var i int | |
if len(terms) > 1 { | |
p.WriteByte('(') | |
} | |
for _, t := range terms { | |
if i > 0 { | |
p.WriteByte(' ') | |
p.WriteString(op.SQL()) | |
p.WriteByte(' ') | |
} | |
mt, ok := t.(map[string]interface{}) | |
if !ok { | |
fmt.Printf("expressions for $%s operator must be type object", op) | |
return | |
} | |
p.and(mt) | |
i++ | |
} | |
if len(terms) > 1 { | |
p.WriteByte(')') | |
} | |
} | |
func (p *parseState) op(op Op) string { | |
return prefix + string(op) | |
} | |
func (p *parseState) and(f map[string]interface{}) { | |
var i int | |
for k, v := range f { | |
if i > 0 { | |
p.WriteString(" AND ") | |
} | |
switch { | |
case k == p.op(OR): | |
terms, ok := v.([]interface{}) | |
if !ok { | |
fmt.Printf("$or must be type array") | |
return | |
} | |
p.relOp(OR, terms) | |
case k == p.op(AND): | |
terms, ok := v.([]interface{}) | |
if !ok { | |
fmt.Printf("$and must be type array") | |
return | |
} | |
p.relOp(AND, terms) | |
// case p.fields[k] != nil: | |
// expect(p.fields[k].Filterable, "field %q is not filterable", k) | |
// p.field(p.fields[k], v) | |
default: | |
if !strings.Contains(k, prefix) { | |
p.field(k, v) | |
} else { | |
fmt.Printf("unrecognized key %q for filtering", k) | |
return | |
} | |
} | |
i++ | |
} | |
} | |
func (p *parseState) field(k string, v interface{}) { | |
terms, ok := v.(map[string]interface{}) | |
// default equality check. | |
if !ok { | |
// must(f.ValidateFn(v), "invalid datatype for field %q", f.Name) | |
p.WriteString(p.fmtOp(k, EQ)) | |
p.values = append(p.values, v) | |
} | |
var i int | |
if len(terms) > 1 { | |
p.WriteByte('(') | |
} | |
for opName, opVal := range terms { | |
if i > 0 { | |
p.WriteString(" AND ") | |
} | |
// expect(f.FilterOps[opName], "can not apply op %q on field %q", opName, f.Name) | |
// must(f.ValidateFn(opVal), "invalid datatype or format for field %q", f.Name) | |
p.WriteString(p.fmtOp(k, Op(opName[1:]))) | |
if Op(opName[1:]) == Op(LIKE) { | |
p.values = append(p.values, fmt.Sprintf("%%%s%%", opVal)) | |
} else { | |
p.values = append(p.values, opVal) | |
} | |
i++ | |
} | |
if len(terms) > 1 { | |
p.WriteByte(')') | |
} | |
} | |
func (p *parseState) fmtOp(field string, op Op) string { | |
colName := p.colName(field) | |
if op == Op(LIKE) { | |
// HACK: Postgres does not have support to insensitive case so we check by lower equality | |
return fmt.Sprintf("lower(%s) %s ?", colName, op.SQL()) | |
} | |
return fmt.Sprintf("%s %s ?", colName, op.SQL()) | |
} | |
// colName formats the query field to database column name in cases the user configured a custom | |
// field separator. for example: if the user configured the field separator to be ".", the fields | |
// like "address.name" will be changed to "address_name". | |
func (p *parseState) colName(field string) string { | |
return strings.ReplaceAll(field, ".", "_") | |
} | |
func parseFilter(f map[string]interface{}) (string, []interface{}) { | |
ps := &parseState{ | |
Buffer: &bytes.Buffer{}, | |
values: []interface{}{}, | |
} | |
ps.and(f) | |
return ps.String(), ps.values | |
} | |
func parseSelect(fields []string) string { | |
return strings.Join(fields, ", ") | |
} | |
func parseSort(fields []string) string { | |
sortParams := make([]string, len(fields)) | |
for i, field := range fields { | |
var orderBy string | |
// if the sort field prefixed by an order indicator. | |
if order, ok := sortDirection[field[0]]; ok { | |
orderBy = order | |
field = field[1:] | |
} | |
colName := field | |
if orderBy != "" { | |
colName += " " + orderBy | |
} | |
sortParams[i] = colName | |
} | |
return strings.Join(sortParams, ", ") | |
} | |
func ParseRawQuery(raw []byte) (Params, error) { | |
var q Query | |
err := json.Unmarshal(raw, &q) | |
if err != nil { | |
return Params{}, err | |
} | |
return ParseQuery(q) | |
} | |
func ParseQuery(q Query) (Params, error) { | |
p := Params{ | |
Limit: q.Limit, | |
Offset: q.Offset, | |
} | |
p.Sort = parseSort(q.Sort) | |
p.Select = parseSelect(q.Select) | |
expr, args := parseFilter(q.Filter) | |
p.FilterExp = expr | |
p.FilterArgs = args | |
return p, nil | |
} | |
func main() { | |
raw := ` | |
{ | |
"select": ["name", "value", "summary"], | |
"sort": ["-price", "+address"], | |
"limit": 10, | |
"filter": { | |
"$or": [ | |
{"shipped": false}, | |
{"username": "admin"} | |
] | |
} | |
} | |
` | |
p, err := ParseRawQuery([]byte(raw)) | |
if err != nil { | |
fmt.Println(err) | |
return | |
} | |
fmt.Printf("%+v", p) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
It's not 100% functional but it does some stuff