diff --git a/pkg/modules/dashboard/impldashboard/listfilter.go b/pkg/modules/dashboard/impldashboard/listfilter.go index baa1c3ad00..28f35cc1f6 100644 --- a/pkg/modules/dashboard/impldashboard/listfilter.go +++ b/pkg/modules/dashboard/impldashboard/listfilter.go @@ -20,8 +20,14 @@ func (c Compiled) IsEmpty() bool { // Compile always returns a non-nil *Compiled. An empty query (or one that // produces no SQL) yields a Compiled with an empty SQL — callers gate on // SQL != "" rather than a nil check. +// +// A query that is a valid `key OP value` filter compiles to the DSL predicate. +// A query made up entirely of bare words (a plain string, possibly with spaces) +// is a free-text search: a case-insensitive substring match against the +// dashboard name, description, and every tag key/value. A bare word mixed into a +// real filter, or any other malformed filter, is a validation error. func Compile(query string, formatter sqlstore.SQLFormatter) (*Compiled, error) { - if len(query) == 0 { + if len(strings.TrimSpace(query)) == 0 { return &Compiled{}, nil } @@ -32,6 +38,27 @@ func Compile(query string, formatter sqlstore.SQLFormatter) (*Compiled, error) { return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid, "invalid filter query: %s", strings.Join(syntaxErrs, "; ")) } + + // The query parsed into nothing but bare terms — treat it as a free-text search. + if sql == "" && len(queryVisitor.errors) == 0 && len(queryVisitor.bareTerms) > 0 { + value := strings.TrimSpace(query) + // A query that is a single quoted string searches for its contents + // literally — the escape hatch for terms that would otherwise parse as DSL. + if len(queryVisitor.bareTerms) == 1 && queryVisitor.bareTerms[0] == value { + value = trimQuotes(value) + } + freeTextVisitor := newVisitor(formatter) + freeSQL, freeArgs := freeTextVisitor.compileFreeText(value) + return &Compiled{ + SQL: freeSQL, + Args: freeArgs, + }, nil + } + + // A bare term alongside a real comparison is not valid DSL. + for _, bareTerm := range queryVisitor.bareTerms { + queryVisitor.addError("unsupported expression %q — every term must be of the form `key OP value`", bareTerm) + } if len(queryVisitor.errors) > 0 { return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid, "invalid filter query: %s", strings.Join(queryVisitor.errors, "; ")) diff --git a/pkg/modules/dashboard/impldashboard/listfilter_test.go b/pkg/modules/dashboard/impldashboard/listfilter_test.go index 277767919c..c587aea564 100644 --- a/pkg/modules/dashboard/impldashboard/listfilter_test.go +++ b/pkg/modules/dashboard/impldashboard/listfilter_test.go @@ -460,6 +460,66 @@ func TestCompile_ComplexExamples(t *testing.T) { }) } +// A query with no `key OP value` comparison — a bare word or a phrase with +// spaces — is a case-insensitive substring search over the dashboard name, +// description, and every tag key/value. The whole string is one CONTAINS term. +func TestCompile_FreeText(t *testing.T) { + // freeTextSQL is the predicate every free-text query compiles to; only the + // bound pattern differs. + freeTextSQL := ` + ( + lower(json_extract("dashboard"."data", '$.spec.display.name')) LIKE LOWER(?) ESCAPE '\' + OR lower(json_extract("dashboard"."data", '$.spec.display.description')) LIKE LOWER(?) ESCAPE '\' + OR EXISTS ( + SELECT 1 FROM tag_relation tr + JOIN tag t ON t.id = tr.tag_id + WHERE tr.kind = ? AND tr.resource_id = dashboard.id + AND (lower(t.key) LIKE LOWER(?) ESCAPE '\' OR lower(t.value) LIKE LOWER(?) ESCAPE '\') + ))` + freeTextArgs := func(pattern string) []any { + return []any{pattern, pattern, kindArg, pattern, pattern} + } + + runCompileCases(t, []compileCase{ + { + subtestName: "single bare word", + dslQueryToCompile: `payment`, + expectedSQL: freeTextSQL, + expectedArgs: freeTextArgs("%payment%"), + }, + { + subtestName: "phrase with spaces matches the whole string", + dslQueryToCompile: `prod payment service`, + expectedSQL: freeTextSQL, + expectedArgs: freeTextArgs("%prod payment service%"), + }, + { + subtestName: "quoted phrase", + dslQueryToCompile: `"prod payment service"`, + expectedSQL: freeTextSQL, + expectedArgs: freeTextArgs("%prod payment service%"), + }, + { + subtestName: "quoting is the escape hatch for a DSL-like literal", + dslQueryToCompile: `"team = prod"`, + expectedSQL: freeTextSQL, + expectedArgs: freeTextArgs("%team = prod%"), + }, + { + subtestName: "LIKE wildcards in the term are escaped to match literally", + dslQueryToCompile: `50% off`, + expectedSQL: freeTextSQL, + expectedArgs: freeTextArgs(`%50\% off%`), + }, + { + subtestName: "surrounding whitespace is trimmed", + dslQueryToCompile: ` payment `, + expectedSQL: freeTextSQL, + expectedArgs: freeTextArgs("%payment%"), + }, + }) +} + func TestCompile_Rejections(t *testing.T) { runCompileCases(t, []compileCase{ { @@ -492,6 +552,11 @@ func TestCompile_Rejections(t *testing.T) { dslQueryToCompile: `name = `, expectedErrShouldContain: "syntax", }, + { + subtestName: "rejects a bare term mixed into a real comparison", + dslQueryToCompile: `payment AND name = 'foo'`, + expectedErrShouldContain: "unsupported expression", + }, }) } diff --git a/pkg/modules/dashboard/impldashboard/listfilter_visitor.go b/pkg/modules/dashboard/impldashboard/listfilter_visitor.go index 0340d563f1..8d7e45836d 100644 --- a/pkg/modules/dashboard/impldashboard/listfilter_visitor.go +++ b/pkg/modules/dashboard/impldashboard/listfilter_visitor.go @@ -23,6 +23,11 @@ type visitor struct { selectBuilder *sqlbuilder.SelectBuilder formatter sqlstore.SQLFormatter errors []string + // bareTerms holds any primaries that were a lone key/value/full-text token + // rather than a `key OP value` comparison. Compile uses them to tell a + // free-text search (the whole query is bare terms) apart from a malformed + // filter (a bare term mixed into a real comparison). + bareTerms []string } func newVisitor(formatter sqlstore.SQLFormatter) *visitor { @@ -119,9 +124,10 @@ func (v *visitor) VisitPrimary(ctx *grammar.PrimaryContext) any { if ctx.Comparison() != nil { return v.visit(ctx.Comparison()) } - // Bare keys, values, full text, and function calls are not part of the - // dashboard list DSL. - v.addError("unsupported expression %q — every term must be of the form `key OP value`", ctx.GetText()) + // A lone key, value, or full-text token is not a `key OP value` comparison. + // Record it; Compile decides whether the query is a free-text search (all + // bare terms) or a malformed filter (bare term alongside a comparison). + v.bareTerms = append(v.bareTerms, ctx.GetText()) return "" } @@ -401,6 +407,51 @@ func buildSubqueryForTagKeyAndValue(subqueryBuilder *sqlbuilder.SelectBuilder, t return buildSubqueryForTagKey(subqueryBuilder, tagKey).Where(valuePredicate) } +// ─── free-text search ──────────────────────────────────────────────────────── + +// compileFreeText treats value as a case-insensitive substring search, matching +// when it is contained in the dashboard name, the description, or any tag's key +// or value. +func (v *visitor) compileFreeText(value string) (string, []any) { + nameColumn := string(v.formatter.JSONExtractString("dashboard.data", "$.spec.display.name")) + descriptionColumn := string(v.formatter.JSONExtractString("dashboard.data", "$.spec.display.description")) + namePredicate := v.buildFreeTextContains(v.selectBuilder, nameColumn, value) + descriptionPredicate := v.buildFreeTextContains(v.selectBuilder, descriptionColumn, value) + + subqueryBuilder := sqlbuilder.NewSelectBuilder() + keyPredicate := v.buildFreeTextContains(subqueryBuilder, "t.key", value) + valuePredicate := v.buildFreeTextContains(subqueryBuilder, "t.value", value) + buildSubqueryForFreeTextTag(subqueryBuilder, keyPredicate, valuePredicate) + tagPredicate := v.selectBuilder.Exists(subqueryBuilder) + + condition := v.selectBuilder.Or(namePredicate, descriptionPredicate, tagPredicate) + return v.selectBuilder.Args.CompileWithFlavor(condition, bunPlaceholderFlavor) +} + +// buildFreeTextContains emits a case-insensitive `col CONTAINS value` predicate as +// LOWER(col) LIKE LOWER(?) — identical on SQLite and Postgres. The value's % and _ +// are escaped so they match literally, and ESCAPE pins backslash as the escape char +// (SQLite has no default; a harmless restatement of the Postgres default). +func (v *visitor) buildFreeTextContains(builder *sqlbuilder.SelectBuilder, columnExpression, value string) string { + lowerColumn := string(v.formatter.LowerExpression(columnExpression)) + pattern := "%" + v.formatter.EscapeLikePattern(value) + "%" + return fmt.Sprintf("%s LIKE LOWER(%s) ESCAPE '\\'", lowerColumn, builder.Var(pattern)) +} + +func buildSubqueryForFreeTextTag(subqueryBuilder *sqlbuilder.SelectBuilder, keyPredicate, valuePredicate string) *sqlbuilder.SelectBuilder { + const dashboardTagKind = `"dashboard"` + + return subqueryBuilder. + Select("1"). + From("tag_relation tr"). + Join("tag t", "t.id = tr.tag_id"). + Where( + subqueryBuilder.Equal("tr.kind", dashboardTagKind), + "tr.resource_id = dashboard.id", + subqueryBuilder.Or(keyPredicate, valuePredicate), + ) +} + // ─── value extraction helpers ─────────────────────────────────────────────── func (v *visitor) addError(format string, arguments ...any) { diff --git a/tests/integration/tests/dashboard/03_v2_dashboard.py b/tests/integration/tests/dashboard/03_v2_dashboard.py index 2e3200f4c3..880cac69d5 100644 --- a/tests/integration/tests/dashboard/03_v2_dashboard.py +++ b/tests/integration/tests/dashboard/03_v2_dashboard.py @@ -723,7 +723,53 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta assert response.status_code == HTTPStatus.OK, response.text assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == expected, query - # ── stage 5: name sort honours order ───────────────────────────────────── + # ── stage 5: free-text search (a non-DSL query) ────────────────────────── + # A query that isn't a `key OP value` filter is a case-insensitive substring + # search over the dashboard name and every tag key/value. The whole string + # (spaces included) is one CONTAINS term, not a per-word match. + free_text_cases = [ + # name substring, matched case-insensitively + ("overview", {"Alpha Overview", "Beta Overview", "Zeta Overview"}), + # matches a name substring on some rows and a tag value on the same rows + ("storage", {"Gamma Storage", "Delta Storage"}), + # tag value only (no name contains "pulse") + ("pulse", {"Alpha Overview", "Beta Overview", "Zeta Overview"}), + # tag value only + ("critical", {"Delta Storage", "Epsilon Metrics"}), + # tag key only + ("tier", {"Delta Storage", "Epsilon Metrics"}), + # tag key present on every dashboard + ( + "env", + { + "Alpha Overview", + "Beta Overview", + "Gamma Storage", + "Delta Storage", + "Epsilon Metrics", + "Zeta Overview", + }, + ), + # a phrase matches as one whole substring, spaces included + ("Delta Storage", {"Delta Storage"}), + # whole-string, not per-word: no field contains this exact substring + ("Overview Storage", set()), + # a fully-quoted string is unquoted and searched literally + ('"Alpha Overview"', {"Alpha Overview"}), + # no match anywhere + ("nonexistent", set()), + ] + for query, expected in free_text_cases: + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"), + params={"query": query, "limit": 200}, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.OK, response.text + assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == expected, query + + # ── stage 6: name sort honours order ───────────────────────────────────── response = requests.get( signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"), params={"sort": "name", "order": "asc", "limit": 200}, @@ -753,7 +799,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta "Alpha Overview", ] - # ── stage 6: pinning floats a dashboard to the top of any ordering ─────── + # ── stage 7: pinning floats a dashboard to the top of any ordering ─────── assert ( requests.put( signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids['lc-gamma']}/pins"), @@ -791,7 +837,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta ] assert all("pinned" not in d for d in response.json()["data"]["dashboards"]) - # ── stage 7: unpinning restores the natural ordering ───────────────────── + # ── stage 8: unpinning restores the natural ordering ───────────────────── assert ( requests.delete( signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids['lc-gamma']}/pins"), @@ -815,7 +861,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta "Zeta Overview", ] - # ── stage 8: update mutates the spec but keeps the immutable name ──────── + # ── stage 9: update mutates the spec but keeps the immutable name ──────── update_body = { "schemaVersion": "v6", "name": "lc-alpha", @@ -840,7 +886,17 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta ) assert response.json()["data"]["spec"]["display"]["description"] == "now with a description" - # ── stage 9: a locked dashboard rejects updates until unlocked ─────────── + # free-text search also matches the description (only Alpha has one now) + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"), + params={"query": "now with a description", "limit": 200}, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.OK, response.text + assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == {"Alpha Overview"} + + # ── stage 10: a locked dashboard rejects updates until unlocked ────────── assert ( requests.put( signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-beta']}/lock"), @@ -880,7 +936,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta == HTTPStatus.OK ) - # ── stage 10: delete removes the dashboard from get and list ───────────── + # ── stage 11: delete removes the dashboard from get and list ───────────── assert ( requests.delete( signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-gamma']}"), @@ -912,7 +968,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta "Zeta Overview", } - # ── stage 11: clone suffixes the display name and mints a new, retrievable one ─ + # ── stage 12: clone suffixes the display name and mints a new, retrievable one ─ response = requests.post( signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-alpha']}/clone"), headers={"Authorization": f"Bearer {token}"},