mirror of
https://github.com/owncloud/ocis
synced 2026-04-25 17:25:21 +02:00
feat(kql): support numeric range queries (>=, <=, >, <)
Add NumericRestrictionNode to the KQL PEG grammar so that range operators work with numeric values, not just DateTimes. This enables queries like `size>=1048576`, `photo.iso>=100`, `photo.fNumber>=2.8`, and `photo.focalLength<50`. Changes: - ast: add NumericNode with Key, Operator, Value (float64) - kql/dictionary.peg: add NumericRestrictionNode and Number rules - kql/factory.go: add buildNumericNode() - kql/cast.go: add toFloat64() - bleve/compiler.go: compile NumericNode to NumericRangeQuery Fixes: #12093 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
Enhancement: Support numeric range queries in KQL
|
||||||
|
|
||||||
|
The KQL parser now supports numeric range queries using comparison
|
||||||
|
operators (>=, <=, >, <) on numeric fields. Previously, range operators
|
||||||
|
only worked with DateTime values, causing queries like `size>=1048576`
|
||||||
|
or `photo.iso>=100` to silently fail by falling through to free-text
|
||||||
|
search.
|
||||||
|
|
||||||
|
Affected numeric fields: Size, photo.iso, photo.fNumber,
|
||||||
|
photo.focalLength, photo.orientation.
|
||||||
|
|
||||||
|
https://github.com/owncloud/ocis/pull/12094
|
||||||
|
https://github.com/owncloud/ocis/issues/12093
|
||||||
@@ -60,6 +60,14 @@ type DateTimeNode struct {
|
|||||||
Value time.Time
|
Value time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NumericNode represents a float64 value with a comparison operator
|
||||||
|
type NumericNode struct {
|
||||||
|
*Base
|
||||||
|
Key string
|
||||||
|
Operator *OperatorNode
|
||||||
|
Value float64
|
||||||
|
}
|
||||||
|
|
||||||
// OperatorNode represents an operator value like
|
// OperatorNode represents an operator value like
|
||||||
// AND, OR, NOT, =, <= ... and so on
|
// AND, OR, NOT, =, <= ... and so on
|
||||||
type OperatorNode struct {
|
type OperatorNode struct {
|
||||||
@@ -81,6 +89,8 @@ func NodeKey(n Node) string {
|
|||||||
return node.Key
|
return node.Key
|
||||||
case *DateTimeNode:
|
case *DateTimeNode:
|
||||||
return node.Key
|
return node.Key
|
||||||
|
case *NumericNode:
|
||||||
|
return node.Key
|
||||||
case *BooleanNode:
|
case *BooleanNode:
|
||||||
return node.Key
|
return node.Key
|
||||||
case *GroupNode:
|
case *GroupNode:
|
||||||
@@ -97,6 +107,8 @@ func NodeValue(n Node) interface{} {
|
|||||||
return node.Value
|
return node.Value
|
||||||
case *DateTimeNode:
|
case *DateTimeNode:
|
||||||
return node.Value
|
return node.Value
|
||||||
|
case *NumericNode:
|
||||||
|
return node.Value
|
||||||
case *BooleanNode:
|
case *BooleanNode:
|
||||||
return node.Value
|
return node.Value
|
||||||
case *GroupNode:
|
case *GroupNode:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ func DiffAst(x, y interface{}, opts ...cmp.Option) string {
|
|||||||
cmpopts.IgnoreFields(ast.GroupNode{}, "Base"),
|
cmpopts.IgnoreFields(ast.GroupNode{}, "Base"),
|
||||||
cmpopts.IgnoreFields(ast.BooleanNode{}, "Base"),
|
cmpopts.IgnoreFields(ast.BooleanNode{}, "Base"),
|
||||||
cmpopts.IgnoreFields(ast.DateTimeNode{}, "Base"),
|
cmpopts.IgnoreFields(ast.DateTimeNode{}, "Base"),
|
||||||
|
cmpopts.IgnoreFields(ast.NumericNode{}, "Base"),
|
||||||
)...,
|
)...,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package kql
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jinzhu/now"
|
"github.com/jinzhu/now"
|
||||||
@@ -25,7 +26,7 @@ func toNodes[T ast.Node](in interface{}) ([]T, error) {
|
|||||||
return []T{v}, nil
|
return []T{v}, nil
|
||||||
case []T:
|
case []T:
|
||||||
return v, nil
|
return v, nil
|
||||||
case []*ast.OperatorNode, []*ast.DateTimeNode:
|
case []*ast.OperatorNode, []*ast.DateTimeNode, []*ast.NumericNode:
|
||||||
return toNodes[T](v)
|
return toNodes[T](v)
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
var nodes []T
|
var nodes []T
|
||||||
@@ -70,6 +71,15 @@ func toString(in interface{}) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toFloat64(in interface{}) (float64, error) {
|
||||||
|
s, err := toString(in)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return strconv.ParseFloat(s, 64)
|
||||||
|
}
|
||||||
|
|
||||||
func toTime(in interface{}) (time.Time, error) {
|
func toTime(in interface{}) (time.Time, error) {
|
||||||
ts, err := toString(in)
|
ts, err := toString(in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ GroupNode <-
|
|||||||
PropertyRestrictionNodes <-
|
PropertyRestrictionNodes <-
|
||||||
YesNoPropertyRestrictionNode /
|
YesNoPropertyRestrictionNode /
|
||||||
DateTimeRestrictionNode /
|
DateTimeRestrictionNode /
|
||||||
|
NumericRestrictionNode /
|
||||||
TextPropertyRestrictionNode
|
TextPropertyRestrictionNode
|
||||||
|
|
||||||
YesNoPropertyRestrictionNode <-
|
YesNoPropertyRestrictionNode <-
|
||||||
@@ -69,6 +70,16 @@ DateTimeRestrictionNode <-
|
|||||||
return buildNaturalLanguageDateTimeNodes(k, v, c.text, c.pos)
|
return buildNaturalLanguageDateTimeNodes(k, v, c.text, c.pos)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NumericRestrictionNode <-
|
||||||
|
k:Char+ o:(
|
||||||
|
OperatorGreaterOrEqualNode /
|
||||||
|
OperatorLessOrEqualNode /
|
||||||
|
OperatorGreaterNode /
|
||||||
|
OperatorLessNode
|
||||||
|
) v:Number {
|
||||||
|
return buildNumericNode(k, o, v, c.text, c.pos)
|
||||||
|
}
|
||||||
|
|
||||||
TextPropertyRestrictionNode <-
|
TextPropertyRestrictionNode <-
|
||||||
k:Char+ (OperatorColonNode / OperatorEqualNode) v:(String / [^ ()]+){
|
k:Char+ (OperatorColonNode / OperatorEqualNode) v:(String / [^ ()]+){
|
||||||
return buildStringNode(k, v, c.text, c.pos)
|
return buildStringNode(k, v, c.text, c.pos)
|
||||||
@@ -224,6 +235,11 @@ String <-
|
|||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Number <-
|
||||||
|
[0-9]+ ("." [0-9]+)? {
|
||||||
|
return c.text, nil
|
||||||
|
}
|
||||||
|
|
||||||
Digit <-
|
Digit <-
|
||||||
[0-9] {
|
[0-9] {
|
||||||
return c.text, nil
|
return c.text, nil
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -858,6 +858,97 @@ func TestParse_DateTimeRestrictionNode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParse_NumericRestrictionNode(t *testing.T) {
|
||||||
|
tests := []testCase{
|
||||||
|
{
|
||||||
|
name: `size>=1048576`,
|
||||||
|
ast: &ast.Ast{
|
||||||
|
Nodes: []ast.Node{
|
||||||
|
&ast.NumericNode{
|
||||||
|
Key: "size",
|
||||||
|
Operator: &ast.OperatorNode{Value: ">="},
|
||||||
|
Value: 1048576,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `size<=10485760`,
|
||||||
|
ast: &ast.Ast{
|
||||||
|
Nodes: []ast.Node{
|
||||||
|
&ast.NumericNode{
|
||||||
|
Key: "size",
|
||||||
|
Operator: &ast.OperatorNode{Value: "<="},
|
||||||
|
Value: 10485760,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `size>=1024 AND size<=2048`,
|
||||||
|
query: `size>=1024 AND size<=2048`,
|
||||||
|
ast: &ast.Ast{
|
||||||
|
Nodes: []ast.Node{
|
||||||
|
&ast.NumericNode{
|
||||||
|
Key: "size",
|
||||||
|
Operator: &ast.OperatorNode{Value: ">="},
|
||||||
|
Value: 1024,
|
||||||
|
},
|
||||||
|
&ast.OperatorNode{Value: kql.BoolAND},
|
||||||
|
&ast.NumericNode{
|
||||||
|
Key: "size",
|
||||||
|
Operator: &ast.OperatorNode{Value: "<="},
|
||||||
|
Value: 2048,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `photo.fNumber>=2.8`,
|
||||||
|
ast: &ast.Ast{
|
||||||
|
Nodes: []ast.Node{
|
||||||
|
&ast.NumericNode{
|
||||||
|
Key: "photo.fNumber",
|
||||||
|
Operator: &ast.OperatorNode{Value: ">="},
|
||||||
|
Value: 2.8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `photo.iso>100`,
|
||||||
|
ast: &ast.Ast{
|
||||||
|
Nodes: []ast.Node{
|
||||||
|
&ast.NumericNode{
|
||||||
|
Key: "photo.iso",
|
||||||
|
Operator: &ast.OperatorNode{Value: ">"},
|
||||||
|
Value: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `photo.focalLength<50`,
|
||||||
|
ast: &ast.Ast{
|
||||||
|
Nodes: []ast.Node{
|
||||||
|
&ast.NumericNode{
|
||||||
|
Key: "photo.focalLength",
|
||||||
|
Operator: &ast.OperatorNode{Value: "<"},
|
||||||
|
Value: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
testKQL(t, tc)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParse_Errors(t *testing.T) {
|
func TestParse_Errors(t *testing.T) {
|
||||||
tests := []testCase{
|
tests := []testCase{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -73,6 +73,35 @@ func buildStringNode(k, v interface{}, text []byte, pos position) (*ast.StringNo
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildNumericNode(k, o, v interface{}, text []byte, pos position) (*ast.NumericNode, error) {
|
||||||
|
b, err := base(text, pos)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
operator, err := toNode[*ast.OperatorNode](o)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := toString(k)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := toFloat64(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ast.NumericNode{
|
||||||
|
Base: b,
|
||||||
|
Key: key,
|
||||||
|
Operator: operator,
|
||||||
|
Value: value,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func buildDateTimeNode(k, o, v interface{}, text []byte, pos position) (*ast.DateTimeNode, error) {
|
func buildDateTimeNode(k, o, v interface{}, text []byte, pos position) (*ast.DateTimeNode, error) {
|
||||||
b, err := base(text, pos)
|
b, err := base(text, pos)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -153,6 +153,40 @@ func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if prev == nil {
|
||||||
|
prev = q
|
||||||
|
} else {
|
||||||
|
next = q
|
||||||
|
}
|
||||||
|
case *ast.NumericNode:
|
||||||
|
if n.Operator == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var minVal, maxVal *float64
|
||||||
|
var minInclusive, maxInclusive *bool
|
||||||
|
val := n.Value
|
||||||
|
|
||||||
|
switch n.Operator.Value {
|
||||||
|
case ">":
|
||||||
|
minVal = &val
|
||||||
|
minInclusive = &[]bool{false}[0]
|
||||||
|
case ">=":
|
||||||
|
minVal = &val
|
||||||
|
minInclusive = &[]bool{true}[0]
|
||||||
|
case "<":
|
||||||
|
maxVal = &val
|
||||||
|
maxInclusive = &[]bool{false}[0]
|
||||||
|
case "<=":
|
||||||
|
maxVal = &val
|
||||||
|
maxInclusive = &[]bool{true}[0]
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
q := bleve.NewNumericRangeInclusiveQuery(minVal, maxVal, minInclusive, maxInclusive)
|
||||||
|
q.SetField(getField(n.Key))
|
||||||
|
|
||||||
if prev == nil {
|
if prev == nil {
|
||||||
prev = q
|
prev = q
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -565,6 +565,107 @@ func Test_compile(t *testing.T) {
|
|||||||
}),
|
}),
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: `size>=1048576`,
|
||||||
|
args: &ast.Ast{
|
||||||
|
Nodes: []ast.Node{
|
||||||
|
&ast.NumericNode{
|
||||||
|
Key: "size",
|
||||||
|
Operator: &ast.OperatorNode{Value: ">="},
|
||||||
|
Value: 1048576,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: query.NewConjunctionQuery([]query.Query{
|
||||||
|
func() query.Query {
|
||||||
|
min := 1048576.0
|
||||||
|
inclusive := true
|
||||||
|
q := query.NewNumericRangeInclusiveQuery(&min, nil, &inclusive, nil)
|
||||||
|
q.SetField("Size")
|
||||||
|
return q
|
||||||
|
}(),
|
||||||
|
}),
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `size<=10485760`,
|
||||||
|
args: &ast.Ast{
|
||||||
|
Nodes: []ast.Node{
|
||||||
|
&ast.NumericNode{
|
||||||
|
Key: "size",
|
||||||
|
Operator: &ast.OperatorNode{Value: "<="},
|
||||||
|
Value: 10485760,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: query.NewConjunctionQuery([]query.Query{
|
||||||
|
func() query.Query {
|
||||||
|
max := 10485760.0
|
||||||
|
inclusive := true
|
||||||
|
q := query.NewNumericRangeInclusiveQuery(nil, &max, nil, &inclusive)
|
||||||
|
q.SetField("Size")
|
||||||
|
return q
|
||||||
|
}(),
|
||||||
|
}),
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `photo.iso>100 AND photo.iso<3200`,
|
||||||
|
args: &ast.Ast{
|
||||||
|
Nodes: []ast.Node{
|
||||||
|
&ast.NumericNode{
|
||||||
|
Key: "photo.iso",
|
||||||
|
Operator: &ast.OperatorNode{Value: ">"},
|
||||||
|
Value: 100,
|
||||||
|
},
|
||||||
|
&ast.OperatorNode{Value: "AND"},
|
||||||
|
&ast.NumericNode{
|
||||||
|
Key: "photo.iso",
|
||||||
|
Operator: &ast.OperatorNode{Value: "<"},
|
||||||
|
Value: 3200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: query.NewConjunctionQuery([]query.Query{
|
||||||
|
func() query.Query {
|
||||||
|
min := 100.0
|
||||||
|
inclusive := false
|
||||||
|
q := query.NewNumericRangeInclusiveQuery(&min, nil, &inclusive, nil)
|
||||||
|
q.SetField("photo.iso")
|
||||||
|
return q
|
||||||
|
}(),
|
||||||
|
func() query.Query {
|
||||||
|
max := 3200.0
|
||||||
|
inclusive := false
|
||||||
|
q := query.NewNumericRangeInclusiveQuery(nil, &max, nil, &inclusive)
|
||||||
|
q.SetField("photo.iso")
|
||||||
|
return q
|
||||||
|
}(),
|
||||||
|
}),
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `photo.fNumber>=2.8`,
|
||||||
|
args: &ast.Ast{
|
||||||
|
Nodes: []ast.Node{
|
||||||
|
&ast.NumericNode{
|
||||||
|
Key: "photo.fNumber",
|
||||||
|
Operator: &ast.OperatorNode{Value: ">="},
|
||||||
|
Value: 2.8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: query.NewConjunctionQuery([]query.Query{
|
||||||
|
func() query.Query {
|
||||||
|
min := 2.8
|
||||||
|
inclusive := true
|
||||||
|
q := query.NewNumericRangeInclusiveQuery(&min, nil, &inclusive, nil)
|
||||||
|
q.SetField("photo.fNumber")
|
||||||
|
return q
|
||||||
|
}(),
|
||||||
|
}),
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: `photo.cameraMake:canon`,
|
name: `photo.cameraMake:canon`,
|
||||||
args: &ast.Ast{
|
args: &ast.Ast{
|
||||||
|
|||||||
Reference in New Issue
Block a user