Add persistent multi-tag include and exclude filters

This commit is contained in:
Lars Nolden
2026-09-19 12:44:05 +02:00
parent 8aab21fe9e
commit 9cc3130b4f
11 changed files with 409 additions and 46 deletions
+18 -20
View File
@@ -15,13 +15,14 @@ import (
type Store struct{ db *sql.DB }
type Filter struct {
From string `json:"from"`
To string `json:"to"`
Currency string `json:"currency"`
AccountID string `json:"account_id"`
CategoryID string `json:"category_id"`
TagID string `json:"tag_id"`
MerchantID string `json:"merchant_id"`
From string `json:"from"`
To string `json:"to"`
Currency string `json:"currency"`
AccountID string `json:"account_id"`
CategoryID string `json:"category_id"`
TagIDs []string `json:"tag_ids"`
ExcludeTagIDs []string `json:"exclude_tag_ids"`
MerchantID string `json:"merchant_id"`
}
type Total struct {
@@ -272,21 +273,18 @@ func (f Filter) where() (string, []any) {
add("t.account_id = ?", f.AccountID)
add("t.merchant_id = ?", f.MerchantID)
add("EXISTS (SELECT 1 FROM category_ancestors ca WHERE ca.category_id = t.category_id AND ca.ancestor_id = ?)", f.CategoryID)
if f.TagID != "" {
ids := strings.Split(f.TagID, ",")
placeholders := make([]string, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id != "" {
placeholders = append(placeholders, "?")
args = append(args, id)
}
addTags := func(predicate string, ids []string) {
if len(ids) == 0 {
return
}
if len(placeholders) == 0 {
clauses = append(clauses, "FALSE")
} else {
clauses = append(clauses, "EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id IN ("+strings.Join(placeholders, ",")+"))")
placeholders := make([]string, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args = append(args, id)
}
clauses = append(clauses, predicate+" (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id IN ("+strings.Join(placeholders, ",")+"))")
}
addTags("EXISTS", f.TagIDs)
addTags("NOT EXISTS", f.ExcludeTagIDs)
return strings.Join(clauses, " AND "), args
}
+118 -3
View File
@@ -132,7 +132,7 @@ func TestMonthlySplitsDirectionsAndRanksLargestPerCurrency(t *testing.T) {
func TestTagUnionNeverDuplicatesTransactions(t *testing.T) {
s := openFixture(t, fixture())
filter := Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", TagID: "tag_shared,tag_work,tag_shared"}
filter := Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", TagIDs: []string{"tag_shared", "tag_work", "tag_shared"}}
got := queryFixture(t, s, filter)
want := []Total{{Currency: "EUR", Expenses: "900719925474.1000", Income: "0.0000", Net: "-900719925474.1000"}}
if !reflect.DeepEqual(got.Totals, want) {
@@ -141,17 +141,132 @@ func TestTagUnionNeverDuplicatesTransactions(t *testing.T) {
if len(got.Monthly) != 1 || got.Monthly[0].Count != 2 {
t.Fatalf("tag union count: %#v", got.Monthly)
}
filter.TagID = "tag_shared"
filter.TagIDs = []string{"tag_shared"}
got = queryFixture(t, s, filter)
if len(got.Totals) != 1 || got.Totals[0].Expenses != "900719925474.0991" {
t.Fatalf("single tag filter: %#v", got.Totals)
}
filter.TagID = "tag_shared') OR TRUE --"
filter.TagIDs = []string{"tag_shared') OR TRUE --"}
if totals := queryFixture(t, s, filter).Totals; len(totals) != 0 {
t.Fatalf("tag input altered SQL predicate: %#v", totals)
}
}
func TestTagExclusionsAndComposition(t *testing.T) {
s := openFixture(t, fixture())
cases := []struct {
name string
include []string
exclude []string
totals []Total
count int64
tagIDs []string
}{
{
name: "excluded tag removes whole multi-tag transaction",
exclude: []string{"tag_shared"},
totals: []Total{{Currency: "EUR", Expenses: "0.0009", Income: "100.1235", Net: "100.1226"}},
count: 3,
tagIDs: []string{"tag_work"},
},
{
name: "any excluded tag removes transaction and untagged income survives",
exclude: []string{"tag_shared", "tag_work"},
totals: []Total{{Currency: "EUR", Expenses: "0.0000", Income: "100.1235", Net: "100.1235"}},
count: 2,
tagIDs: []string{},
},
{
name: "include union and exclusion intersect with exclusion winning overlap",
include: []string{"tag_shared", "tag_work"},
exclude: []string{"tag_shared"},
totals: []Total{{Currency: "EUR", Expenses: "0.0009", Income: "0.0000", Net: "-0.0009"}},
count: 1,
tagIDs: []string{"tag_work"},
},
{
name: "identical include and exclude match nothing",
include: []string{"tag_shared"},
exclude: []string{"tag_shared"},
totals: []Total{},
tagIDs: []string{},
},
{
name: "exclusion values cannot alter SQL",
exclude: []string{"tag_shared') OR TRUE --"},
totals: []Total{{Currency: "EUR", Expenses: "900719925474.1000", Income: "100.1235", Net: "-900719925373.9765"}},
count: 4,
tagIDs: []string{"tag_shared", "tag_work"},
},
{
name: "empty lists leave transactions unrestricted",
include: []string{},
exclude: []string{},
totals: []Total{{Currency: "EUR", Expenses: "900719925474.1000", Income: "100.1235", Net: "-900719925373.9765"}},
count: 4,
tagIDs: []string{"tag_shared", "tag_work"},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
got := queryFixture(t, s, Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", TagIDs: tt.include, ExcludeTagIDs: tt.exclude})
if !reflect.DeepEqual(got.Totals, tt.totals) {
t.Fatalf("totals: got %#v, want %#v", got.Totals, tt.totals)
}
var count int64
for _, month := range got.Monthly {
count += month.Count
}
if count != tt.count {
t.Fatalf("transaction count: got %d, want %d", count, tt.count)
}
tagIDs := make([]string, 0, len(got.Tags))
for _, tag := range got.Tags {
tagIDs = append(tagIDs, tag.ID)
}
if !reflect.DeepEqual(tagIDs, tt.tagIDs) {
t.Fatalf("tag groups: got %#v, want %#v", got.Tags, tt.tagIDs)
}
accounts := []Group{}
if len(tt.totals) != 0 {
accounts = append(accounts, Group{ID: "acc_eur", Name: "Current", Currency: "EUR", Amount: tt.totals[0].Net, Count: tt.count})
}
if !reflect.DeepEqual(got.Accounts, accounts) {
t.Fatalf("account groups: got %#v, want %#v", got.Accounts, accounts)
}
})
}
}
func TestTagFiltersApplyToPreviousPeriodAndCategoryRollups(t *testing.T) {
data := fixture()
// Mirror current transactions into the preceding month, including the
// untagged income and the multi-tag expense that must be excluded.
for _, transaction := range data.Transactions[:4] {
transaction.Facts.ID += "_previous"
transaction.Facts.Fingerprint += "_previous"
transaction.Facts.BookingDate = "2026-01-15"
data.Transactions = append(data.Transactions, transaction)
}
s := openFixture(t, data)
got := queryFixture(t, s, Filter{
From: "2026-02-01", To: "2026-02-28", Currency: "EUR",
TagIDs: []string{"tag_shared", "tag_work"}, ExcludeTagIDs: []string{"tag_shared"},
})
want := []Total{{Currency: "EUR", Expenses: "0.0009", Income: "0.0000", Net: "-0.0009"}}
if !reflect.DeepEqual(got.Totals, want) || !reflect.DeepEqual(got.Previous, want) {
t.Fatalf("period totals: current %#v, previous %#v, want %#v", got.Totals, got.Previous, want)
}
groups := []Group{
{ID: "cat_expenses", Name: "Expenses", Currency: "EUR", Amount: "-0.0009", Count: 1},
{ID: "cat_food", Name: "Food", Currency: "EUR", Amount: "-0.0009", Count: 1},
{ID: "cat_living", Name: "Living", Currency: "EUR", Amount: "-0.0009", Count: 1},
}
if !reflect.DeepEqual(got.Categories, groups) || !reflect.DeepEqual(got.PreviousCategories, groups) {
t.Fatalf("category rollups: current %#v, previous %#v, want %#v", got.Categories, got.PreviousCategories, groups)
}
}
func TestAncestorFilteringAndRollups(t *testing.T) {
s := openFixture(t, fixture())
filter := Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", CategoryID: "cat_living"}