From 7dd9e8f18539303a8729ee6a86142d6ed6a5846a Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Tue, 2 Dec 2025 15:49:49 +0100 Subject: [PATCH] Alerting: Fix ignored filters when paginating alert rules in the API (#114710) Alerting: Fix alert rules pagination with filters --- pkg/services/ngalert/store/alert_rule.go | 21 +++-- pkg/services/ngalert/store/alert_rule_test.go | 88 +++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 3feefac6d73..6cc007b4a44 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -676,8 +676,10 @@ func (st DBstore) ListAlertRulesByGroup(ctx context.Context, query *ngmodels.Lis } func buildGroupCursorCondition(sess *xorm.Session, c ngmodels.GroupCursor) *xorm.Session { - return sess.Where("(namespace_uid > ?)", c.NamespaceUID). - Or("(namespace_uid = ? AND rule_group > ?)", c.NamespaceUID, c.RuleGroup) + return sess.And( + "((namespace_uid > ?) OR (namespace_uid = ? AND rule_group > ?))", + c.NamespaceUID, c.NamespaceUID, c.RuleGroup, + ) } func shouldIncludeRule(rule *ngmodels.AlertRule, query *ngmodels.ListAlertRulesExtendedQuery, groupsMap map[string]struct{}) bool { @@ -979,10 +981,17 @@ func decodeCursor(token string) (continueCursor, error) { } func buildCursorCondition(sess *xorm.Session, c continueCursor) *xorm.Session { - return sess.Where("(namespace_uid > ?)", c.NamespaceUID). - Or("(namespace_uid = ? AND rule_group > ?)", c.NamespaceUID, c.RuleGroup). - Or("(namespace_uid = ? AND rule_group = ? AND rule_group_idx > ?)", c.NamespaceUID, c.RuleGroup, c.RuleGroupIdx). - Or("(namespace_uid = ? AND rule_group = ? AND rule_group_idx = ? AND id > ?)", c.NamespaceUID, c.RuleGroup, c.RuleGroupIdx, c.ID) + return sess.And(`( + (namespace_uid > ?) + OR (namespace_uid = ? AND rule_group > ?) + OR (namespace_uid = ? AND rule_group = ? AND rule_group_idx > ?) + OR (namespace_uid = ? AND rule_group = ? AND rule_group_idx = ? AND id > ?) + )`, + c.NamespaceUID, + c.NamespaceUID, c.RuleGroup, + c.NamespaceUID, c.RuleGroup, c.RuleGroupIdx, + c.NamespaceUID, c.RuleGroup, c.RuleGroupIdx, c.ID, + ) } // Count returns either the number of the alert rules under a specific org (if orgID is not zero) diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index b9a574d33fc..5a4b1947181 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -1963,6 +1963,94 @@ func TestIntegration_ListAlertRulesByGroup(t *testing.T) { require.Equal(t, expected, actual, "rules should be ordered by group name") } }) + + t.Run("SearchTitle filter should be applied across all pages", func(t *testing.T) { + sqlStore := db.InitTestDB(t) + folderService2 := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) + store := createTestStore(sqlStore, folderService2, &logtest.Fake{}, cfg.UnifiedAlerting, &fakeBus{}) + + searchTitle := "str" + + // Create rules across multiple groups with some having "str" in title + // Group 1: 2 rules, one with "str", one without + // Group 2: 2 rules, none with "str" + // Group 3: 2 rules, one with "str", one without + ruleGen := models.RuleGen.With( + models.RuleMuts.WithIntervalMatching(cfg.UnifiedAlerting.BaseInterval), + models.RuleMuts.WithOrgID(orgID), + ) + + ns := "test-ns" + createRule(t, store, ruleGen.With( + ruleGen.WithNamespaceUID(ns), + ruleGen.WithGroupName("group-1"), + models.RuleMuts.WithTitle("rule-1 with str"), + )) + createRule(t, store, ruleGen.With( + ruleGen.WithNamespaceUID(ns), + ruleGen.WithGroupName("group-1"), + models.RuleMuts.WithTitle("rule-2"), + )) + createRule(t, store, ruleGen.With( + ruleGen.WithNamespaceUID(ns), + ruleGen.WithGroupName("group-2"), + models.RuleMuts.WithTitle("rule-3"), + )) + createRule(t, store, ruleGen.With( + ruleGen.WithNamespaceUID(ns), + ruleGen.WithGroupName("group-2"), + models.RuleMuts.WithTitle("rule-4"), + )) + createRule(t, store, ruleGen.With( + ruleGen.WithNamespaceUID(ns), + ruleGen.WithGroupName("group-3"), + models.RuleMuts.WithTitle("rule-5 with str"), + )) + createRule(t, store, ruleGen.With( + ruleGen.WithNamespaceUID(ns), + ruleGen.WithGroupName("group-3"), + models.RuleMuts.WithTitle("rule-6"), + )) + + // First page: get 1 group with SearchTitle filter + result, continueToken, err := store.ListAlertRulesByGroup(context.Background(), &models.ListAlertRulesExtendedQuery{ + ListAlertRulesQuery: models.ListAlertRulesQuery{ + OrgID: orgID, + SearchTitle: searchTitle, + }, + Limit: 1, + }) + require.NoError(t, err) + require.NotEmpty(t, continueToken, "should have more pages") + + // Verify first page only has rules with "str" in title + require.Len(t, result, 1) + for _, rule := range result { + require.Contains(t, strings.ToLower(rule.Title), searchTitle) + require.Equal(t, "group-1", rule.RuleGroup) + } + + // Second page + result2, continueToken2, err := store.ListAlertRulesByGroup(context.Background(), &models.ListAlertRulesExtendedQuery{ + ListAlertRulesQuery: models.ListAlertRulesQuery{ + OrgID: orgID, + SearchTitle: searchTitle, + }, + Limit: 1, + ContinueToken: continueToken, + }) + require.NoError(t, err) + + // Verify second page also only has rules with "str" in title + require.Len(t, result2, 1) + for _, rule := range result2 { + require.Contains(t, strings.ToLower(rule.Title), searchTitle) + require.Equal(t, "group-3", rule.RuleGroup) + } + + // After all pages, token should be empty + require.Empty(t, continueToken2, "should be no more pages") + }) } func Benchmark_ListAlertRules(b *testing.B) {