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
|
||||
}
|
||||
|
||||
// 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
|
||||
// AND, OR, NOT, =, <= ... and so on
|
||||
type OperatorNode struct {
|
||||
@@ -81,6 +89,8 @@ func NodeKey(n Node) string {
|
||||
return node.Key
|
||||
case *DateTimeNode:
|
||||
return node.Key
|
||||
case *NumericNode:
|
||||
return node.Key
|
||||
case *BooleanNode:
|
||||
return node.Key
|
||||
case *GroupNode:
|
||||
@@ -97,6 +107,8 @@ func NodeValue(n Node) interface{} {
|
||||
return node.Value
|
||||
case *DateTimeNode:
|
||||
return node.Value
|
||||
case *NumericNode:
|
||||
return node.Value
|
||||
case *BooleanNode:
|
||||
return node.Value
|
||||
case *GroupNode:
|
||||
|
||||
@@ -21,6 +21,7 @@ func DiffAst(x, y interface{}, opts ...cmp.Option) string {
|
||||
cmpopts.IgnoreFields(ast.GroupNode{}, "Base"),
|
||||
cmpopts.IgnoreFields(ast.BooleanNode{}, "Base"),
|
||||
cmpopts.IgnoreFields(ast.DateTimeNode{}, "Base"),
|
||||
cmpopts.IgnoreFields(ast.NumericNode{}, "Base"),
|
||||
)...,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package kql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/jinzhu/now"
|
||||
@@ -25,7 +26,7 @@ func toNodes[T ast.Node](in interface{}) ([]T, error) {
|
||||
return []T{v}, nil
|
||||
case []T:
|
||||
return v, nil
|
||||
case []*ast.OperatorNode, []*ast.DateTimeNode:
|
||||
case []*ast.OperatorNode, []*ast.DateTimeNode, []*ast.NumericNode:
|
||||
return toNodes[T](v)
|
||||
case []interface{}:
|
||||
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) {
|
||||
ts, err := toString(in)
|
||||
if err != nil {
|
||||
|
||||
@@ -40,6 +40,7 @@ GroupNode <-
|
||||
PropertyRestrictionNodes <-
|
||||
YesNoPropertyRestrictionNode /
|
||||
DateTimeRestrictionNode /
|
||||
NumericRestrictionNode /
|
||||
TextPropertyRestrictionNode
|
||||
|
||||
YesNoPropertyRestrictionNode <-
|
||||
@@ -69,6 +70,16 @@ DateTimeRestrictionNode <-
|
||||
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 <-
|
||||
k:Char+ (OperatorColonNode / OperatorEqualNode) v:(String / [^ ()]+){
|
||||
return buildStringNode(k, v, c.text, c.pos)
|
||||
@@ -224,6 +235,11 @@ String <-
|
||||
return v, nil
|
||||
}
|
||||
|
||||
Number <-
|
||||
[0-9]+ ("." [0-9]+)? {
|
||||
return c.text, nil
|
||||
}
|
||||
|
||||
Digit <-
|
||||
[0-9] {
|
||||
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) {
|
||||
tests := []testCase{
|
||||
{
|
||||
|
||||
@@ -73,6 +73,35 @@ func buildStringNode(k, v interface{}, text []byte, pos position) (*ast.StringNo
|
||||
}, 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) {
|
||||
b, err := base(text, pos)
|
||||
if err != nil {
|
||||
|
||||
@@ -153,6 +153,40 @@ func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
|
||||
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 {
|
||||
prev = q
|
||||
} else {
|
||||
|
||||
@@ -565,6 +565,107 @@ func Test_compile(t *testing.T) {
|
||||
}),
|
||||
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`,
|
||||
args: &ast.Ast{
|
||||
|
||||
Reference in New Issue
Block a user