Compare commits

...

3 Commits

Author SHA1 Message Date
Piyush Singariya
96cebb095b fix: only accounting primitive types 2026-03-05 17:44:20 +05:30
Piyush Singariya
a4d24d5055 fix: convert other types to string 2026-03-05 17:42:10 +05:30
Piyush Singariya
36c5db177f feat: operators LIKE/ILIKE support in logs pipelines 2026-03-05 17:27:55 +05:30
2 changed files with 201 additions and 11 deletions

View File

@@ -38,7 +38,6 @@ var logOperatorsToExpr = map[qbtypes.FilterOperator]string{
qbtypes.FilterOperatorNotIn: "not in",
qbtypes.FilterOperatorExists: "in",
qbtypes.FilterOperatorNotExists: "not in",
// LIKE/NOT LIKE and ILIKE/NOT ILIKE are not supported yet
}
func getName(key *telemetrytypes.TelemetryFieldKey) (string, error) {
@@ -249,17 +248,32 @@ func (v *exprVisitor) VisitComparison(ctx *grammar.ComparisonContext) any {
op = qbtypes.FilterOperatorIn
case ctx.NotInClause() != nil:
op = qbtypes.FilterOperatorNotIn
case ctx.LIKE() != nil || ctx.ILIKE() != nil:
v.errors = append(v.errors, "LIKE/ILIKE operators are not supported in expr expressions")
return ""
case ctx.LIKE() != nil:
if notModifier {
op = qbtypes.FilterOperatorNotLike
} else {
op = qbtypes.FilterOperatorLike
}
case ctx.ILIKE() != nil:
if notModifier {
op = qbtypes.FilterOperatorNotILike
} else {
op = qbtypes.FilterOperatorILike
}
default:
v.errors = append(v.errors, fmt.Sprintf("unsupported comparison operator: %s", ctx.GetText()))
return ""
}
if _, ok := logOperatorsToExpr[op]; !ok {
v.errors = append(v.errors, fmt.Sprintf("operator not supported: %v", op))
return ""
switch op {
case qbtypes.FilterOperatorLike, qbtypes.FilterOperatorNotLike,
qbtypes.FilterOperatorILike, qbtypes.FilterOperatorNotILike:
// supported using functions
default:
if _, ok := logOperatorsToExpr[op]; !ok {
v.errors = append(v.errors, fmt.Sprintf("operator not supported: %v", op))
return ""
}
}
// Build the right-hand side value.
@@ -275,8 +289,9 @@ func (v *exprVisitor) VisitComparison(ctx *grammar.ComparisonContext) any {
value = v.buildValue(valuesCtx[0])
}
// Validate regex patterns eagerly.
if op == qbtypes.FilterOperatorRegexp || op == qbtypes.FilterOperatorNotRegexp {
// Validate patterns eagerly.
switch op {
case qbtypes.FilterOperatorRegexp, qbtypes.FilterOperatorNotRegexp:
str, ok := value.(string)
if !ok {
v.errors = append(v.errors, "value for regex operator must be a string")
@@ -286,6 +301,13 @@ func (v *exprVisitor) VisitComparison(ctx *grammar.ComparisonContext) any {
v.errors = append(v.errors, "value for regex operator must be a valid regex")
return ""
}
case qbtypes.FilterOperatorLike, qbtypes.FilterOperatorNotLike,
qbtypes.FilterOperatorILike, qbtypes.FilterOperatorNotILike:
_, ok := value.(string)
if !ok {
v.errors = append(v.errors, "value for LIKE/NOT LIKE/ILIKE/ NOT ILIKE operator must be a string")
return ""
}
}
filter, err := buildFilterExpr(key, op, value)
@@ -400,10 +422,19 @@ func buildFilterExpr(key *telemetrytypes.TelemetryFieldKey, op qbtypes.FilterOpe
fmtValue := exprFormattedValue(value)
var filter string
if op == qbtypes.FilterOperatorContains || op == qbtypes.FilterOperatorNotContains {
switch op {
case qbtypes.FilterOperatorContains, qbtypes.FilterOperatorNotContains:
// contains / not contains must be case-insensitive to match query-time behaviour.
filter = fmt.Sprintf("lower(%s) %s lower(%s)", keyName, logOperatorsToExpr[op], fmtValue)
} else {
case qbtypes.FilterOperatorLike:
filter = fmt.Sprintf(`type(%s) != "map" && like(string(%s), %s)`, keyName, keyName, fmtValue)
case qbtypes.FilterOperatorNotLike:
filter = fmt.Sprintf(`type(%s) != "map" && not like(string(%s), %s)`, keyName, keyName, fmtValue)
case qbtypes.FilterOperatorILike:
filter = fmt.Sprintf(`type(%s) != "map" && ilike(string(%s), %s)`, keyName, keyName, fmtValue)
case qbtypes.FilterOperatorNotILike:
filter = fmt.Sprintf(`type(%s) != "map" && not ilike(string(%s), %s)`, keyName, keyName, fmtValue)
default:
filter = fmt.Sprintf("%s %s %s", keyName, logOperatorsToExpr[op], fmtValue)
}

View File

@@ -200,6 +200,48 @@ func TestParseExpression(t *testing.T) {
},
ExpectError: true,
},
{
Name: "LIKE",
Query: &qbtypes.Filter{
Expression: "attribute.key LIKE 'foo%'",
},
Expr: `attributes["key"] != nil && type(attributes["key"]) != "map" && like(string(attributes["key"]), "foo%")`,
},
{
Name: "NOT LIKE",
Query: &qbtypes.Filter{
Expression: "attribute.key NOT LIKE 'foo%'",
},
Expr: `attributes["key"] != nil && type(attributes["key"]) != "map" && not like(string(attributes["key"]), "foo%")`,
},
{
Name: "ILIKE",
Query: &qbtypes.Filter{
Expression: "attribute.key ILIKE 'FOO%'",
},
Expr: `attributes["key"] != nil && type(attributes["key"]) != "map" && ilike(string(attributes["key"]), "FOO%")`,
},
{
Name: "NOT ILIKE",
Query: &qbtypes.Filter{
Expression: "attribute.key NOT ILIKE 'FOO%'",
},
Expr: `attributes["key"] != nil && type(attributes["key"]) != "map" && not ilike(string(attributes["key"]), "FOO%")`,
},
{
Name: "body LIKE",
Query: &qbtypes.Filter{
Expression: "body LIKE 'Server%'",
},
Expr: `body != nil && type(body) != "map" && like(string(body), "Server%")`,
},
{
Name: "body ILIKE",
Query: &qbtypes.Filter{
Expression: "body ILIKE 'server%'",
},
Expr: `body != nil && type(body) != "map" && ilike(string(body), "server%")`,
},
}
for _, tt := range testCases {
@@ -512,6 +554,123 @@ func TestExpressionVSEntry(t *testing.T) {
},
ExpectedMatches: []int{5},
},
// LIKE / NOT LIKE / ILIKE / NOT ILIKE pattern and type-safety coverage
{
Name: "LIKE exact match (attribute)",
Query: &qbtypes.Filter{
Expression: "attribute.level LIKE 'info'",
},
ExpectedMatches: []int{0, 2, 16, 20},
},
{
Name: "LIKE prefix match (body)",
Query: &qbtypes.Filter{
Expression: "body LIKE 'Server%'",
},
ExpectedMatches: []int{13},
},
{
Name: "LIKE suffix match (body)",
Query: &qbtypes.Filter{
Expression: "body LIKE '%8080'",
},
ExpectedMatches: []int{13},
},
{
Name: "LIKE substring with % (body)",
Query: &qbtypes.Filter{
Expression: "body LIKE '%checkbody%'",
},
// map bodies excluded by type check; entries 18-20 have map body → no match
ExpectedMatches: []int{2, 9, 16},
},
{
Name: "LIKE single-char wildcard (attribute)",
Query: &qbtypes.Filter{
Expression: "attribute.level LIKE 'inf_'",
},
ExpectedMatches: []int{0, 2, 16, 20},
},
{
Name: "LIKE prefix on attribute",
Query: &qbtypes.Filter{
Expression: "attribute.service LIKE 'au%'",
},
ExpectedMatches: []int{6, 7, 15},
},
{
Name: "LIKE pattern on resource",
Query: &qbtypes.Filter{
Expression: "resource.host LIKE 'node-1%'",
},
ExpectedMatches: []int{1, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19},
},
{
Name: "NOT LIKE exact match (attribute)",
Query: &qbtypes.Filter{
Expression: "attribute.level NOT LIKE 'info'",
},
ExpectedMatches: []int{1, 3, 14, 15, 17},
},
{
Name: "NOT LIKE suffix pattern (attribute)",
Query: &qbtypes.Filter{
Expression: "attribute.level NOT LIKE '%o'",
},
// level ending in 'o': info; not ending in 'o': error, debug, warn
ExpectedMatches: []int{1, 3, 14, 15, 17},
},
{
Name: "ILIKE case-insensitive exact (attribute)",
Query: &qbtypes.Filter{
Expression: "attribute.level ILIKE 'INFO'",
},
ExpectedMatches: []int{0, 2, 16, 20},
},
{
Name: "ILIKE case-insensitive prefix (attribute)",
Query: &qbtypes.Filter{
Expression: "attribute.status ILIKE 'ACT%'",
},
ExpectedMatches: []int{11},
},
{
Name: "ILIKE case-insensitive substring (body)",
Query: &qbtypes.Filter{
Expression: "body ILIKE '%checkbody%'",
},
// map bodies excluded by type check
ExpectedMatches: []int{2, 9, 10, 16},
},
{
Name: "NOT ILIKE case-insensitive exact (attribute)",
Query: &qbtypes.Filter{
Expression: "attribute.level NOT ILIKE 'INFO'",
},
ExpectedMatches: []int{1, 3, 14, 15, 17},
},
{
Name: "NOT ILIKE case-insensitive (attribute)",
Query: &qbtypes.Filter{
Expression: "attribute.service NOT ILIKE 'API'",
},
ExpectedMatches: []int{6, 7, 15},
},
{
Name: "LIKE numeric attribute converted via string()",
Query: &qbtypes.Filter{
Expression: "attribute.count LIKE '%5%'",
},
// count int64(5) -> string "5" matches; count int64(10) -> "10" does not
ExpectedMatches: []int{4},
},
{
Name: "LIKE with multi filter (attribute + resource)",
Query: &qbtypes.Filter{
Expression: "attribute.level LIKE 'info' and resource.env = 'prod'",
},
ExpectedMatches: []int{0},
},
}
for _, tt := range testCases {