summaryrefslogtreecommitdiff
path: root/internal/db/bundb
diff options
context:
space:
mode:
Diffstat (limited to 'internal/db/bundb')
-rw-r--r--internal/db/bundb/bundb.go17
-rw-r--r--internal/db/bundb/filter.go263
-rw-r--r--internal/db/bundb/filter_test.go185
-rw-r--r--internal/db/bundb/filterkeyword.go89
-rw-r--r--internal/db/bundb/filterkeyword_test.go49
-rw-r--r--internal/db/bundb/filterstatus.go101
-rw-r--r--internal/db/bundb/filterstatus_test.go47
-rw-r--r--internal/db/bundb/migrations/20241018151036_filter_unique_fix.go2
-rw-r--r--internal/db/bundb/migrations/20241018151036_filter_unique_fix/filter.go77
-rw-r--r--internal/db/bundb/migrations/20250617122055_filter_improvements.go303
-rw-r--r--internal/db/bundb/migrations/20250617122055_filter_improvements/filter.go243
11 files changed, 833 insertions, 543 deletions
diff --git a/internal/db/bundb/bundb.go b/internal/db/bundb/bundb.go
index ba705dd33..6545414a7 100644
--- a/internal/db/bundb/bundb.go
+++ b/internal/db/bundb/bundb.go
@@ -35,6 +35,7 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/config"
"code.superseriousbusiness.org/gotosocial/internal/db"
"code.superseriousbusiness.org/gotosocial/internal/db/bundb/migrations"
+ "code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/log"
"code.superseriousbusiness.org/gotosocial/internal/observability"
@@ -118,11 +119,17 @@ func doMigration(ctx context.Context, db *bun.DB) error {
log.Infof(ctx, "MIGRATED DATABASE TO %s", group)
if db.Dialect().Name() == dialect.SQLite {
- log.Info(ctx,
- "running ANALYZE to update table and index statistics; this will take somewhere between "+
- "1-10 minutes, or maybe longer depending on your hardware and database size, please be patient",
- )
- _, err := db.ExecContext(ctx, "ANALYZE")
+ // Perform a final WAL checkpoint after a migration on SQLite.
+ if strings.EqualFold(config.GetDbSqliteJournalMode(), "WAL") {
+ _, err := db.ExecContext(ctx, "PRAGMA wal_checkpoint(RESTART);")
+ if err != nil {
+ return gtserror.Newf("error performing wal_checkpoint: %w", err)
+ }
+ }
+
+ log.Info(ctx, "running ANALYZE to update table and index statistics; this will take somewhere between "+
+ "1-10 minutes, or maybe longer depending on your hardware and database size, please be patient")
+ _, err := db.ExecContext(ctx, "ANALYZE;")
if err != nil {
log.Warnf(ctx, "ANALYZE failed, query planner may make poor life choices: %s", err)
}
diff --git a/internal/db/bundb/filter.go b/internal/db/bundb/filter.go
index 24208b1f3..dbc560a12 100644
--- a/internal/db/bundb/filter.go
+++ b/internal/db/bundb/filter.go
@@ -21,8 +21,8 @@ import (
"context"
"errors"
"slices"
- "time"
+ "code.superseriousbusiness.org/gotosocial/internal/db"
"code.superseriousbusiness.org/gotosocial/internal/gtscontext"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
@@ -64,24 +64,14 @@ func (f *filterDB) GetFilterByID(ctx context.Context, id string) (*gtsmodel.Filt
return filter, nil
}
-func (f *filterDB) GetFiltersForAccountID(ctx context.Context, accountID string) ([]*gtsmodel.Filter, error) {
- // Fetch IDs of all filters owned by this account.
- var filterIDs []string
- if err := f.db.
- NewSelect().
- Model((*gtsmodel.Filter)(nil)).
- Column("id").
- Where("? = ?", bun.Ident("account_id"), accountID).
- Scan(ctx, &filterIDs); err != nil {
- return nil, err
- }
- if len(filterIDs) == 0 {
+func (f *filterDB) GetFiltersByIDs(ctx context.Context, ids []string) ([]*gtsmodel.Filter, error) {
+ if len(ids) == 0 {
return nil, nil
}
// Get each filter by ID from the cache or DB.
filters, err := f.state.Caches.DB.Filter.LoadIDs("ID",
- filterIDs,
+ ids,
func(uncached []string) ([]*gtsmodel.Filter, error) {
filters := make([]*gtsmodel.Filter, 0, len(uncached))
if err := f.db.
@@ -99,14 +89,15 @@ func (f *filterDB) GetFiltersForAccountID(ctx context.Context, accountID string)
}
// Put the filter structs in the same order as the filter IDs.
- xslices.OrderBy(filters, filterIDs, func(filter *gtsmodel.Filter) string { return filter.ID })
+ xslices.OrderBy(filters, ids, func(filter *gtsmodel.Filter) string { return filter.ID })
if gtscontext.Barebones(ctx) {
return filters, nil
}
+ var errs gtserror.MultiError
+
// Populate the filters. Remove any that we can't populate from the return slice.
- errs := gtserror.NewMultiError(len(filters))
filters = slices.DeleteFunc(filters, func(filter *gtsmodel.Filter) bool {
if err := f.populateFilter(ctx, filter); err != nil {
errs.Appendf("error populating filter %s: %w", filter.ID, err)
@@ -118,235 +109,115 @@ func (f *filterDB) GetFiltersForAccountID(ctx context.Context, accountID string)
return filters, errs.Combine()
}
+func (f *filterDB) GetFilterIDsByAccountID(ctx context.Context, accountID string) ([]string, error) {
+ return f.state.Caches.DB.FilterIDs.Load(accountID, func() ([]string, error) {
+ var filterIDs []string
+
+ if err := f.db.
+ NewSelect().
+ Model((*gtsmodel.Filter)(nil)).
+ Column("id").
+ Where("? = ?", bun.Ident("account_id"), accountID).
+ Scan(ctx, &filterIDs); err != nil {
+ return nil, err
+ }
+
+ return filterIDs, nil
+ })
+}
+
+func (f *filterDB) GetFiltersByAccountID(ctx context.Context, accountID string) ([]*gtsmodel.Filter, error) {
+ filterIDs, err := f.GetFilterIDsByAccountID(ctx, accountID)
+ if err != nil {
+ return nil, gtserror.Newf("error getting filter ids: %w", err)
+ }
+ return f.GetFiltersByIDs(ctx, filterIDs)
+}
+
func (f *filterDB) populateFilter(ctx context.Context, filter *gtsmodel.Filter) error {
var err error
- errs := gtserror.NewMultiError(2)
+ var errs gtserror.MultiError
- if filter.Keywords == nil {
+ if !filter.KeywordsPopulated() {
// Filter keywords are not set, fetch from the database.
- filter.Keywords, err = f.state.DB.GetFilterKeywordsForFilterID(
- gtscontext.SetBarebones(ctx),
- filter.ID,
- )
+ filter.Keywords, err = f.GetFilterKeywordsByIDs(ctx, filter.KeywordIDs)
if err != nil {
errs.Appendf("error populating filter keywords: %w", err)
}
- for i := range filter.Keywords {
- filter.Keywords[i].Filter = filter
- }
}
- if filter.Statuses == nil {
+ if !filter.StatusesPopulated() {
// Filter statuses are not set, fetch from the database.
- filter.Statuses, err = f.state.DB.GetFilterStatusesForFilterID(
- gtscontext.SetBarebones(ctx),
- filter.ID,
- )
+ filter.Statuses, err = f.GetFilterStatusesByIDs(ctx, filter.StatusIDs)
if err != nil {
errs.Appendf("error populating filter statuses: %w", err)
}
- for i := range filter.Statuses {
- filter.Statuses[i].Filter = filter
- }
}
return errs.Combine()
}
func (f *filterDB) PutFilter(ctx context.Context, filter *gtsmodel.Filter) error {
- // Pre-compile filter keyword regular expressions.
- for _, filterKeyword := range filter.Keywords {
- if err := filterKeyword.Compile(); err != nil {
- return gtserror.Newf("error compiling filter keyword regex: %w", err)
- }
- }
-
- // Update database.
- if err := f.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
- if _, err := tx.
- NewInsert().
- Model(filter).
- Exec(ctx); err != nil {
- return err
- }
-
- if len(filter.Keywords) > 0 {
- if _, err := tx.
- NewInsert().
- Model(&filter.Keywords).
- Exec(ctx); err != nil {
- return err
- }
- }
-
- if len(filter.Statuses) > 0 {
- if _, err := tx.
- NewInsert().
- Model(&filter.Statuses).
- Exec(ctx); err != nil {
- return err
- }
- }
-
- return nil
- }); err != nil {
+ return f.state.Caches.DB.Filter.Store(filter, func() error {
+ _, err := f.db.NewInsert().Model(filter).Exec(ctx)
return err
- }
-
- // Update cache.
- f.state.Caches.DB.Filter.Put(filter)
- f.state.Caches.DB.FilterKeyword.Put(filter.Keywords...)
- f.state.Caches.DB.FilterStatus.Put(filter.Statuses...)
-
- return nil
+ })
}
-func (f *filterDB) UpdateFilter(
- ctx context.Context,
- filter *gtsmodel.Filter,
- filterColumns []string,
- filterKeywordColumns [][]string,
- deleteFilterKeywordIDs []string,
- deleteFilterStatusIDs []string,
-) error {
- if len(filter.Keywords) != len(filterKeywordColumns) {
- return errors.New("number of filter keywords must match number of lists of filter keyword columns")
- }
-
- updatedAt := time.Now()
- filter.UpdatedAt = updatedAt
- for _, filterKeyword := range filter.Keywords {
- filterKeyword.UpdatedAt = updatedAt
- }
- for _, filterStatus := range filter.Statuses {
- filterStatus.UpdatedAt = updatedAt
- }
-
- // If we're updating by column, ensure "updated_at" is included.
- if len(filterColumns) > 0 {
- filterColumns = append(filterColumns, "updated_at")
- }
- for i := range filterKeywordColumns {
- if len(filterKeywordColumns[i]) > 0 {
- filterKeywordColumns[i] = append(filterKeywordColumns[i], "updated_at")
- }
- }
-
- // Pre-compile filter keyword regular expressions.
- for _, filterKeyword := range filter.Keywords {
- if err := filterKeyword.Compile(); err != nil {
- return gtserror.Newf("error compiling filter keyword regex: %w", err)
- }
- }
-
- // Update database.
- if err := f.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
- if _, err := tx.
- NewUpdate().
+func (f *filterDB) UpdateFilter(ctx context.Context, filter *gtsmodel.Filter, cols ...string) error {
+ return f.state.Caches.DB.Filter.Store(filter, func() error {
+ _, err := f.db.NewUpdate().
Model(filter).
- Column(filterColumns...).
Where("? = ?", bun.Ident("id"), filter.ID).
- Exec(ctx); err != nil {
- return err
- }
-
- for i, filterKeyword := range filter.Keywords {
- if _, err := NewUpsert(tx).
- Model(filterKeyword).
- Constraint("id").
- Column(filterKeywordColumns[i]...).
- Exec(ctx); err != nil {
- return err
- }
- }
-
- if len(filter.Statuses) > 0 {
- if _, err := tx.
- NewInsert().
- Ignore().
- Model(&filter.Statuses).
- Exec(ctx); err != nil {
- return err
- }
- }
-
- if len(deleteFilterKeywordIDs) > 0 {
- if _, err := tx.
- NewDelete().
- Model((*gtsmodel.FilterKeyword)(nil)).
- Where("? = (?)", bun.Ident("id"), bun.In(deleteFilterKeywordIDs)).
- Exec(ctx); err != nil {
- return err
- }
- }
-
- if len(deleteFilterStatusIDs) > 0 {
- if _, err := tx.
- NewDelete().
- Model((*gtsmodel.FilterStatus)(nil)).
- Where("? = (?)", bun.Ident("id"), bun.In(deleteFilterStatusIDs)).
- Exec(ctx); err != nil {
- return err
- }
- }
-
- return nil
- }); err != nil {
+ Column(cols...).
+ Exec(ctx)
return err
- }
-
- // Update cache.
- f.state.Caches.DB.Filter.Put(filter)
- f.state.Caches.DB.FilterKeyword.Put(filter.Keywords...)
- f.state.Caches.DB.FilterStatus.Put(filter.Statuses...)
- // TODO: (Vyr) replace with cache multi-invalidate call
- for _, id := range deleteFilterKeywordIDs {
- f.state.Caches.DB.FilterKeyword.Invalidate("ID", id)
- }
- for _, id := range deleteFilterStatusIDs {
- f.state.Caches.DB.FilterStatus.Invalidate("ID", id)
- }
-
- return nil
+ })
}
-func (f *filterDB) DeleteFilterByID(ctx context.Context, id string) error {
+func (f *filterDB) DeleteFilter(ctx context.Context, filter *gtsmodel.Filter) error {
if err := f.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
- // Delete all keywords attached to filter.
+ // Delete all keywords both known
+ // by filter, and possible stragglers,
+ // storing IDs in filter.KeywordIDs.
if _, err := tx.
NewDelete().
Model((*gtsmodel.FilterKeyword)(nil)).
- Where("? = ?", bun.Ident("filter_id"), id).
- Exec(ctx); err != nil {
+ Where("? = ?", bun.Ident("filter_id"), filter.ID).
+ Returning("?", bun.Ident("id")).
+ Exec(ctx, &filter.KeywordIDs); err != nil &&
+ !errors.Is(err, db.ErrNoEntries) {
return err
}
- // Delete all statuses attached to filter.
+ // Delete all statuses both known
+ // by filter, and possible stragglers.
+ // storing IDs in filter.StatusIDs.
if _, err := tx.
NewDelete().
Model((*gtsmodel.FilterStatus)(nil)).
- Where("? = ?", bun.Ident("filter_id"), id).
- Exec(ctx); err != nil {
+ Where("? = ?", bun.Ident("filter_id"), filter.ID).
+ Returning("?", bun.Ident("id")).
+ Exec(ctx, &filter.StatusIDs); err != nil &&
+ !errors.Is(err, db.ErrNoEntries) {
return err
}
- // Delete the filter itself.
+ // Delete filter itself.
_, err := tx.
NewDelete().
Model((*gtsmodel.Filter)(nil)).
- Where("? = ?", bun.Ident("id"), id).
+ Where("? = ?", bun.Ident("id"), filter.ID).
Exec(ctx)
return err
}); err != nil {
return err
}
- // Invalidate this filter.
- f.state.Caches.DB.Filter.Invalidate("ID", id)
-
- // Invalidate all keywords and statuses for this filter.
- f.state.Caches.DB.FilterKeyword.Invalidate("FilterID", id)
- f.state.Caches.DB.FilterStatus.Invalidate("FilterID", id)
+ // Invalidate the filter itself, and
+ // call invalidate hook in-case not cached.
+ f.state.Caches.DB.Filter.Invalidate("ID", filter.ID)
+ f.state.Caches.OnInvalidateFilter(filter)
return nil
}
diff --git a/internal/db/bundb/filter_test.go b/internal/db/bundb/filter_test.go
index 12f3476ed..ce41ac016 100644
--- a/internal/db/bundb/filter_test.go
+++ b/internal/db/bundb/filter_test.go
@@ -38,26 +38,30 @@ func (suite *FilterTestSuite) TestFilterCRUD() {
// Create new example filter with attached keyword.
filter := &gtsmodel.Filter{
- ID: "01HNEJNVZZVXJTRB3FX3K2B1YF",
- AccountID: "01HNEJXCPRTJVJY9MV0VVHGD47",
- Title: "foss jail",
- Action: gtsmodel.FilterActionWarn,
- ContextHome: util.Ptr(true),
- ContextPublic: util.Ptr(true),
+ ID: "01HNEJNVZZVXJTRB3FX3K2B1YF",
+ AccountID: "01HNEJXCPRTJVJY9MV0VVHGD47",
+ Title: "foss jail",
+ Action: gtsmodel.FilterActionWarn,
+ Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextHome | gtsmodel.FilterContextPublic),
}
filterKeyword := &gtsmodel.FilterKeyword{
- ID: "01HNEK4RW5QEAMG9Y4ET6ST0J4",
- AccountID: filter.AccountID,
- FilterID: filter.ID,
- Keyword: "GNU/Linux",
+ ID: "01HNEK4RW5QEAMG9Y4ET6ST0J4",
+ FilterID: filter.ID,
+ Keyword: "GNU/Linux",
}
filter.Keywords = []*gtsmodel.FilterKeyword{filterKeyword}
+ filter.KeywordIDs = []string{filterKeyword.ID}
// Create new cancellable test context.
ctx := suite.T().Context()
ctx, cncl := context.WithCancel(ctx)
defer cncl()
+ // Insert the example filter keyword into db.
+ if err := suite.db.PutFilterKeyword(ctx, filterKeyword); err != nil {
+ t.Fatalf("error inserting filter keyword: %v", err)
+ }
+
// Insert the example filter into db.
if err := suite.db.PutFilter(ctx, filter); err != nil {
t.Fatalf("error inserting filter: %v", err)
@@ -74,27 +78,18 @@ func (suite *FilterTestSuite) TestFilterCRUD() {
suite.Equal(filter.AccountID, check.AccountID)
suite.Equal(filter.Title, check.Title)
suite.Equal(filter.Action, check.Action)
- suite.Equal(filter.ContextHome, check.ContextHome)
- suite.Equal(filter.ContextNotifications, check.ContextNotifications)
- suite.Equal(filter.ContextPublic, check.ContextPublic)
- suite.Equal(filter.ContextThread, check.ContextThread)
- suite.Equal(filter.ContextAccount, check.ContextAccount)
- suite.NotZero(check.CreatedAt)
- suite.NotZero(check.UpdatedAt)
+ suite.Equal(filter.Contexts, check.Contexts)
suite.Equal(len(filter.Keywords), len(check.Keywords))
suite.Equal(filter.Keywords[0].ID, check.Keywords[0].ID)
- suite.Equal(filter.Keywords[0].AccountID, check.Keywords[0].AccountID)
suite.Equal(filter.Keywords[0].FilterID, check.Keywords[0].FilterID)
suite.Equal(filter.Keywords[0].Keyword, check.Keywords[0].Keyword)
suite.Equal(filter.Keywords[0].FilterID, check.Keywords[0].FilterID)
- suite.NotZero(check.Keywords[0].CreatedAt)
- suite.NotZero(check.Keywords[0].UpdatedAt)
suite.Equal(len(filter.Statuses), len(check.Statuses))
// Fetch all filters.
- all, err := suite.db.GetFiltersForAccountID(ctx, filter.AccountID)
+ all, err := suite.db.GetFiltersByAccountID(ctx, filter.AccountID)
if err != nil {
t.Fatalf("error fetching filters: %v", err)
}
@@ -108,28 +103,39 @@ func (suite *FilterTestSuite) TestFilterCRUD() {
suite.Empty(all[0].Statuses)
- // Update the filter context and add another keyword and a status.
- check.ContextNotifications = util.Ptr(true)
-
+ // Update the filter context and
+ // add another keyword and a status.
+ check.Contexts.SetNotifications()
newKeyword := &gtsmodel.FilterKeyword{
- ID: "01HNEMY810E5XKWDDMN5ZRE749",
- FilterID: filter.ID,
- AccountID: filter.AccountID,
- Keyword: "tux",
+ ID: "01HNEMY810E5XKWDDMN5ZRE749",
+ FilterID: filter.ID,
+ Keyword: "tux",
}
check.Keywords = append(check.Keywords, newKeyword)
-
+ check.KeywordIDs = append(check.KeywordIDs, newKeyword.ID)
newStatus := &gtsmodel.FilterStatus{
- ID: "01HNEMYD5XE7C8HH8TNCZ76FN2",
- FilterID: filter.ID,
- AccountID: filter.AccountID,
- StatusID: "01HNEKZW34SQZ8PSDQ0Z10NZES",
+ ID: "01HNEMYD5XE7C8HH8TNCZ76FN2",
+ FilterID: filter.ID,
+ StatusID: "01HNEKZW34SQZ8PSDQ0Z10NZES",
}
check.Statuses = append(check.Statuses, newStatus)
+ check.StatusIDs = append(check.StatusIDs, newStatus.ID)
- if err := suite.db.UpdateFilter(ctx, check, nil, [][]string{nil, nil}, nil, nil); err != nil {
+ // Insert the new filter keyword.
+ if err := suite.db.PutFilterKeyword(ctx, newKeyword); err != nil {
+ t.Fatalf("error inserting filter keyword: %v", err)
+ }
+
+ // Insert the new filter status.
+ if err := suite.db.PutFilterStatus(ctx, newStatus); err != nil {
+ t.Fatalf("error inserting filter status: %v", err)
+ }
+
+ // Now update the filter with new keyword and status.
+ if err := suite.db.UpdateFilter(ctx, check); err != nil {
t.Fatalf("error updating filter: %v", err)
}
+
// Now fetch newly updated filter.
check, err = suite.db.GetFilterByID(ctx, filter.ID)
if err != nil {
@@ -137,22 +143,11 @@ func (suite *FilterTestSuite) TestFilterCRUD() {
}
// Ensure expected fields were modified on check filter.
- suite.True(check.UpdatedAt.After(filter.UpdatedAt))
- if suite.NotNil(check.ContextHome) {
- suite.True(*check.ContextHome)
- }
- if suite.NotNil(check.ContextNotifications) {
- suite.True(*check.ContextNotifications)
- }
- if suite.NotNil(check.ContextPublic) {
- suite.True(*check.ContextPublic)
- }
- if suite.NotNil(check.ContextThread) {
- suite.False(*check.ContextThread)
- }
- if suite.NotNil(check.ContextAccount) {
- suite.False(*check.ContextAccount)
- }
+ suite.True(check.Contexts.Home())
+ suite.True(check.Contexts.Notifications())
+ suite.True(check.Contexts.Public())
+ suite.False(check.Contexts.Thread())
+ suite.False(check.Contexts.Account())
// Ensure keyword entries were added.
suite.Len(check.Keywords, 2)
@@ -175,9 +170,19 @@ func (suite *FilterTestSuite) TestFilterCRUD() {
check.Keywords = []*gtsmodel.FilterKeyword{filterKeyword}
check.Statuses = nil
- if err := suite.db.UpdateFilter(ctx, check, nil, [][]string{{"whole_word"}}, []string{newKeyword.ID}, nil); err != nil {
+ // Update the original filter keyword.
+ filterKeyword.WholeWord = util.Ptr(true)
+ if err := suite.db.UpdateFilterKeyword(ctx, filterKeyword); err != nil {
+ t.Fatalf("error updating filter keyword: %v", err)
+ }
+
+ // Drop most recently added filter keyword from filter.
+ check.Keywords = []*gtsmodel.FilterKeyword{filterKeyword}
+ check.KeywordIDs = []string{filterKeyword.ID}
+ if err := suite.db.UpdateFilter(ctx, check); err != nil {
t.Fatalf("error updating filter: %v", err)
}
+
check, err = suite.db.GetFilterByID(ctx, filter.ID)
if err != nil {
t.Fatalf("error fetching updated filter: %v", err)
@@ -186,23 +191,14 @@ func (suite *FilterTestSuite) TestFilterCRUD() {
// Ensure expected fields were not modified.
suite.Equal(filter.Title, check.Title)
suite.Equal(gtsmodel.FilterActionWarn, check.Action)
- if suite.NotNil(check.ContextHome) {
- suite.True(*check.ContextHome)
- }
- if suite.NotNil(check.ContextNotifications) {
- suite.True(*check.ContextNotifications)
- }
- if suite.NotNil(check.ContextPublic) {
- suite.True(*check.ContextPublic)
- }
- if suite.NotNil(check.ContextThread) {
- suite.False(*check.ContextThread)
- }
- if suite.NotNil(check.ContextAccount) {
- suite.False(*check.ContextAccount)
- }
-
- // Ensure only changed field of keyword was modified, and other keyword was deleted.
+ suite.True(check.Contexts.Home())
+ suite.True(check.Contexts.Notifications())
+ suite.True(check.Contexts.Public())
+ suite.False(check.Contexts.Thread())
+ suite.False(check.Contexts.Account())
+
+ // Ensure only changed field of keyword was
+ // modified, and other keyword was deleted.
suite.Len(check.Keywords, 1)
suite.Equal(filterKeyword.ID, check.Keywords[0].ID)
suite.Equal("GNU/Linux", check.Keywords[0].Keyword)
@@ -214,29 +210,8 @@ func (suite *FilterTestSuite) TestFilterCRUD() {
suite.Len(check.Statuses, 1)
suite.Equal(newStatus.ID, check.Statuses[0].ID)
- // Add another status entry for the same status ID. It should be ignored without problems.
- redundantStatus := &gtsmodel.FilterStatus{
- ID: "01HQXJ5Y405XZSQ67C2BSQ6HJ0",
- FilterID: filter.ID,
- AccountID: filter.AccountID,
- StatusID: newStatus.StatusID,
- }
- check.Statuses = []*gtsmodel.FilterStatus{redundantStatus}
- if err := suite.db.UpdateFilter(ctx, check, nil, [][]string{nil}, nil, nil); err != nil {
- t.Fatalf("error updating filter: %v", err)
- }
- check, err = suite.db.GetFilterByID(ctx, filter.ID)
- if err != nil {
- t.Fatalf("error fetching updated filter: %v", err)
- }
-
- // Ensure status entry was not deleted, updated, or duplicated.
- suite.Len(check.Statuses, 1)
- suite.Equal(newStatus.ID, check.Statuses[0].ID)
- suite.Equal(newStatus.StatusID, check.Statuses[0].StatusID)
-
// Now delete the filter from the DB.
- if err := suite.db.DeleteFilterByID(ctx, filter.ID); err != nil {
+ if err := suite.db.DeleteFilter(ctx, filter); err != nil {
t.Fatalf("error deleting filter: %v", err)
}
@@ -256,11 +231,11 @@ func (suite *FilterTestSuite) TestFilterTitleOverlap() {
// Create an empty filter for account 1.
account1filter1 := &gtsmodel.Filter{
- ID: "01HNEJNVZZVXJTRB3FX3K2B1YF",
- AccountID: account1,
- Title: "my filter",
- Action: gtsmodel.FilterActionWarn,
- ContextHome: util.Ptr(true),
+ ID: "01HNEJNVZZVXJTRB3FX3K2B1YF",
+ AccountID: account1,
+ Title: "my filter",
+ Action: gtsmodel.FilterActionWarn,
+ Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextHome),
}
if err := suite.db.PutFilter(ctx, account1filter1); err != nil {
suite.FailNow("", "error putting account1filter1: %s", err)
@@ -269,11 +244,11 @@ func (suite *FilterTestSuite) TestFilterTitleOverlap() {
// Create a filter for account 2 with
// the same title, should be no issue.
account2filter1 := &gtsmodel.Filter{
- ID: "01JAG5GPXG7H5Y4ZP78GV1F2ET",
- AccountID: account2,
- Title: "my filter",
- Action: gtsmodel.FilterActionWarn,
- ContextHome: util.Ptr(true),
+ ID: "01JAG5GPXG7H5Y4ZP78GV1F2ET",
+ AccountID: account2,
+ Title: "my filter",
+ Action: gtsmodel.FilterActionWarn,
+ Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextHome),
}
if err := suite.db.PutFilter(ctx, account2filter1); err != nil {
suite.FailNow("", "error putting account2filter1: %s", err)
@@ -283,11 +258,11 @@ func (suite *FilterTestSuite) TestFilterTitleOverlap() {
// account 1 with the same name as
// an existing filter of theirs.
account1filter2 := &gtsmodel.Filter{
- ID: "01JAG5J8NYKQE2KYCD28Y4P05V",
- AccountID: account1,
- Title: "my filter",
- Action: gtsmodel.FilterActionWarn,
- ContextHome: util.Ptr(true),
+ ID: "01JAG5J8NYKQE2KYCD28Y4P05V",
+ AccountID: account1,
+ Title: "my filter",
+ Action: gtsmodel.FilterActionWarn,
+ Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextHome),
}
err := suite.db.PutFilter(ctx, account1filter2)
if !errors.Is(err, db.ErrAlreadyExists) {
diff --git a/internal/db/bundb/filterkeyword.go b/internal/db/bundb/filterkeyword.go
index 1c80061e9..1476245b0 100644
--- a/internal/db/bundb/filterkeyword.go
+++ b/internal/db/bundb/filterkeyword.go
@@ -20,9 +20,7 @@ package bundb
import (
"context"
"slices"
- "time"
- "code.superseriousbusiness.org/gotosocial/internal/gtscontext"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/log"
@@ -31,7 +29,7 @@ import (
)
func (f *filterDB) GetFilterKeywordByID(ctx context.Context, id string) (*gtsmodel.FilterKeyword, error) {
- filterKeyword, err := f.state.Caches.DB.FilterKeyword.LoadOne(
+ return f.state.Caches.DB.FilterKeyword.LoadOne(
"ID",
func() (*gtsmodel.FilterKeyword, error) {
var filterKeyword gtsmodel.FilterKeyword
@@ -54,64 +52,16 @@ func (f *filterDB) GetFilterKeywordByID(ctx context.Context, id string) (*gtsmod
},
id,
)
- if err != nil {
- return nil, err
- }
-
- if !gtscontext.Barebones(ctx) {
- err = f.populateFilterKeyword(ctx, filterKeyword)
- if err != nil {
- return nil, err
- }
- }
-
- return filterKeyword, nil
-}
-
-func (f *filterDB) populateFilterKeyword(ctx context.Context, filterKeyword *gtsmodel.FilterKeyword) (err error) {
- if filterKeyword.Filter == nil {
- // Filter is not set, fetch from the cache or database.
- filterKeyword.Filter, err = f.state.DB.GetFilterByID(
-
- // Don't populate the filter with all of its keywords
- // and statuses or we'll just end up back here.
- gtscontext.SetBarebones(ctx),
- filterKeyword.FilterID,
- )
- if err != nil {
- return err
- }
- }
- return nil
-}
-
-func (f *filterDB) GetFilterKeywordsForFilterID(ctx context.Context, filterID string) ([]*gtsmodel.FilterKeyword, error) {
- return f.getFilterKeywords(ctx, "filter_id", filterID)
-}
-
-func (f *filterDB) GetFilterKeywordsForAccountID(ctx context.Context, accountID string) ([]*gtsmodel.FilterKeyword, error) {
- return f.getFilterKeywords(ctx, "account_id", accountID)
}
-func (f *filterDB) getFilterKeywords(ctx context.Context, idColumn string, id string) ([]*gtsmodel.FilterKeyword, error) {
- var filterKeywordIDs []string
-
- if err := f.db.
- NewSelect().
- Model((*gtsmodel.FilterKeyword)(nil)).
- Column("id").
- Where("? = ?", bun.Ident(idColumn), id).
- Scan(ctx, &filterKeywordIDs); err != nil {
- return nil, err
- }
-
- if len(filterKeywordIDs) == 0 {
+func (f *filterDB) GetFilterKeywordsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.FilterKeyword, error) {
+ if len(ids) == 0 {
return nil, nil
}
// Get each filter keyword by ID from the cache or DB.
filterKeywords, err := f.state.Caches.DB.FilterKeyword.LoadIDs("ID",
- filterKeywordIDs,
+ ids,
func(uncached []string) ([]*gtsmodel.FilterKeyword, error) {
filterKeywords := make([]*gtsmodel.FilterKeyword, 0, len(uncached))
@@ -140,23 +90,10 @@ func (f *filterDB) getFilterKeywords(ctx context.Context, idColumn string, id st
}
// Put the filter keyword structs in the same order as the filter keyword IDs.
- xslices.OrderBy(filterKeywords, filterKeywordIDs, func(filterKeyword *gtsmodel.FilterKeyword) string {
+ xslices.OrderBy(filterKeywords, ids, func(filterKeyword *gtsmodel.FilterKeyword) string {
return filterKeyword.ID
})
- if gtscontext.Barebones(ctx) {
- return filterKeywords, nil
- }
-
- // Populate the filter keywords. Remove any that we can't populate from the return slice.
- filterKeywords = slices.DeleteFunc(filterKeywords, func(filterKeyword *gtsmodel.FilterKeyword) bool {
- if err := f.populateFilterKeyword(ctx, filterKeyword); err != nil {
- log.Errorf(ctx, "error populating filter keyword: %v", err)
- return true
- }
- return false
- })
-
return filterKeywords, nil
}
@@ -178,11 +115,7 @@ func (f *filterDB) PutFilterKeyword(ctx context.Context, filterKeyword *gtsmodel
})
}
-func (f *filterDB) UpdateFilterKeyword(ctx context.Context, filterKeyword *gtsmodel.FilterKeyword, columns ...string) error {
- filterKeyword.UpdatedAt = time.Now()
- if len(columns) > 0 {
- columns = append(columns, "updated_at")
- }
+func (f *filterDB) UpdateFilterKeyword(ctx context.Context, filterKeyword *gtsmodel.FilterKeyword, cols ...string) error {
if filterKeyword.Regexp == nil {
// Ensure regexp is compiled
// before attempted caching.
@@ -196,22 +129,20 @@ func (f *filterDB) UpdateFilterKeyword(ctx context.Context, filterKeyword *gtsmo
NewUpdate().
Model(filterKeyword).
Where("? = ?", bun.Ident("id"), filterKeyword.ID).
- Column(columns...).
+ Column(cols...).
Exec(ctx)
return err
})
}
-func (f *filterDB) DeleteFilterKeywordByID(ctx context.Context, id string) error {
+func (f *filterDB) DeleteFilterKeywordsByIDs(ctx context.Context, ids ...string) error {
if _, err := f.db.
NewDelete().
Model((*gtsmodel.FilterKeyword)(nil)).
- Where("? = ?", bun.Ident("id"), id).
+ Where("? IN (?)", bun.Ident("id"), bun.In(ids)).
Exec(ctx); err != nil {
return err
}
-
- f.state.Caches.DB.FilterKeyword.Invalidate("ID", id)
-
+ f.state.Caches.DB.FilterKeyword.InvalidateIDs("ID", ids)
return nil
}
diff --git a/internal/db/bundb/filterkeyword_test.go b/internal/db/bundb/filterkeyword_test.go
index ab814d413..bce308f42 100644
--- a/internal/db/bundb/filterkeyword_test.go
+++ b/internal/db/bundb/filterkeyword_test.go
@@ -32,12 +32,11 @@ func (suite *FilterTestSuite) TestFilterKeywordCRUD() {
// Create new filter.
filter := &gtsmodel.Filter{
- ID: "01HNEJNVZZVXJTRB3FX3K2B1YF",
- AccountID: "01HNEJXCPRTJVJY9MV0VVHGD47",
- Title: "foss jail",
- Action: gtsmodel.FilterActionWarn,
- ContextHome: util.Ptr(true),
- ContextPublic: util.Ptr(true),
+ ID: "01HNEJNVZZVXJTRB3FX3K2B1YF",
+ AccountID: "01HNEJXCPRTJVJY9MV0VVHGD47",
+ Title: "foss jail",
+ Action: gtsmodel.FilterActionWarn,
+ Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextHome | gtsmodel.FilterContextPublic),
}
// Create new cancellable test context.
@@ -51,19 +50,11 @@ func (suite *FilterTestSuite) TestFilterKeywordCRUD() {
t.Fatalf("error inserting filter: %v", err)
}
- // There should be no filter keywords yet.
- all, err := suite.db.GetFilterKeywordsForAccountID(ctx, filter.AccountID)
- if err != nil {
- t.Fatalf("error fetching filter keywords: %v", err)
- }
- suite.Empty(all)
-
// Add a filter keyword to it.
filterKeyword := &gtsmodel.FilterKeyword{
- ID: "01HNEK4RW5QEAMG9Y4ET6ST0J4",
- AccountID: filter.AccountID,
- FilterID: filter.ID,
- Keyword: "GNU/Linux",
+ ID: "01HNEK4RW5QEAMG9Y4ET6ST0J4",
+ FilterID: filter.ID,
+ Keyword: "GNU/Linux",
}
// Insert the new filter keyword into the DB.
@@ -78,28 +69,17 @@ func (suite *FilterTestSuite) TestFilterKeywordCRUD() {
t.Fatalf("error fetching filter keyword: %v", err)
}
suite.Equal(filterKeyword.ID, check.ID)
- suite.NotZero(check.CreatedAt)
- suite.NotZero(check.UpdatedAt)
- suite.Equal(filterKeyword.AccountID, check.AccountID)
suite.Equal(filterKeyword.FilterID, check.FilterID)
suite.Equal(filterKeyword.Keyword, check.Keyword)
suite.Equal(filterKeyword.WholeWord, check.WholeWord)
- // Loading filter keywords by account ID should find the one we inserted.
- all, err = suite.db.GetFilterKeywordsForAccountID(ctx, filter.AccountID)
- if err != nil {
- t.Fatalf("error fetching filter keywords: %v", err)
- }
- suite.Len(all, 1)
- suite.Equal(filterKeyword.ID, all[0].ID)
-
- // Loading filter keywords by filter ID should also find the one we inserted.
- all, err = suite.db.GetFilterKeywordsForFilterID(ctx, filter.ID)
+ // Check that fetching multiple filter keywords by IDs works.
+ checks, err := suite.db.GetFilterKeywordsByIDs(ctx, []string{filterKeyword.ID})
if err != nil {
t.Fatalf("error fetching filter keywords: %v", err)
}
- suite.Len(all, 1)
- suite.Equal(filterKeyword.ID, all[0].ID)
+ suite.Len(checks, 1)
+ suite.Equal(filterKeyword.ID, checks[0].ID)
// Modify the filter keyword.
filterKeyword.WholeWord = util.Ptr(true)
@@ -114,15 +94,12 @@ func (suite *FilterTestSuite) TestFilterKeywordCRUD() {
t.Fatalf("error fetching filter keyword: %v", err)
}
suite.Equal(filterKeyword.ID, check.ID)
- suite.NotZero(check.CreatedAt)
- suite.True(check.UpdatedAt.After(check.CreatedAt))
- suite.Equal(filterKeyword.AccountID, check.AccountID)
suite.Equal(filterKeyword.FilterID, check.FilterID)
suite.Equal(filterKeyword.Keyword, check.Keyword)
suite.Equal(filterKeyword.WholeWord, check.WholeWord)
// Delete the filter keyword from the DB.
- err = suite.db.DeleteFilterKeywordByID(ctx, filter.ID)
+ err = suite.db.DeleteFilterKeywordsByIDs(ctx, filter.ID)
if err != nil {
t.Fatalf("error deleting filter keyword: %v", err)
}
diff --git a/internal/db/bundb/filterstatus.go b/internal/db/bundb/filterstatus.go
index a14e2a7b4..d15705a14 100644
--- a/internal/db/bundb/filterstatus.go
+++ b/internal/db/bundb/filterstatus.go
@@ -19,86 +19,38 @@ package bundb
import (
"context"
- "slices"
- "time"
- "code.superseriousbusiness.org/gotosocial/internal/gtscontext"
- "code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
func (f *filterDB) GetFilterStatusByID(ctx context.Context, id string) (*gtsmodel.FilterStatus, error) {
- filterStatus, err := f.state.Caches.DB.FilterStatus.LoadOne(
+ return f.state.Caches.DB.FilterStatus.LoadOne(
"ID",
func() (*gtsmodel.FilterStatus, error) {
var filterStatus gtsmodel.FilterStatus
- err := f.db.
+ if err := f.db.
NewSelect().
Model(&filterStatus).
Where("? = ?", bun.Ident("id"), id).
- Scan(ctx)
- return &filterStatus, err
+ Scan(ctx); err != nil {
+ return nil, err
+ }
+ return &filterStatus, nil
},
id,
)
- if err != nil {
- return nil, err
- }
-
- if !gtscontext.Barebones(ctx) {
- err = f.populateFilterStatus(ctx, filterStatus)
- if err != nil {
- return nil, err
- }
- }
-
- return filterStatus, nil
-}
-
-func (f *filterDB) populateFilterStatus(ctx context.Context, filterStatus *gtsmodel.FilterStatus) error {
- if filterStatus.Filter == nil {
- // Filter is not set, fetch from the cache or database.
- filter, err := f.state.DB.GetFilterByID(
- // Don't populate the filter with all of its keywords and statuses or we'll just end up back here.
- gtscontext.SetBarebones(ctx),
- filterStatus.FilterID,
- )
- if err != nil {
- return err
- }
- filterStatus.Filter = filter
- }
-
- return nil
}
-func (f *filterDB) GetFilterStatusesForFilterID(ctx context.Context, filterID string) ([]*gtsmodel.FilterStatus, error) {
- return f.getFilterStatuses(ctx, "filter_id", filterID)
-}
-
-func (f *filterDB) GetFilterStatusesForAccountID(ctx context.Context, accountID string) ([]*gtsmodel.FilterStatus, error) {
- return f.getFilterStatuses(ctx, "account_id", accountID)
-}
-
-func (f *filterDB) getFilterStatuses(ctx context.Context, idColumn string, id string) ([]*gtsmodel.FilterStatus, error) {
- var filterStatusIDs []string
- if err := f.db.
- NewSelect().
- Model((*gtsmodel.FilterStatus)(nil)).
- Column("id").
- Where("? = ?", bun.Ident(idColumn), id).
- Scan(ctx, &filterStatusIDs); err != nil {
- return nil, err
- }
- if len(filterStatusIDs) == 0 {
+func (f *filterDB) GetFilterStatusesByIDs(ctx context.Context, ids []string) ([]*gtsmodel.FilterStatus, error) {
+ if len(ids) == 0 {
return nil, nil
}
// Get each filter status by ID from the cache or DB.
filterStatuses, err := f.state.Caches.DB.FilterStatus.LoadIDs("ID",
- filterStatusIDs,
+ ids,
func(uncached []string) ([]*gtsmodel.FilterStatus, error) {
filterStatuses := make([]*gtsmodel.FilterStatus, 0, len(uncached))
if err := f.db.
@@ -116,29 +68,11 @@ func (f *filterDB) getFilterStatuses(ctx context.Context, idColumn string, id st
}
// Put the filter status structs in the same order as the filter status IDs.
- xslices.OrderBy(filterStatuses, filterStatusIDs, func(filterStatus *gtsmodel.FilterStatus) string {
+ xslices.OrderBy(filterStatuses, ids, func(filterStatus *gtsmodel.FilterStatus) string {
return filterStatus.ID
})
- if gtscontext.Barebones(ctx) {
- return filterStatuses, nil
- }
-
- // Populate the filter statuses. Remove any that we can't populate from the return slice.
- errs := gtserror.NewMultiError(len(filterStatuses))
- filterStatuses = slices.DeleteFunc(filterStatuses, func(filterStatus *gtsmodel.FilterStatus) bool {
- if err := f.populateFilterStatus(ctx, filterStatus); err != nil {
- errs.Appendf(
- "error populating filter status %s: %w",
- filterStatus.ID,
- err,
- )
- return true
- }
- return false
- })
-
- return filterStatuses, errs.Combine()
+ return filterStatuses, nil
}
func (f *filterDB) PutFilterStatus(ctx context.Context, filterStatus *gtsmodel.FilterStatus) error {
@@ -152,11 +86,6 @@ func (f *filterDB) PutFilterStatus(ctx context.Context, filterStatus *gtsmodel.F
}
func (f *filterDB) UpdateFilterStatus(ctx context.Context, filterStatus *gtsmodel.FilterStatus, columns ...string) error {
- filterStatus.UpdatedAt = time.Now()
- if len(columns) > 0 {
- columns = append(columns, "updated_at")
- }
-
return f.state.Caches.DB.FilterStatus.Store(filterStatus, func() error {
_, err := f.db.
NewUpdate().
@@ -168,16 +97,14 @@ func (f *filterDB) UpdateFilterStatus(ctx context.Context, filterStatus *gtsmode
})
}
-func (f *filterDB) DeleteFilterStatusByID(ctx context.Context, id string) error {
+func (f *filterDB) DeleteFilterStatusesByIDs(ctx context.Context, ids ...string) error {
if _, err := f.db.
NewDelete().
Model((*gtsmodel.FilterStatus)(nil)).
- Where("? = ?", bun.Ident("id"), id).
+ Where("? IN (?)", bun.Ident("id"), bun.In(ids)).
Exec(ctx); err != nil {
return err
}
-
- f.state.Caches.DB.FilterStatus.Invalidate("ID", id)
-
+ f.state.Caches.DB.FilterStatus.InvalidateIDs("ID", ids)
return nil
}
diff --git a/internal/db/bundb/filterstatus_test.go b/internal/db/bundb/filterstatus_test.go
index 9485ee3f7..27f5c17b3 100644
--- a/internal/db/bundb/filterstatus_test.go
+++ b/internal/db/bundb/filterstatus_test.go
@@ -23,7 +23,6 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/db"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
- "code.superseriousbusiness.org/gotosocial/internal/util"
)
// TestFilterStatusCRD tests CRD (no U) and read-all operations on filter statuses.
@@ -32,12 +31,11 @@ func (suite *FilterTestSuite) TestFilterStatusCRD() {
// Create new filter.
filter := &gtsmodel.Filter{
- ID: "01HNEJNVZZVXJTRB3FX3K2B1YF",
- AccountID: "01HNEJXCPRTJVJY9MV0VVHGD47",
- Title: "foss jail",
- Action: gtsmodel.FilterActionWarn,
- ContextHome: util.Ptr(true),
- ContextPublic: util.Ptr(true),
+ ID: "01HNEJNVZZVXJTRB3FX3K2B1YF",
+ AccountID: "01HNEJXCPRTJVJY9MV0VVHGD47",
+ Title: "foss jail",
+ Action: gtsmodel.FilterActionWarn,
+ Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextHome | gtsmodel.FilterContextPublic),
}
// Create new cancellable test context.
@@ -51,19 +49,11 @@ func (suite *FilterTestSuite) TestFilterStatusCRD() {
t.Fatalf("error inserting filter: %v", err)
}
- // There should be no filter statuses yet.
- all, err := suite.db.GetFilterStatusesForAccountID(ctx, filter.AccountID)
- if err != nil {
- t.Fatalf("error fetching filter statuses: %v", err)
- }
- suite.Empty(all)
-
// Add a filter status to it.
filterStatus := &gtsmodel.FilterStatus{
- ID: "01HNEK4RW5QEAMG9Y4ET6ST0J4",
- AccountID: filter.AccountID,
- FilterID: filter.ID,
- StatusID: "01HQXGMQ3QFXRT4GX9WNQ8KC0X",
+ ID: "01HNEK4RW5QEAMG9Y4ET6ST0J4",
+ FilterID: filter.ID,
+ StatusID: "01HQXGMQ3QFXRT4GX9WNQ8KC0X",
}
// Insert the new filter status into the DB.
@@ -78,30 +68,19 @@ func (suite *FilterTestSuite) TestFilterStatusCRD() {
t.Fatalf("error fetching filter status: %v", err)
}
suite.Equal(filterStatus.ID, check.ID)
- suite.NotZero(check.CreatedAt)
- suite.NotZero(check.UpdatedAt)
- suite.Equal(filterStatus.AccountID, check.AccountID)
suite.Equal(filterStatus.FilterID, check.FilterID)
suite.Equal(filterStatus.StatusID, check.StatusID)
- // Loading filter statuses by account ID should find the one we inserted.
- all, err = suite.db.GetFilterStatusesForAccountID(ctx, filter.AccountID)
- if err != nil {
- t.Fatalf("error fetching filter statuses: %v", err)
- }
- suite.Len(all, 1)
- suite.Equal(filterStatus.ID, all[0].ID)
-
- // Loading filter statuses by filter ID should also find the one we inserted.
- all, err = suite.db.GetFilterStatusesForFilterID(ctx, filter.ID)
+ // Check that fetching multiple filter statuses by IDs works.
+ checks, err := suite.db.GetFilterStatusesByIDs(ctx, []string{filterStatus.ID})
if err != nil {
t.Fatalf("error fetching filter statuses: %v", err)
}
- suite.Len(all, 1)
- suite.Equal(filterStatus.ID, all[0].ID)
+ suite.Len(checks, 1)
+ suite.Equal(filterStatus.ID, checks[0].ID)
// Delete the filter status from the DB.
- err = suite.db.DeleteFilterStatusByID(ctx, filter.ID)
+ err = suite.db.DeleteFilterStatusesByIDs(ctx, filter.ID)
if err != nil {
t.Fatalf("error deleting filter status: %v", err)
}
diff --git a/internal/db/bundb/migrations/20241018151036_filter_unique_fix.go b/internal/db/bundb/migrations/20241018151036_filter_unique_fix.go
index a1eb700a7..b7b185f99 100644
--- a/internal/db/bundb/migrations/20241018151036_filter_unique_fix.go
+++ b/internal/db/bundb/migrations/20241018151036_filter_unique_fix.go
@@ -20,7 +20,7 @@ package migrations
import (
"context"
- "code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
+ gtsmodel "code.superseriousbusiness.org/gotosocial/internal/db/bundb/migrations/20241018151036_filter_unique_fix"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
)
diff --git a/internal/db/bundb/migrations/20241018151036_filter_unique_fix/filter.go b/internal/db/bundb/migrations/20241018151036_filter_unique_fix/filter.go
new file mode 100644
index 000000000..e90eabaac
--- /dev/null
+++ b/internal/db/bundb/migrations/20241018151036_filter_unique_fix/filter.go
@@ -0,0 +1,77 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package gtsmodel
+
+import (
+ "regexp"
+ "time"
+)
+
+// Filter stores a filter created by a local account.
+type Filter struct {
+ ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
+ CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
+ UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
+ ExpiresAt time.Time `bun:"type:timestamptz,nullzero"` // Time filter should expire. If null, should not expire.
+ AccountID string `bun:"type:CHAR(26),notnull,nullzero,unique:filters_account_id_title_uniq"` // ID of the local account that created the filter.
+ Title string `bun:",nullzero,notnull,unique:filters_account_id_title_uniq"` // The name of the filter.
+ Action FilterAction `bun:",nullzero,notnull"` // The action to take.
+ Keywords []*FilterKeyword `bun:"-"` // Keywords for this filter.
+ Statuses []*FilterStatus `bun:"-"` // Statuses for this filter.
+ ContextHome *bool `bun:",nullzero,notnull,default:false"` // Apply filter to home timeline and lists.
+ ContextNotifications *bool `bun:",nullzero,notnull,default:false"` // Apply filter to notifications.
+ ContextPublic *bool `bun:",nullzero,notnull,default:false"` // Apply filter to home timeline and lists.
+ ContextThread *bool `bun:",nullzero,notnull,default:false"` // Apply filter when viewing a status's associated thread.
+ ContextAccount *bool `bun:",nullzero,notnull,default:false"` // Apply filter when viewing an account profile.
+}
+
+// FilterKeyword stores a single keyword to filter statuses against.
+type FilterKeyword struct {
+ ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
+ CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
+ UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
+ AccountID string `bun:"type:CHAR(26),notnull,nullzero"` // ID of the local account that created the filter keyword.
+ FilterID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_keywords_filter_id_keyword_uniq"` // ID of the filter that this keyword belongs to.
+ Filter *Filter `bun:"-"` // Filter corresponding to FilterID
+ Keyword string `bun:",nullzero,notnull,unique:filter_keywords_filter_id_keyword_uniq"` // The keyword or phrase to filter against.
+ WholeWord *bool `bun:",nullzero,notnull,default:false"` // Should the filter consider word boundaries?
+ Regexp *regexp.Regexp `bun:"-"` // pre-prepared regular expression
+}
+
+// FilterStatus stores a single status to filter.
+type FilterStatus struct {
+ ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
+ CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
+ UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
+ AccountID string `bun:"type:CHAR(26),notnull,nullzero"` // ID of the local account that created the filter keyword.
+ FilterID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_statuses_filter_id_status_id_uniq"` // ID of the filter that this keyword belongs to.
+ Filter *Filter `bun:"-"` // Filter corresponding to FilterID
+ StatusID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_statuses_filter_id_status_id_uniq"` // ID of the status to filter.
+}
+
+// FilterAction represents the action to take on a filtered status.
+type FilterAction string
+
+const (
+ // FilterActionNone filters should not exist, except internally, for partially constructed or invalid filters.
+ FilterActionNone FilterAction = ""
+ // FilterActionWarn means that the status should be shown behind a warning.
+ FilterActionWarn FilterAction = "warn"
+ // FilterActionHide means that the status should be removed from timeline results entirely.
+ FilterActionHide FilterAction = "hide"
+)
diff --git a/internal/db/bundb/migrations/20250617122055_filter_improvements.go b/internal/db/bundb/migrations/20250617122055_filter_improvements.go
new file mode 100644
index 000000000..09fde089e
--- /dev/null
+++ b/internal/db/bundb/migrations/20250617122055_filter_improvements.go
@@ -0,0 +1,303 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package migrations
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "reflect"
+ "strings"
+
+ oldmodel "code.superseriousbusiness.org/gotosocial/internal/db/bundb/migrations/20241018151036_filter_unique_fix"
+ newmodel "code.superseriousbusiness.org/gotosocial/internal/db/bundb/migrations/20250617122055_filter_improvements"
+ "code.superseriousbusiness.org/gotosocial/internal/gtserror"
+ "github.com/uptrace/bun"
+)
+
+func init() {
+ up := func(ctx context.Context, db *bun.DB) error {
+ // Replace 'context_*' and 'action' columns with space-saving enum / bitfields.
+ if err := db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ newFilterType := reflect.TypeOf((*newmodel.Filter)(nil))
+
+ // Generate bun definition for new filter table contexts column.
+ newColDef, err := getBunColumnDef(tx, newFilterType, "Contexts")
+ if err != nil {
+ return gtserror.Newf("error getting bun column def: %w", err)
+ }
+
+ // Add new column type to table.
+ if _, err := tx.NewAddColumn().
+ Model((*oldmodel.Filter)(nil)).
+ ColumnExpr(newColDef).
+ Exec(ctx); err != nil {
+ return gtserror.Newf("error adding filter.contexts column: %w", err)
+ }
+
+ // Generate bun definition for new filter table action column.
+ newColDef, err = getBunColumnDef(tx, newFilterType, "Action")
+ if err != nil {
+ return gtserror.Newf("error getting bun column def: %w", err)
+ }
+
+ // For now, name it as '_new'.
+ newColDef = strings.ReplaceAll(
+ newColDef,
+ "action",
+ "action_new",
+ )
+
+ // Add new column type to table.
+ if _, err := tx.NewAddColumn().
+ Model((*oldmodel.Filter)(nil)).
+ ColumnExpr(newColDef).
+ Exec(ctx); err != nil {
+ return gtserror.Newf("error adding filter.contexts column: %w", err)
+ }
+
+ var oldFilters []*oldmodel.Filter
+
+ // Select all filters.
+ if err := tx.NewSelect().
+ Model(&oldFilters).
+ Column("id",
+ "context_home",
+ "context_notifications",
+ "context_public",
+ "context_thread",
+ "context_account",
+ "action").
+ Scan(ctx); err != nil {
+ return gtserror.Newf("error selecting filters: %w", err)
+ }
+
+ for _, oldFilter := range oldFilters {
+ var newContexts newmodel.FilterContexts
+ var newAction newmodel.FilterAction
+
+ // Convert old contexts
+ // to new contexts type.
+ if *oldFilter.ContextHome {
+ newContexts.SetHome()
+ }
+ if *oldFilter.ContextNotifications {
+ newContexts.SetNotifications()
+ }
+ if *oldFilter.ContextPublic {
+ newContexts.SetPublic()
+ }
+ if *oldFilter.ContextThread {
+ newContexts.SetThread()
+ }
+ if *oldFilter.ContextAccount {
+ newContexts.SetAccount()
+ }
+
+ // Convert old action
+ // to new action type.
+ switch oldFilter.Action {
+ case oldmodel.FilterActionHide:
+ newAction = newmodel.FilterActionHide
+ case oldmodel.FilterActionWarn:
+ newAction = newmodel.FilterActionWarn
+ default:
+ return gtserror.Newf("invalid filter action %q for %s", oldFilter.Action, oldFilter.ID)
+ }
+
+ // Update filter row with
+ // the new contexts value.
+ if _, err := tx.NewUpdate().
+ Model((*oldmodel.Filter)(nil)).
+ Where("? = ?", bun.Ident("id"), oldFilter.ID).
+ Set("? = ?", bun.Ident("contexts"), newContexts).
+ Set("? = ?", bun.Ident("action_new"), newAction).
+ Exec(ctx); err != nil {
+ return gtserror.Newf("error updating filter.contexts: %w", err)
+ }
+ }
+
+ // Drop the old updated columns.
+ for _, col := range []string{
+ "context_home",
+ "context_notifications",
+ "context_public",
+ "context_thread",
+ "context_account",
+ "action",
+ } {
+ if _, err := tx.NewDropColumn().
+ Model((*oldmodel.Filter)(nil)).
+ Column(col).
+ Exec(ctx); err != nil {
+ return gtserror.Newf("error dropping filter.%s column: %w", col, err)
+ }
+ }
+
+ // Rename the new action
+ // column to correct name.
+ if _, err := tx.NewRaw(
+ "ALTER TABLE ? RENAME COLUMN ? TO ?",
+ bun.Ident("filters"),
+ bun.Ident("action_new"),
+ bun.Ident("action"),
+ ).Exec(ctx); err != nil {
+ return gtserror.Newf("error renaming new action column: %w", err)
+ }
+
+ return nil
+ }); err != nil {
+ return err
+ }
+
+ // SQLITE: force WAL checkpoint to merge writes.
+ if err := doWALCheckpoint(ctx, db); err != nil {
+ return err
+ }
+
+ // Drop a bunch of (now, and more generally) unused columns from filter tables.
+ if err := db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ for model, indices := range map[any][]string{
+ (*oldmodel.FilterKeyword)(nil): {"filter_keywords_account_id_idx"},
+ (*oldmodel.FilterStatus)(nil): {"filter_statuses_account_id_idx"},
+ } {
+ for _, index := range indices {
+ if _, err := tx.NewDropIndex().
+ Model(model).
+ Index(index).
+ Exec(ctx); err != nil {
+ return gtserror.Newf("error dropping %s index: %w", index, err)
+ }
+ }
+ }
+ for model, cols := range map[any][]string{
+ (*oldmodel.Filter)(nil): {"created_at", "updated_at"},
+ (*oldmodel.FilterKeyword)(nil): {"created_at", "updated_at", "account_id"},
+ (*oldmodel.FilterStatus)(nil): {"created_at", "updated_at", "account_id"},
+ } {
+ for _, col := range cols {
+ if _, err := tx.NewDropColumn().
+ Model(model).
+ Column(col).
+ Exec(ctx); err != nil {
+ return gtserror.Newf("error dropping %T.%s column: %w", model, col, err)
+ }
+ }
+ }
+ return nil
+ }); err != nil {
+ return err
+ }
+
+ // SQLITE: force WAL checkpoint to merge writes.
+ if err := doWALCheckpoint(ctx, db); err != nil {
+ return err
+ }
+
+ // Create links from 'filters' table to 'filter_{keywords,statuses}' tables.
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ newFilterType := reflect.TypeOf((*newmodel.Filter)(nil))
+
+ var filterIDs string
+
+ // Select all filter IDs.
+ if err := tx.NewSelect().
+ Model((*newmodel.Filter)(nil)).
+ Column("id").
+ Scan(ctx, &filterIDs); err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return gtserror.Newf("error selecting filter ids: %w", err)
+ }
+
+ for _, data := range []struct {
+ Field string
+ Model any
+ }{
+ {
+ Field: "KeywordIDs",
+ Model: (*newmodel.FilterKeyword)(nil),
+ },
+ {
+ Field: "StatusIDs",
+ Model: (*newmodel.FilterStatus)(nil),
+ },
+ } {
+ // Generate bun definition for new filter table field column.
+ newColDef, err := getBunColumnDef(tx, newFilterType, data.Field)
+ if err != nil {
+ return gtserror.Newf("error getting bun column def: %w", err)
+ }
+
+ // Add new column type to table.
+ if _, err := tx.NewAddColumn().
+ Model((*oldmodel.Filter)(nil)).
+ ColumnExpr(newColDef).
+ Exec(ctx); err != nil {
+ return gtserror.Newf("error adding filter.%s column: %w", data.Field, err)
+ }
+
+ // Get the SQL field information from bun for Filter{}.$Field.
+ field, _, err := getModelField(tx, newFilterType, data.Field)
+ if err != nil {
+ return gtserror.Newf("error getting bun model field: %w", err)
+ }
+
+ // Extract column name.
+ col := field.SQLName
+
+ var relatedIDs []string
+ for _, filterID := range filterIDs {
+ // Reset related IDs.
+ clear(relatedIDs)
+ relatedIDs = relatedIDs[:0]
+
+ // Select $Model IDs that
+ // are attached to filterID.
+ if err := tx.NewSelect().
+ Model(data.Model).
+ Column("id").
+ Where("? = ?", bun.Ident("filter_id"), filterID).
+ Scan(ctx, &relatedIDs); err != nil {
+ return gtserror.Newf("error selecting %T ids: %w", data.Model, err)
+ }
+
+ // Now update the relevant filter
+ // row to contain these related IDs.
+ if _, err := tx.NewUpdate().
+ Model((*newmodel.Filter)(nil)).
+ Where("? = ?", bun.Ident("id"), filterID).
+ Set("? = ?", bun.Ident(col), relatedIDs).
+ Exec(ctx); err != nil {
+ return gtserror.Newf("error updating filters.%s ids: %w", col, err)
+ }
+ }
+ }
+
+ return nil
+ })
+ }
+
+ down := func(ctx context.Context, db *bun.DB) error {
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ return nil
+ })
+ }
+
+ if err := Migrations.Register(up, down); err != nil {
+ panic(err)
+ }
+}
diff --git a/internal/db/bundb/migrations/20250617122055_filter_improvements/filter.go b/internal/db/bundb/migrations/20250617122055_filter_improvements/filter.go
new file mode 100644
index 000000000..20d3ba32e
--- /dev/null
+++ b/internal/db/bundb/migrations/20250617122055_filter_improvements/filter.go
@@ -0,0 +1,243 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package gtsmodel
+
+import (
+ "regexp"
+ "time"
+
+ "code.superseriousbusiness.org/gotosocial/internal/util"
+)
+
+// smallint is the largest size supported
+// by a PostgreSQL SMALLINT, since an SQLite
+// SMALLINT is actually variable in size.
+type smallint int16
+
+// enumType is the type we (at least, should) use
+// for database enum types, as smallest int size.
+type enumType smallint
+
+// bitFieldType is the type we use
+// for database int bit fields, at
+// least where the smallest int size
+// will suffice for number of fields.
+type bitFieldType smallint
+
+// FilterContext represents the
+// context in which a Filter applies.
+//
+// These are used as bit-field masks to determine
+// which are enabled in a FilterContexts bit field,
+// as well as to signify internally any particular
+// context in which a status should be filtered in.
+type FilterContext bitFieldType
+
+const (
+ // FilterContextNone means no filters should
+ // be applied, this is for internal use only.
+ FilterContextNone FilterContext = 0
+
+ // FilterContextHome means this status is being
+ // filtered as part of a home or list timeline.
+ FilterContextHome FilterContext = 1 << 1
+
+ // FilterContextNotifications means this status is
+ // being filtered as part of the notifications timeline.
+ FilterContextNotifications FilterContext = 1 << 2
+
+ // FilterContextPublic means this status is
+ // being filtered as part of a public or tag timeline.
+ FilterContextPublic FilterContext = 1 << 3
+
+ // FilterContextThread means this status is
+ // being filtered as part of a thread's context.
+ FilterContextThread FilterContext = 1 << 4
+
+ // FilterContextAccount means this status is
+ // being filtered as part of an account's statuses.
+ FilterContextAccount FilterContext = 1 << 5
+)
+
+// FilterContexts stores multiple contexts
+// in which a Filter applies as bits in an int.
+type FilterContexts bitFieldType
+
+// Applies returns whether receiving FilterContexts applies in FilterContexts.
+func (ctxs FilterContexts) Applies(ctx FilterContext) bool {
+ switch ctx {
+ case FilterContextHome:
+ return ctxs.Home()
+ case FilterContextNotifications:
+ return ctxs.Notifications()
+ case FilterContextPublic:
+ return ctxs.Public()
+ case FilterContextThread:
+ return ctxs.Thread()
+ case FilterContextAccount:
+ return ctxs.Account()
+ default:
+ return false
+ }
+}
+
+// Home returns whether FilterContextHome is set.
+func (ctxs FilterContexts) Home() bool {
+ return ctxs&FilterContexts(FilterContextHome) != 0
+}
+
+// SetHome will set the FilterContextHome bit.
+func (ctxs *FilterContexts) SetHome() {
+ *ctxs |= FilterContexts(FilterContextHome)
+}
+
+// UnsetHome will unset the FilterContextHome bit.
+func (ctxs *FilterContexts) UnsetHome() {
+ *ctxs &= ^FilterContexts(FilterContextHome)
+}
+
+// Notifications returns whether FilterContextNotifications is set.
+func (ctxs FilterContexts) Notifications() bool {
+ return ctxs&FilterContexts(FilterContextNotifications) != 0
+}
+
+// SetNotifications will set the FilterContextNotifications bit.
+func (ctxs *FilterContexts) SetNotifications() {
+ *ctxs |= FilterContexts(FilterContextNotifications)
+}
+
+// UnsetNotifications will unset the FilterContextNotifications bit.
+func (ctxs *FilterContexts) UnsetNotifications() {
+ *ctxs &= ^FilterContexts(FilterContextNotifications)
+}
+
+// Public returns whether FilterContextPublic is set.
+func (ctxs FilterContexts) Public() bool {
+ return ctxs&FilterContexts(FilterContextPublic) != 0
+}
+
+// SetPublic will set the FilterContextPublic bit.
+func (ctxs *FilterContexts) SetPublic() {
+ *ctxs |= FilterContexts(FilterContextPublic)
+}
+
+// UnsetPublic will unset the FilterContextPublic bit.
+func (ctxs *FilterContexts) UnsetPublic() {
+ *ctxs &= ^FilterContexts(FilterContextPublic)
+}
+
+// Thread returns whether FilterContextThread is set.
+func (ctxs FilterContexts) Thread() bool {
+ return ctxs&FilterContexts(FilterContextThread) != 0
+}
+
+// SetThread will set the FilterContextThread bit.
+func (ctxs *FilterContexts) SetThread() {
+ *ctxs |= FilterContexts(FilterContextThread)
+}
+
+// UnsetThread will unset the FilterContextThread bit.
+func (ctxs *FilterContexts) UnsetThread() {
+ *ctxs &= ^FilterContexts(FilterContextThread)
+}
+
+// Account returns whether FilterContextAccount is set.
+func (ctxs FilterContexts) Account() bool {
+ return ctxs&FilterContexts(FilterContextAccount) != 0
+}
+
+// SetAccount will set / unset the FilterContextAccount bit.
+func (ctxs *FilterContexts) SetAccount() {
+ *ctxs |= FilterContexts(FilterContextAccount)
+}
+
+// UnsetAccount will unset the FilterContextAccount bit.
+func (ctxs *FilterContexts) UnsetAccount() {
+ *ctxs &= ^FilterContexts(FilterContextAccount)
+}
+
+// FilterAction represents the action
+// to take on a filtered status.
+type FilterAction enumType
+
+const (
+ // FilterActionNone filters should not exist, except
+ // internally, for partially constructed or invalid filters.
+ FilterActionNone FilterAction = 0
+
+ // FilterActionWarn means that the
+ // status should be shown behind a warning.
+ FilterActionWarn FilterAction = 1
+
+ // FilterActionHide means that the status should
+ // be removed from timeline results entirely.
+ FilterActionHide FilterAction = 2
+)
+
+// Filter stores a filter created by a local account.
+type Filter struct {
+ ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
+ ExpiresAt time.Time `bun:"type:timestamptz,nullzero"` // Time filter should expire. If null, should not expire.
+ AccountID string `bun:"type:CHAR(26),notnull,nullzero,unique:filters_account_id_title_uniq"` // ID of the local account that created the filter.
+ Title string `bun:",nullzero,notnull,unique:filters_account_id_title_uniq"` // The name of the filter.
+ Action FilterAction `bun:",nullzero,notnull,default:0"` // The action to take.
+ Keywords []*FilterKeyword `bun:"-"` // Keywords for this filter.
+ KeywordIDs []string `bun:"keywords,array"` //
+ Statuses []*FilterStatus `bun:"-"` // Statuses for this filter.
+ StatusIDs []string `bun:"statuses,array"` //
+ Contexts FilterContexts `bun:",nullzero,notnull,default:0"` // Which contexts does this filter apply in?
+}
+
+// FilterKeyword stores a single keyword to filter statuses against.
+type FilterKeyword struct {
+ ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
+ FilterID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_keywords_filter_id_keyword_uniq"` // ID of the filter that this keyword belongs to.
+ Keyword string `bun:",nullzero,notnull,unique:filter_keywords_filter_id_keyword_uniq"` // The keyword or phrase to filter against.
+ WholeWord *bool `bun:",nullzero,notnull,default:false"` // Should the filter consider word boundaries?
+ Regexp *regexp.Regexp `bun:"-"` // pre-prepared regular expression
+}
+
+// Compile will compile this FilterKeyword as a prepared regular expression.
+func (k *FilterKeyword) Compile() (err error) {
+ var (
+ wordBreakStart string
+ wordBreakEnd string
+ )
+
+ if util.PtrOrZero(k.WholeWord) {
+ // Either word boundary or
+ // whitespace or start of line.
+ wordBreakStart = `(?:\b|\s|^)`
+
+ // Either word boundary or
+ // whitespace or end of line.
+ wordBreakEnd = `(?:\b|\s|$)`
+ }
+
+ // Compile keyword filter regexp.
+ quoted := regexp.QuoteMeta(k.Keyword)
+ k.Regexp, err = regexp.Compile(`(?i)` + wordBreakStart + quoted + wordBreakEnd)
+ return // caller is expected to wrap this error
+}
+
+// FilterStatus stores a single status to filter.
+type FilterStatus struct {
+ ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
+ FilterID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_statuses_filter_id_status_id_uniq"` // ID of the filter that this keyword belongs to.
+ StatusID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_statuses_filter_id_status_id_uniq"` // ID of the status to filter.
+}