chore: support non-dsl search in dashboards v2 list apis

This commit is contained in:
Naman Verma
2026-07-09 19:13:38 +05:30
parent da250bfd3e
commit c629cc40dc
4 changed files with 210 additions and 11 deletions

View File

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

View File

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

View File

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

View File

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