mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-09 16:10:40 +01:00
Compare commits
3 Commits
main
...
nv/dashboa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dadcbfdb6d | ||
|
|
f8514482fa | ||
|
|
c629cc40dc |
@@ -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{
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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