3
0

Expand pkg/filter for easier paging cursor construction/use

* Add a cursor method to return an ql.ASTNode representation.
* Add a package function to construct a cursor from an arbitrary
  value input.
This commit is contained in:
Tomaž Jerman 2022-08-21 16:25:23 +02:00
parent 689be25a89
commit 985c063d89
2 changed files with 379 additions and 1 deletions

View File

@ -4,6 +4,11 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"time"
"github.com/cortezaproject/corteza-server/pkg/expr"
"github.com/cortezaproject/corteza-server/pkg/ql"
"github.com/modern-go/reflect2"
)
type (
@ -56,6 +61,12 @@ type (
pagingCursorValue struct {
v interface{}
}
// copy of the DAL valueGetter so we don't need a reference to pkg/dal
valueGetter interface {
GetValue(string, uint) (any, error)
CountValues() map[string]uint
}
)
var (
@ -289,6 +300,258 @@ func (p *PagingCursor) Sort(sort SortExprSet) (SortExprSet, error) {
return sort, nil
}
// ToAST converts the given PagingCursor to a corresponding AST tree
//
// The method should be used as the base for generating filtering expressions
// when working with databases, DAL reports, ...
//
// @todo discuss this one
func (cur *PagingCursor) ToAST(identLookup func(i string) (string, error), castFn func(i string, val any) (expr.TypedValue, error)) (out *ql.ASTNode, err error) {
var (
cc = cur.Keys()
vv = cur.Values()
value expr.TypedValue
ident string
ltOp = map[bool]string{
true: "lt",
false: "gt",
}
isValueNull = func(i int, neg bool) expr.TypedValue {
if (reflect2.IsNil(vv[i]) && !neg) || (!reflect2.IsNil(vv[i]) && neg) {
return expr.Must(expr.NewBoolean(true))
}
return expr.Must(expr.NewBoolean(false))
}
)
_ = value
if len(cc) == 0 {
return
}
// going from the last key/column to the 1st one
for i := len(cc) - 1; i >= 0; i-- {
if identLookup != nil {
// Get the key context so we know how to format fields and format typecasts
ident, err = identLookup(cc[i])
if err != nil {
return
}
} else {
ident = cc[i]
}
if castFn == nil {
value, err = cur.guessTypedValue(vv[i])
} else {
value, err = castFn(cc[i], vv[i])
}
if err != nil {
return
}
// We need to cut off the values that are before the cursor (when ascending)
// and vice-versa for descending.
lt := cur.Desc()[i]
if cur.IsROrder() {
lt = !lt
}
op := ltOp[lt]
//// Typecast the value so comparison can work properly
// Either BOTH (field and value) are NULL or field is grater-then value
base := &ql.ASTNode{
Ref: "group",
Args: ql.ASTNodeSet{
&ql.ASTNode{
Ref: "or",
Args: ql.ASTNodeSet{
&ql.ASTNode{
Ref: "group",
Args: ql.ASTNodeSet{
&ql.ASTNode{
Ref: "and",
Args: ql.ASTNodeSet{
&ql.ASTNode{
Ref: "nnull",
Args: ql.ASTNodeSet{
&ql.ASTNode{
Symbol: ident,
},
},
},
&ql.ASTNode{
Value: ql.WrapValue(isValueNull(i, false)),
},
},
},
},
},
&ql.ASTNode{
Ref: op,
Args: ql.ASTNodeSet{{
Symbol: ident,
}, {
Value: ql.WrapValue(value),
}},
},
},
},
},
}
if out == nil {
out = base
} else {
out = &ql.ASTNode{
Ref: "group",
Args: ql.ASTNodeSet{
&ql.ASTNode{
Ref: "or",
Args: ql.ASTNodeSet{
base,
&ql.ASTNode{
Ref: "group",
Args: ql.ASTNodeSet{
&ql.ASTNode{
Ref: "and",
Args: ql.ASTNodeSet{
&ql.ASTNode{
Ref: "group",
Args: ql.ASTNodeSet{
&ql.ASTNode{
Ref: "or",
Args: ql.ASTNodeSet{
&ql.ASTNode{
Ref: "and",
Args: ql.ASTNodeSet{
&ql.ASTNode{
Ref: "null",
Args: ql.ASTNodeSet{
&ql.ASTNode{
Symbol: ident,
},
},
},
&ql.ASTNode{
Value: ql.WrapValue(isValueNull(i, false)),
},
},
},
&ql.ASTNode{
Ref: "eq",
Args: ql.ASTNodeSet{{
Symbol: ident,
}, {
Value: ql.WrapValue(value),
}},
},
},
},
},
},
out,
},
},
},
},
},
},
},
}
}
}
return
}
func (PagingCursor) guessTypedValue(v any) (expr.TypedValue, error) {
// handle boolean edgecases
if v == "true" || v == true {
return expr.NewBoolean(true)
} else if v == "false" || v == false {
return expr.NewBoolean(false)
}
// other types
switch v.(type) {
case int, int8, int16, int32, int64:
return expr.NewInteger(v)
case float32, float64:
return expr.NewFloat(v)
case string:
return expr.NewString(v)
case time.Time,
*time.Time:
return expr.NewDateTime(v)
}
return nil, fmt.Errorf("failed to determine value type for %v", v)
}
// PagingCursorFrom constructs a new paging cursor for the given valueGetter
func PagingCursorFrom(ss SortExprSet, v valueGetter, primaries ...string) (_ *PagingCursor, err error) {
var (
cur = &PagingCursor{LThen: ss.Reversed()}
pkUsed = make(map[string]bool)
pkIndex = make(map[string]bool)
value any
)
// Index all of the primary keys for easier code
for _, a := range primaries {
pkIndex[a] = true
}
if len(pkIndex) == 0 {
err = fmt.Errorf("can not construct cursor without primary key attributes")
return
}
for _, s := range ss {
if ok := pkIndex[s.Column]; ok {
pkUsed[s.Column] = true
}
// @todo multi values?
value, err = v.GetValue(s.Column, 0)
if err != nil {
return
}
cur.Set(s.Column, value, s.Descending)
}
// Make sure the rest of the unused primary keys are applied
if len(pkUsed) != len(pkIndex) {
for _, ident := range primaries {
if _, ok := pkUsed[ident]; ok {
continue
}
value, err = v.GetValue(ident, 0)
if err != nil {
return
}
cur.Set(ident, value, false)
}
}
return cur, nil
}
func parseCursor(in string) (p *PagingCursor, err error) {
if len(in) == 0 {
return nil, nil

View File

@ -2,10 +2,35 @@ package filter
import (
"fmt"
"github.com/stretchr/testify/require"
"testing"
"github.com/stretchr/testify/require"
)
type (
simpleGetter map[string][]any
)
func (g simpleGetter) GetValue(name string, pos uint) (any, error) {
if g[name] == nil {
return nil, fmt.Errorf("not found")
}
if int(pos) >= len(g[name]) {
return nil, fmt.Errorf("out of bounds")
}
return g[name][pos], nil
}
func (g simpleGetter) CountValues() map[string]uint {
out := make(map[string]uint)
for k := range g {
out[k]++
}
return out
}
func Test_cursorEncDec(t *testing.T) {
var (
req = require.New(t)
@ -100,3 +125,93 @@ func Test_cursorUnmarshal(t *testing.T) {
}
}
func TestPagingCursorFromValueGetter(t *testing.T) {
tcc := []struct {
name string
vals simpleGetter
ss SortExprSet
primaries []string
out *PagingCursor
err bool
}{
{
name: "no pk",
primaries: []string{},
vals: simpleGetter{"k1": {"a"}},
err: true,
},
{
name: "simple without sorting",
primaries: []string{"k1"},
vals: simpleGetter{"k1": {"a"}},
out: &PagingCursor{
keys: []string{"k1"},
values: []any{"a"},
desc: []bool{false},
},
},
{
name: "complex without sorting",
primaries: []string{"k1", "k2"},
vals: simpleGetter{"k1": {"a"}, "k2": {"b"}, "something": {10}},
out: &PagingCursor{
keys: []string{"k1", "k2"},
values: []any{"a", "b"},
desc: []bool{false, false},
},
},
{
name: "simple with sorting",
primaries: []string{"k1"},
vals: simpleGetter{"k1": {"a"}, "something": {42}},
ss: SortExprSet{{Column: "something"}},
out: &PagingCursor{
keys: []string{"something", "k1"},
values: []any{42, "a"},
desc: []bool{false, false},
},
},
{
name: "complex with sorting",
primaries: []string{"k1", "k2"},
vals: simpleGetter{"k1": {"a"}, "k2": {"b"}, "something": {10}},
ss: SortExprSet{{Column: "something"}, {Column: "k2"}},
out: &PagingCursor{
keys: []string{"something", "k2", "k1"},
values: []any{10, "b", "a"},
desc: []bool{false, false, false},
},
},
{
name: "complex mix and match",
primaries: []string{"k1", "k2"},
vals: simpleGetter{"k1": {"a"}, "k2": {"b"}, "something": {10}, "another_thing": {"qwerty"}},
ss: SortExprSet{{Column: "something", Descending: true}, {Column: "k2", Descending: false}, {Column: "another_thing", Descending: true}},
out: &PagingCursor{
keys: []string{"something", "k2", "another_thing", "k1"},
values: []any{10, "b", "qwerty", "a"},
desc: []bool{true, false, true, false},
LThen: true,
},
},
}
for _, c := range tcc {
t.Run(c.name, func(t *testing.T) {
out, err := PagingCursorFrom(c.ss, c.vals, c.primaries...)
if c.err {
require.Error(t, err)
}
require.Equal(t, c.out, out)
})
}
}
func TestPagingCursor_ToAST(t *testing.T) {
t.Skip("TODO")
}