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:
Paul Faure
2026-03-07 23:49:01 -05:00
parent 8b5bffcff9
commit 4b7349037f
10 changed files with 2164 additions and 2565 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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"),
)...,
)
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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{
{

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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{