Compare commits

...

3 Commits

Author SHA1 Message Date
Naman Verma
dadcbfdb6d chore: shorten comments 2026-07-09 20:30:05 +05:30
Naman Verma
f8514482fa chore: move free text compilation to visitor 2026-07-09 20:09:25 +05:30
Naman Verma
c629cc40dc chore: support non-dsl search in dashboards v2 list apis 2026-07-09 19:13:38 +05:30
4 changed files with 209 additions and 21 deletions

View File

@@ -20,21 +20,19 @@ 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 valid `key OP value` filter compiles to the DSL predicate; a plain string
// (bare words, spaces allowed) is a case-insensitive substring search over the
// dashboard name, description, and tag keys/values; anything else is an error.
func Compile(query string, formatter sqlstore.SQLFormatter) (*Compiled, error) {
if len(query) == 0 {
if len(strings.TrimSpace(query)) == 0 {
return &Compiled{}, nil
}
queryVisitor := newVisitor(formatter)
sql, args, syntaxErrs := queryVisitor.compile(query)
if len(syntaxErrs) > 0 {
sql, args, errs := newVisitor(formatter).compile(query)
if len(errs) > 0 {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(syntaxErrs, "; "))
}
if len(queryVisitor.errors) > 0 {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(queryVisitor.errors, "; "))
"invalid filter query: %s", strings.Join(errs, "; "))
}
return &Compiled{

View File

@@ -460,6 +460,63 @@ func TestCompile_ComplexExamples(t *testing.T) {
})
}
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 +549,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,10 @@ type visitor struct {
selectBuilder *sqlbuilder.SelectBuilder
formatter sqlstore.SQLFormatter
errors []string
// bareTerms are primaries that were a lone key/value/full-text token, not a
// `key OP value` comparison. compile uses them to tell a free-text search (all
// bare terms) from a malformed filter (a bare term beside a comparison).
bareTerms []string
}
func newVisitor(formatter sqlstore.SQLFormatter) *visitor {
@@ -32,13 +36,37 @@ func newVisitor(formatter sqlstore.SQLFormatter) *visitor {
}
}
// compile turns the parse tree into `?`-placeholder WHERE SQL + arguments for bun.
// compile builds `?`-placeholder WHERE SQL + args for bun: a `key OP value`
// filter becomes the DSL predicate, an all-bare-terms query a free-text search,
// anything malformed an error.
func (v *visitor) compile(query string) (string, []any, []string) {
tree, _, collector := filterquery.Parse(query)
if len(collector.Errors) > 0 {
return "", nil, collector.Errors
}
condition, _ := v.visit(tree).(string)
// The query parsed into nothing but bare terms — treat it as a free-text search.
if condition == "" && len(v.errors) == 0 && len(v.bareTerms) > 0 {
value := strings.TrimSpace(query)
// A lone term spanning the whole query may be a quoted string — unquote it
// so a user can search a DSL-like literal, e.g. `"team = prod"`. A
// multi-word phrase is searched verbatim.
if len(v.bareTerms) == 1 && v.bareTerms[0] == value {
value = trimQuotes(value)
}
sql, arguments := v.compileFreeText(value)
return sql, arguments, nil
}
// A bare term alongside a real comparison is not valid DSL.
for _, bareTerm := range v.bareTerms {
v.addError("unsupported expression %q — every term must be of the form `key OP value`", bareTerm)
}
if len(v.errors) > 0 {
return "", nil, v.errors
}
if condition == "" {
return "", nil, nil
}
@@ -119,9 +147,9 @@ 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/full-text token, not a comparison — recorded as a bare
// term for compile to interpret.
v.bareTerms = append(v.bareTerms, ctx.GetText())
return ""
}
@@ -401,6 +429,50 @@ func buildSubqueryForTagKeyAndValue(subqueryBuilder *sqlbuilder.SelectBuilder, t
return buildSubqueryForTagKey(subqueryBuilder, tagKey).Where(valuePredicate)
}
// ─── free-text search ────────────────────────────────────────────────────────
// compileFreeText matches value as a case-insensitive substring of the dashboard
// name, description, or any tag key/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 contains as LOWER(col) LIKE
// LOWER(?) — identical on SQLite and Postgres. The value's % and _ are escaped to
// 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}"},