mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-09 16:10:40 +01:00
chore: support non-dsl search in dashboards v2 list apis
This commit is contained in:
@@ -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, "; "))
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}"},
|
||||
|
||||
Reference in New Issue
Block a user