[feature] add support for receiving federated status edits (#3597)

* add support for extracting Updated field from Statusable implementers

* add support for status edits in the database, and update status dereferencer to handle them

* remove unused AdditionalInfo{}.CreatedAt

* remove unused AdditionalEmojiInfo{}.CreatedAt

* update new mention creation to use status.UpdatedAt

* remove mention.UpdatedAt, fixes related to NewULIDFromTime() change

* add migration to remove Mention{}.UpdatedAt field

* add migration to add the StatusEdit{} table

* start adding tests, add delete function for status edits

* add more of status edit migrations, fill in more of the necessary edit delete functionality

* remove unused function

* allow generating gotosocial compatible ulid via CLI with `go run ./cmd/gen-ulid`

* add StatusEdit{} test models

* fix new statusedits sql

* use model instead of table name

* actually remove the Mention.UpdatedAt field...

* fix tests now new models are added, add more status edit DB tests

* fix panic wording

* add test for deleting status edits

* don't automatically set `updated_at` field on updated statuses

* flesh out more of the dereferencer status edit tests, ensure updated at field set on outgoing AS statuses

* remove media_attachments.updated_at column

* fix up more tests, further complete the dereferencer status edit tests

* update more status serialization tests not expecting 'updated' AS property

* gah!! json serialization tests!!

* undo some gtscontext wrapping changes

* more serialization test fixing 🥲

* more test fixing, ensure the edit.status_id field is actually set 🤦

* fix status edit test

* grrr linter

* add edited_at field to apimodel status

* remove the choice of paging on the timeline public filtered test (otherwise it needs updating every time you add statuses ...)

* ensure that status.updated_at always fits chronologically

* fix more serialization tests ...

* add more code comments

* fix envparsing

* update swagger file

* properly handle media description changes during status edits

* slight formatting tweak

* code comment
This commit is contained in:
kim 2024-12-05 13:35:07 +00:00 committed by GitHub
commit 23fc70f4e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
86 changed files with 2557 additions and 651 deletions

View file

@ -46,7 +46,7 @@ type AccountTestSuite struct {
func (suite *AccountTestSuite) TestGetAccountStatuses() {
statuses, err := suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 20, false, false, "", "", false, false)
suite.NoError(err)
suite.Len(statuses, 8)
suite.Len(statuses, 9)
}
func (suite *AccountTestSuite) TestGetAccountStatusesPageDown() {
@ -69,7 +69,7 @@ func (suite *AccountTestSuite) TestGetAccountStatusesPageDown() {
if err != nil {
suite.FailNow(err.Error())
}
suite.Len(statuses, 2)
suite.Len(statuses, 3)
// try to get the last page (should be empty)
statuses, err = suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 3, false, false, statuses[len(statuses)-1].ID, "", false, false)
@ -80,13 +80,13 @@ func (suite *AccountTestSuite) TestGetAccountStatusesPageDown() {
func (suite *AccountTestSuite) TestGetAccountStatusesExcludeRepliesAndReblogs() {
statuses, err := suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 20, true, true, "", "", false, false)
suite.NoError(err)
suite.Len(statuses, 7)
suite.Len(statuses, 8)
}
func (suite *AccountTestSuite) TestGetAccountStatusesExcludeRepliesAndReblogsPublicOnly() {
statuses, err := suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 20, true, true, "", "", false, true)
suite.NoError(err)
suite.Len(statuses, 3)
suite.Len(statuses, 4)
}
// populateTestStatus adds mandatory fields to a partially populated status.
@ -173,7 +173,7 @@ func (suite *AccountTestSuite) TestGetAccountStatusesExcludeRepliesExcludesSelfR
testAccount := suite.testAccounts["local_account_1"]
statuses, err := suite.db.GetAccountStatuses(context.Background(), testAccount.ID, 20, true, true, "", "", false, false)
suite.NoError(err)
suite.Len(statuses, 8)
suite.Len(statuses, 9)
for _, status := range statuses {
if status.InReplyToID != "" && status.InReplyToAccountID != testAccount.ID {
suite.FailNowf("", "Status with ID %s is a non-self reply and should have been excluded", status.ID)

View file

@ -114,7 +114,7 @@ func (suite *BasicTestSuite) TestGetAllStatuses() {
s := []*gtsmodel.Status{}
err := suite.db.GetAll(context.Background(), &s)
suite.NoError(err)
suite.Len(s, 25)
suite.Len(s, 28)
}
func (suite *BasicTestSuite) TestGetAllNotNull() {

View file

@ -81,6 +81,7 @@ type DBService struct {
db.SinBinStatus
db.Status
db.StatusBookmark
db.StatusEdit
db.StatusFave
db.Tag
db.Thread
@ -272,6 +273,10 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
db: db,
state: state,
},
StatusEdit: &statusEditDB{
db: db,
state: state,
},
StatusFave: &statusFaveDB{
db: db,
state: state,

View file

@ -57,6 +57,7 @@ type BunDBStandardTestSuite struct {
testPolls map[string]*gtsmodel.Poll
testPollVotes map[string]*gtsmodel.PollVote
testInteractionRequests map[string]*gtsmodel.InteractionRequest
testStatusEdits map[string]*gtsmodel.StatusEdit
}
func (suite *BunDBStandardTestSuite) SetupSuite() {
@ -83,6 +84,7 @@ func (suite *BunDBStandardTestSuite) SetupSuite() {
suite.testPolls = testrig.NewTestPolls()
suite.testPollVotes = testrig.NewTestPollVotes()
suite.testInteractionRequests = testrig.NewTestInteractionRequests()
suite.testStatusEdits = testrig.NewTestStatusEdits()
}
func (suite *BunDBStandardTestSuite) SetupTest() {

View file

@ -47,13 +47,13 @@ func (suite *InstanceTestSuite) TestCountInstanceUsersRemote() {
func (suite *InstanceTestSuite) TestCountInstanceStatuses() {
count, err := suite.db.CountInstanceStatuses(context.Background(), config.GetHost())
suite.NoError(err)
suite.Equal(19, count)
suite.Equal(21, count)
}
func (suite *InstanceTestSuite) TestCountInstanceStatusesRemote() {
count, err := suite.db.CountInstanceStatuses(context.Background(), "fossbros-anonymous.io")
suite.NoError(err)
suite.Equal(3, count)
suite.Equal(4, count)
}
func (suite *InstanceTestSuite) TestCountInstanceDomains() {

View file

@ -59,11 +59,7 @@ func (suite *InteractionTestSuite) markInteractionsPending(
// Put an interaction request
// in the DB for this reply.
req, err := typeutils.StatusToInteractionRequest(ctx, reply)
if err != nil {
suite.FailNow(err.Error())
}
req := typeutils.StatusToInteractionRequest(reply)
if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil {
suite.FailNow(err.Error())
}
@ -90,11 +86,7 @@ func (suite *InteractionTestSuite) markInteractionsPending(
// Put an interaction request
// in the DB for this boost.
req, err := typeutils.StatusToInteractionRequest(ctx, boost)
if err != nil {
suite.FailNow(err.Error())
}
req := typeutils.StatusToInteractionRequest(boost)
if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil {
suite.FailNow(err.Error())
}
@ -121,11 +113,7 @@ func (suite *InteractionTestSuite) markInteractionsPending(
// Put an interaction request
// in the DB for this fave.
req, err := typeutils.StatusFaveToInteractionRequest(ctx, fave)
if err != nil {
suite.FailNow(err.Error())
}
req := typeutils.StatusFaveToInteractionRequest(fave)
if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil {
suite.FailNow(err.Error())
}

View file

@ -104,12 +104,6 @@ func (m *mediaDB) PutAttachment(ctx context.Context, media *gtsmodel.MediaAttach
}
func (m *mediaDB) UpdateAttachment(ctx context.Context, media *gtsmodel.MediaAttachment, columns ...string) error {
media.UpdatedAt = time.Now()
if len(columns) > 0 {
// If we're updating by column, ensure "updated_at" is included.
columns = append(columns, "updated_at")
}
return m.state.Caches.DB.Media.Store(media, func() error {
_, err := m.db.NewUpdate().
Model(media).

View file

@ -93,11 +93,7 @@ func init() {
// For each currently pending status, check whether it's a reply or
// a boost, and insert a corresponding interaction request into the db.
for _, pendingStatus := range pendingStatuses {
req, err := typeutils.StatusToInteractionRequest(ctx, pendingStatus)
if err != nil {
return err
}
req := typeutils.StatusToInteractionRequest(pendingStatus)
if _, err := tx.
NewInsert().
Model(req).
@ -125,10 +121,7 @@ func init() {
}
for _, pendingFave := range pendingFaves {
req, err := typeutils.StatusFaveToInteractionRequest(ctx, pendingFave)
if err != nil {
return err
}
req := typeutils.StatusFaveToInteractionRequest(pendingFave)
if _, err := tx.
NewInsert().

View file

@ -0,0 +1,57 @@
// 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"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/uptrace/bun"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
// Check for 'updated_at' column on mentions table, else return.
exists, err := doesColumnExist(ctx, tx, "mentions", "updated_at")
if err != nil {
return err
} else if !exists {
return nil
}
// Remove 'updated_at' column.
_, err = tx.NewDropColumn().
Model((*gtsmodel.Mention)(nil)).
Column("updated_at").
Exec(ctx)
return err
})
}
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)
}
}

View file

@ -0,0 +1,67 @@
// 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"
"reflect"
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20241113152126_add_status_edits"
"github.com/uptrace/bun"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
statusType := reflect.TypeOf((*gtsmodel.Status)(nil))
// Generate new Status.EditIDs column definition from bun.
colDef, err := getBunColumnDef(tx, statusType, "EditIDs")
if err != nil {
return err
}
// Add EditIDs column to Status table.
_, err = tx.NewAddColumn().
Model((*gtsmodel.Status)(nil)).
ColumnExpr(colDef).
Exec(ctx)
if err != nil {
return err
}
// Create the main StatusEdits table.
_, err = tx.NewCreateTable().
IfNotExists().
Model((*gtsmodel.StatusEdit)(nil)).
Exec(ctx)
return err
})
}
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)
}
}

View file

@ -0,0 +1,97 @@
// 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 (
"time"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// Status represents a user-created 'post' or 'status' in the database, either remote or local
type Status 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
FetchedAt time.Time `bun:"type:timestamptz,nullzero"` // when was item (remote) last fetched.
PinnedAt time.Time `bun:"type:timestamptz,nullzero"` // Status was pinned by owning account at this time.
URI string `bun:",unique,nullzero,notnull"` // activitypub URI of this status
URL string `bun:",nullzero"` // web url for viewing this status
Content string `bun:""` // content of this status; likely html-formatted but not guaranteed
AttachmentIDs []string `bun:"attachments,array"` // Database IDs of any media attachments associated with this status
Attachments []*gtsmodel.MediaAttachment `bun:"attached_media,rel:has-many"` // Attachments corresponding to attachmentIDs
TagIDs []string `bun:"tags,array"` // Database IDs of any tags used in this status
Tags []*gtsmodel.Tag `bun:"attached_tags,m2m:status_to_tags"` // Tags corresponding to tagIDs. https://bun.uptrace.dev/guide/relations.html#many-to-many-relation
MentionIDs []string `bun:"mentions,array"` // Database IDs of any mentions in this status
Mentions []*gtsmodel.Mention `bun:"attached_mentions,rel:has-many"` // Mentions corresponding to mentionIDs
EmojiIDs []string `bun:"emojis,array"` // Database IDs of any emojis used in this status
Emojis []*gtsmodel.Emoji `bun:"attached_emojis,m2m:status_to_emojis"` // Emojis corresponding to emojiIDs. https://bun.uptrace.dev/guide/relations.html#many-to-many-relation
Local *bool `bun:",nullzero,notnull,default:false"` // is this status from a local account?
AccountID string `bun:"type:CHAR(26),nullzero,notnull"` // which account posted this status?
Account *gtsmodel.Account `bun:"rel:belongs-to"` // account corresponding to accountID
AccountURI string `bun:",nullzero,notnull"` // activitypub uri of the owner of this status
InReplyToID string `bun:"type:CHAR(26),nullzero"` // id of the status this status replies to
InReplyToURI string `bun:",nullzero"` // activitypub uri of the status this status is a reply to
InReplyToAccountID string `bun:"type:CHAR(26),nullzero"` // id of the account that this status replies to
InReplyTo *Status `bun:"-"` // status corresponding to inReplyToID
InReplyToAccount *gtsmodel.Account `bun:"rel:belongs-to"` // account corresponding to inReplyToAccountID
BoostOfID string `bun:"type:CHAR(26),nullzero"` // id of the status this status is a boost of
BoostOfURI string `bun:"-"` // URI of the status this status is a boost of; field not inserted in the db, just for dereferencing purposes.
BoostOfAccountID string `bun:"type:CHAR(26),nullzero"` // id of the account that owns the boosted status
BoostOf *Status `bun:"-"` // status that corresponds to boostOfID
BoostOfAccount *gtsmodel.Account `bun:"rel:belongs-to"` // account that corresponds to boostOfAccountID
ThreadID string `bun:"type:CHAR(26),nullzero"` // id of the thread to which this status belongs; only set for remote statuses if a local account is involved at some point in the thread, otherwise null
EditIDs []string `bun:"edits,array"` //
Edits []*StatusEdit `bun:"-"` //
PollID string `bun:"type:CHAR(26),nullzero"` //
Poll *gtsmodel.Poll `bun:"-"` //
ContentWarning string `bun:",nullzero"` // cw string for this status
Visibility Visibility `bun:",nullzero,notnull"` // visibility entry for this status
Sensitive *bool `bun:",nullzero,notnull,default:false"` // mark the status as sensitive?
Language string `bun:",nullzero"` // what language is this status written in?
CreatedWithApplicationID string `bun:"type:CHAR(26),nullzero"` // Which application was used to create this status?
CreatedWithApplication *gtsmodel.Application `bun:"rel:belongs-to"` // application corresponding to createdWithApplicationID
ActivityStreamsType string `bun:",nullzero,notnull"` // What is the activitystreams type of this status? See: https://www.w3.org/TR/activitystreams-vocabulary/#object-types. Will probably almost always be Note but who knows!.
Text string `bun:""` // Original text of the status without formatting
Federated *bool `bun:",notnull"` // This status will be federated beyond the local timeline(s)
InteractionPolicy *gtsmodel.InteractionPolicy `bun:""` // InteractionPolicy for this status. If null then the default InteractionPolicy should be assumed for this status's Visibility. Always null for boost wrappers.
PendingApproval *bool `bun:",nullzero,notnull,default:false"` // If true then status is a reply or boost wrapper that must be Approved by the reply-ee or boost-ee before being fully distributed.
PreApproved bool `bun:"-"` // If true, then status is a reply to or boost wrapper of a status on our instance, has permission to do the interaction, and an Accept should be sent out for it immediately. Field not stored in the DB.
ApprovedByURI string `bun:",nullzero"` // URI of an Accept Activity that approves the Announce or Create Activity that this status was/will be attached to.
}
// Visibility represents the visibility granularity of a status.
type Visibility string
const (
// VisibilityNone means nobody can see this.
// It's only used for web status visibility.
VisibilityNone Visibility = "none"
// VisibilityPublic means this status will be visible to everyone on all timelines.
VisibilityPublic Visibility = "public"
// VisibilityUnlocked means this status will be visible to everyone, but will only show on home timeline to followers, and in lists.
VisibilityUnlocked Visibility = "unlocked"
// VisibilityFollowersOnly means this status is viewable to followers only.
VisibilityFollowersOnly Visibility = "followers_only"
// VisibilityMutualsOnly means this status is visible to mutual followers only.
VisibilityMutualsOnly Visibility = "mutuals_only"
// VisibilityDirect means this status is visible only to mentioned recipients.
VisibilityDirect Visibility = "direct"
// VisibilityDefault is used when no other setting can be found.
VisibilityDefault Visibility = VisibilityUnlocked
)

View file

@ -0,0 +1,48 @@
// 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 (
"time"
)
// StatusEdit represents a **historical** view of a Status
// after a received edit. The Status itself will always
// contain the latest up-to-date information.
//
// Note that stored status edits may not exactly match that
// of the origin server, they are a best-effort by receiver
// to store version history. There is no AP history endpoint.
type StatusEdit struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // ID of this item in the database.
Content string `bun:""` // Content of status at time of edit; likely html-formatted but not guaranteed.
ContentWarning string `bun:",nullzero"` // Content warning of status at time of edit.
Text string `bun:""` // Original status text, without formatting, at time of edit.
Language string `bun:",nullzero"` // Status language at time of edit.
Sensitive *bool `bun:",nullzero,notnull,default:false"` // Status sensitive flag at time of edit.
AttachmentIDs []string `bun:"attachments,array"` // Database IDs of media attachments associated with status at time of edit.
AttachmentDescriptions []string `bun:",array"` // Previous media descriptions of media attachments associated with status at time of edit.
PollOptions []string `bun:",array"` // Poll options of status at time of edit, only set if status contains a poll.
PollVotes []int `bun:",array"` // Poll vote count at time of status edit, only set if poll votes were reset.
StatusID string `bun:"type:CHAR(26),nullzero,notnull"` // The originating status ID this is a historical edit of.
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // The creation time of this version of the status content (according to receiving server).
// We don't bother having a *gtsmodel.Status model here
// as the StatusEdit is always just attached to a Status,
// so it doesn't need a self-reference back to it.
}

View file

@ -19,12 +19,9 @@ package migrations
import (
"context"
"errors"
old_gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
new_gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/uptrace/bun"
@ -128,97 +125,6 @@ func init() {
}
}
// convertEnums performs a transaction that converts
// a table's column of our old-style enums (strings) to
// more performant and space-saving integer types.
func convertEnums[OldType ~string, NewType ~int16](
ctx context.Context,
tx bun.Tx,
table string,
column string,
mapping map[OldType]NewType,
defaultValue *NewType,
) error {
if len(mapping) == 0 {
return errors.New("empty mapping")
}
// Generate new column name.
newColumn := column + "_new"
log.Infof(ctx, "converting %s.%s enums; "+
"this may take a while, please don't interrupt!",
table, column,
)
// Ensure a default value.
if defaultValue == nil {
var zero NewType
defaultValue = &zero
}
// Add new column to database.
if _, err := tx.NewAddColumn().
Table(table).
ColumnExpr("? SMALLINT NOT NULL DEFAULT ?",
bun.Ident(newColumn),
*defaultValue).
Exec(ctx); err != nil {
return gtserror.Newf("error adding new column: %w", err)
}
// Get a count of all in table.
total, err := tx.NewSelect().
Table(table).
Count(ctx)
if err != nil {
return gtserror.Newf("error selecting total count: %w", err)
}
var updated int
for old, new := range mapping {
// Update old to new values.
res, err := tx.NewUpdate().
Table(table).
Where("? = ?", bun.Ident(column), old).
Set("? = ?", bun.Ident(newColumn), new).
Exec(ctx)
if err != nil {
return gtserror.Newf("error updating old column values: %w", err)
}
// Count number items updated.
n, _ := res.RowsAffected()
updated += int(n)
}
// Check total updated.
if total != updated {
log.Warnf(ctx, "total=%d does not match updated=%d", total, updated)
}
// Drop the old column from table.
if _, err := tx.NewDropColumn().
Table(table).
ColumnExpr("?", bun.Ident(column)).
Exec(ctx); err != nil {
return gtserror.Newf("error dropping old column: %w", err)
}
// Rename new to old name.
if _, err := tx.NewRaw(
"ALTER TABLE ? RENAME COLUMN ? TO ?",
bun.Ident(table),
bun.Ident(newColumn),
bun.Ident(column),
).Exec(ctx); err != nil {
return gtserror.Newf("error renaming new column: %w", err)
}
return nil
}
// visibilityEnumMapping maps old Visibility enum values to their newer integer type.
func visibilityEnumMapping[T ~string]() map[T]new_gtsmodel.Visibility {
return map[T]new_gtsmodel.Visibility{

View file

@ -0,0 +1,57 @@
// 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"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/uptrace/bun"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
// Check for 'updated_at' column on media attachments table, else return.
exists, err := doesColumnExist(ctx, tx, "media_attachments", "updated_at")
if err != nil {
return err
} else if !exists {
return nil
}
// Remove 'updated_at' column.
_, err = tx.NewDropColumn().
Model((*gtsmodel.MediaAttachment)(nil)).
Column("updated_at").
Exec(ctx)
return err
})
}
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)
}
}

View file

@ -19,11 +19,209 @@ package migrations
import (
"context"
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"codeberg.org/gruf/go-byteutil"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/dialect/feature"
"github.com/uptrace/bun/dialect/sqltype"
"github.com/uptrace/bun/schema"
)
// convertEnums performs a transaction that converts
// a table's column of our old-style enums (strings) to
// more performant and space-saving integer types.
func convertEnums[OldType ~string, NewType ~int16](
ctx context.Context,
tx bun.Tx,
table string,
column string,
mapping map[OldType]NewType,
defaultValue *NewType,
) error {
if len(mapping) == 0 {
return errors.New("empty mapping")
}
// Generate new column name.
newColumn := column + "_new"
log.Infof(ctx, "converting %s.%s enums; "+
"this may take a while, please don't interrupt!",
table, column,
)
// Ensure a default value.
if defaultValue == nil {
var zero NewType
defaultValue = &zero
}
// Add new column to database.
if _, err := tx.NewAddColumn().
Table(table).
ColumnExpr("? SMALLINT NOT NULL DEFAULT ?",
bun.Ident(newColumn),
*defaultValue).
Exec(ctx); err != nil {
return gtserror.Newf("error adding new column: %w", err)
}
// Get a count of all in table.
total, err := tx.NewSelect().
Table(table).
Count(ctx)
if err != nil {
return gtserror.Newf("error selecting total count: %w", err)
}
var updated int
for old, new := range mapping {
// Update old to new values.
res, err := tx.NewUpdate().
Table(table).
Where("? = ?", bun.Ident(column), old).
Set("? = ?", bun.Ident(newColumn), new).
Exec(ctx)
if err != nil {
return gtserror.Newf("error updating old column values: %w", err)
}
// Count number items updated.
n, _ := res.RowsAffected()
updated += int(n)
}
// Check total updated.
if total != updated {
log.Warnf(ctx, "total=%d does not match updated=%d", total, updated)
}
// Drop the old column from table.
if _, err := tx.NewDropColumn().
Table(table).
ColumnExpr("?", bun.Ident(column)).
Exec(ctx); err != nil {
return gtserror.Newf("error dropping old column: %w", err)
}
// Rename new to old name.
if _, err := tx.NewRaw(
"ALTER TABLE ? RENAME COLUMN ? TO ?",
bun.Ident(table),
bun.Ident(newColumn),
bun.Ident(column),
).Exec(ctx); err != nil {
return gtserror.Newf("error renaming new column: %w", err)
}
return nil
}
// getBunColumnDef generates a column definition string for the SQL table represented by
// Go type, with the SQL column represented by the given Go field name. This ensures when
// adding a new column for table by migration that it will end up as bun would create it.
//
// NOTE: this function must stay in sync with (*bun.CreateTableQuery{}).AppendQuery(),
// specifically where it loops over table fields appending each column definition.
func getBunColumnDef(db bun.IDB, rtype reflect.Type, fieldName string) (string, error) {
d := db.Dialect()
f := d.Features()
// Get bun schema definitions for Go type and its field.
field, table, err := getModelField(db, rtype, fieldName)
if err != nil {
return "", err
}
// Start with reasonable buf.
buf := make([]byte, 0, 64)
// Start with the SQL column name.
buf = append(buf, field.SQLName...)
buf = append(buf, " "...)
// Append the SQL
// type information.
switch {
// Most of the time these two will match, but for the cases where DiscoveredSQLType is dialect-specific,
// e.g. pgdialect would change sqltype.SmallInt to pgTypeSmallSerial for columns that have `bun:",autoincrement"`
case !strings.EqualFold(field.CreateTableSQLType, field.DiscoveredSQLType):
buf = append(buf, field.CreateTableSQLType...)
// For all common SQL types except VARCHAR, both UserDefinedSQLType and DiscoveredSQLType specify the correct type,
// and we needn't modify it. For VARCHAR columns, we will stop to check if a valid length has been set in .Varchar(int).
case !strings.EqualFold(field.CreateTableSQLType, sqltype.VarChar):
buf = append(buf, field.CreateTableSQLType...)
// All else falls back
// to a default varchar.
default:
if d.Name() == dialect.Oracle {
buf = append(buf, "VARCHAR2"...)
} else {
buf = append(buf, sqltype.VarChar...)
}
buf = append(buf, "("...)
buf = strconv.AppendInt(buf, int64(d.DefaultVarcharLen()), 10)
buf = append(buf, ")"...)
}
// Append not null definition if field requires.
if field.NotNull && d.Name() != dialect.Oracle {
buf = append(buf, " NOT NULL"...)
}
// Append autoincrement definition if field requires.
if field.Identity && f.Has(feature.GeneratedIdentity) ||
(field.AutoIncrement && (f.Has(feature.AutoIncrement) || f.Has(feature.Identity))) {
buf = d.AppendSequence(buf, table, field)
}
// Append any default value.
if field.SQLDefault != "" {
buf = append(buf, " DEFAULT "...)
buf = append(buf, field.SQLDefault...)
}
return byteutil.B2S(buf), nil
}
// getModelField returns the uptrace/bun schema details for given Go type and field name.
func getModelField(db bun.IDB, rtype reflect.Type, fieldName string) (*schema.Field, *schema.Table, error) {
// Get the associated table for Go type.
table := db.Dialect().Tables().Get(rtype)
if table == nil {
return nil, nil, fmt.Errorf("no table found for type: %s", rtype)
}
var field *schema.Field
// Look for field matching Go name.
for i := range table.Fields {
if table.Fields[i].GoName == fieldName {
field = table.Fields[i]
break
}
}
if field == nil {
return nil, nil, fmt.Errorf("no bun field found on %s with name: %s", rtype, fieldName)
}
return field, table, nil
}
// doesColumnExist safely checks whether given column exists on table, handling both SQLite and PostgreSQL appropriately.
func doesColumnExist(ctx context.Context, tx bun.Tx, table, col string) (bool, error) {
var n int

View file

@ -21,7 +21,6 @@ import (
"context"
"errors"
"slices"
"time"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
@ -181,7 +180,7 @@ func (s *statusDB) getStatus(ctx context.Context, lookup string, dbQuery func(*g
func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status) error {
var (
err error
errs = gtserror.NewMultiError(9)
errs gtserror.MultiError
)
if status.Account == nil {
@ -257,7 +256,7 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status)
if !status.AttachmentsPopulated() {
// Status attachments are out-of-date with IDs, repopulate.
status.Attachments, err = s.state.DB.GetAttachmentsByIDs(
ctx, // these are already barebones
gtscontext.SetBarebones(ctx),
status.AttachmentIDs,
)
if err != nil {
@ -268,7 +267,7 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status)
if !status.TagsPopulated() {
// Status tags are out-of-date with IDs, repopulate.
status.Tags, err = s.state.DB.GetTags(
ctx,
gtscontext.SetBarebones(ctx),
status.TagIDs,
)
if err != nil {
@ -279,7 +278,7 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status)
if !status.MentionsPopulated() {
// Status mentions are out-of-date with IDs, repopulate.
status.Mentions, err = s.state.DB.GetMentions(
ctx, // leave fully populated for now
ctx, // TODO: manually populate mentions for places expecting these populated
status.MentionIDs,
)
if err != nil {
@ -290,7 +289,7 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status)
if !status.EmojisPopulated() {
// Status emojis are out-of-date with IDs, repopulate.
status.Emojis, err = s.state.DB.GetEmojisByIDs(
ctx, // these are already barebones
gtscontext.SetBarebones(ctx),
status.EmojiIDs,
)
if err != nil {
@ -298,10 +297,21 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status)
}
}
if !status.EditsPopulated() {
// Status edits are out-of-date with IDs, repopulate.
status.Edits, err = s.state.DB.GetStatusEditsByIDs(
gtscontext.SetBarebones(ctx),
status.EditIDs,
)
if err != nil {
errs.Appendf("error populating status edits: %w", err)
}
}
if status.CreatedWithApplicationID != "" && status.CreatedWithApplication == nil {
// Populate the status' expected CreatedWithApplication (not always set).
status.CreatedWithApplication, err = s.state.DB.GetApplicationByID(
ctx, // these are already barebones
gtscontext.SetBarebones(ctx),
status.CreatedWithApplicationID,
)
if err != nil {
@ -350,14 +360,14 @@ func (s *statusDB) PutStatus(ctx context.Context, status *gtsmodel.Status) error
}
}
// change the status ID of the media attachments to the new status
// change the status ID of the media
// attachments to the current status
for _, a := range status.Attachments {
a.StatusID = status.ID
a.UpdatedAt = time.Now()
if _, err := tx.
NewUpdate().
Model(a).
Column("status_id", "updated_at").
Column("status_id").
Where("? = ?", bun.Ident("media_attachment.id"), a.ID).
Exec(ctx); err != nil {
if !errors.Is(err, db.ErrAlreadyExists) {
@ -384,19 +394,15 @@ func (s *statusDB) PutStatus(ctx context.Context, status *gtsmodel.Status) error
}
// Finally, insert the status
_, err := tx.NewInsert().Model(status).Exec(ctx)
_, err := tx.NewInsert().
Model(status).
Exec(ctx)
return err
})
})
}
func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status, columns ...string) error {
status.UpdatedAt = time.Now()
if len(columns) > 0 {
// If we're updating by column, ensure "updated_at" is included.
columns = append(columns, "updated_at")
}
return s.state.Caches.DB.Status.Store(status, func() error {
// It is safe to run this database transaction within cache.Store
// as the cache does not attempt a mutex lock until AFTER hook.
@ -434,13 +440,14 @@ func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status, co
}
}
// change the status ID of the media attachments to the new status
// change the status ID of the media
// attachments to the current status.
for _, a := range status.Attachments {
a.StatusID = status.ID
a.UpdatedAt = time.Now()
if _, err := tx.
NewUpdate().
Model(a).
Column("status_id").
Where("? = ?", bun.Ident("media_attachment.id"), a.ID).
Exec(ctx); err != nil {
if !errors.Is(err, db.ErrAlreadyExists) {
@ -467,8 +474,7 @@ func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status, co
}
// Finally, update the status
_, err := tx.
NewUpdate().
_, err := tx.NewUpdate().
Model(status).
Column(columns...).
Where("? = ?", bun.Ident("status.id"), status.ID).

View file

@ -0,0 +1,198 @@
// 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 bundb
import (
"context"
"errors"
"slices"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
type statusEditDB struct {
db *bun.DB
state *state.State
}
func (s *statusEditDB) GetStatusEditByID(ctx context.Context, id string) (*gtsmodel.StatusEdit, error) {
// Fetch edit from database cache with loader callback.
edit, err := s.state.Caches.DB.StatusEdit.LoadOne("ID",
func() (*gtsmodel.StatusEdit, error) {
var edit gtsmodel.StatusEdit
// Not cached, load edit
// from database by its ID.
if err := s.db.NewSelect().
Model(&edit).
Where("? = ?", bun.Ident("id"), id).
Scan(ctx); err != nil {
return nil, err
}
return &edit, nil
}, id,
)
if err != nil {
return nil, err
}
if gtscontext.Barebones(ctx) {
// no need to fully populate.
return edit, nil
}
// Further populate the edit fields where applicable.
if err := s.PopulateStatusEdit(ctx, edit); err != nil {
return nil, err
}
return edit, nil
}
func (s *statusEditDB) GetStatusEditsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.StatusEdit, error) {
// Load status edits for IDs via cache loader callbacks.
edits, err := s.state.Caches.DB.StatusEdit.LoadIDs("ID",
ids,
func(uncached []string) ([]*gtsmodel.StatusEdit, error) {
// Preallocate expected length of uncached edits.
edits := make([]*gtsmodel.StatusEdit, 0, len(uncached))
// Perform database query scanning
// the remaining (uncached) edit IDs.
if err := s.db.NewSelect().
Model(&edits).
Where("? IN (?)", bun.Ident("id"), bun.In(uncached)).
Scan(ctx); err != nil {
return nil, err
}
return edits, nil
},
)
if err != nil {
return nil, err
}
// Reorder the edits by their
// IDs to ensure in correct order.
getID := func(e *gtsmodel.StatusEdit) string { return e.ID }
xslices.OrderBy(edits, ids, getID)
if gtscontext.Barebones(ctx) {
// no need to fully populate.
return edits, nil
}
// Populate all loaded edits, removing those we fail to
// populate (removes needing so many nil checks everywhere).
edits = slices.DeleteFunc(edits, func(edit *gtsmodel.StatusEdit) bool {
if err := s.PopulateStatusEdit(ctx, edit); err != nil {
log.Errorf(ctx, "error populating edit %s: %v", edit.ID, err)
return true
}
return false
})
return edits, nil
}
func (s *statusEditDB) PopulateStatusEdit(ctx context.Context, edit *gtsmodel.StatusEdit) error {
var err error
var errs gtserror.MultiError
// For sub-models we only want
// barebones versions of them.
ctx = gtscontext.SetBarebones(ctx)
if !edit.AttachmentsPopulated() {
// Fetch all attachments for status edit's IDs.
edit.Attachments, err = s.state.DB.GetAttachmentsByIDs(
ctx,
edit.AttachmentIDs,
)
if err != nil {
errs.Appendf("error populating edit attachments: %w", err)
}
}
return errs.Combine()
}
func (s *statusEditDB) PutStatusEdit(ctx context.Context, edit *gtsmodel.StatusEdit) error {
return s.state.Caches.DB.StatusEdit.Store(edit, func() error {
_, err := s.db.NewInsert().Model(edit).Exec(ctx)
return err
})
}
func (s *statusEditDB) DeleteStatusEdits(ctx context.Context, ids []string) error {
// Gather necessary fields from
// deleted for cache invalidation.
deleted := make([]*gtsmodel.StatusEdit, 0, len(ids))
// Delete all edits with IDs pertaining
// to given slice, returning status IDs.
if _, err := s.db.NewDelete().
Model(&deleted).
Where("? IN (?)", bun.Ident("id"), bun.In(ids)).
Returning("?", bun.Ident("status_id")).
Exec(ctx); err != nil &&
!errors.Is(err, db.ErrNoEntries) {
return err
}
// Check for no deletes.
if len(deleted) == 0 {
return nil
}
// Invalidate all the cached status edits with IDs.
s.state.Caches.DB.StatusEdit.InvalidateIDs("ID", ids)
// With each invalidate hook mark status ID of
// edit we just called for. We only want to call
// invalidate hooks of edits from unique statuses.
invalidated := make(map[string]struct{}, 1)
// Invalidate the first delete manually, this
// opt negates need for initial hashmap lookup.
s.state.Caches.OnInvalidateStatusEdit(deleted[0])
invalidated[deleted[0].StatusID] = struct{}{}
for _, edit := range deleted {
// Check not already called for status.
_, ok := invalidated[edit.StatusID]
if ok {
continue
}
// Manually call status edit invalidate hook.
s.state.Caches.OnInvalidateStatusEdit(edit)
invalidated[edit.StatusID] = struct{}{}
}
return nil
}

View file

@ -0,0 +1,168 @@
// 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 bundb_test
import (
"context"
"errors"
"reflect"
"slices"
"testing"
"time"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
type StatusEditTestSuite struct {
BunDBStandardTestSuite
}
func (suite *StatusEditTestSuite) TestGetStatusEditBy() {
t := suite.T()
// Create a new context for this test.
ctx, cncl := context.WithCancel(context.Background())
defer cncl()
// Sentinel error to mark avoiding a test case.
sentinelErr := errors.New("sentinel")
for _, edit := range suite.testStatusEdits {
for lookup, dbfunc := range map[string]func() (*gtsmodel.StatusEdit, error){
"id": func() (*gtsmodel.StatusEdit, error) {
return suite.db.GetStatusEditByID(ctx, edit.ID)
},
} {
// Clear database caches.
suite.state.Caches.Init()
t.Logf("checking database lookup %q", lookup)
// Perform database function.
checkEdit, err := dbfunc()
if err != nil {
if err == sentinelErr {
continue
}
t.Errorf("error encountered for database lookup %q: %v", lookup, err)
continue
}
// Check received account data.
if !areEditsEqual(edit, checkEdit) {
t.Errorf("edit does not contain expected data: %+v", checkEdit)
continue
}
}
}
}
func (suite *StatusEditTestSuite) TestGetStatusEditsByIDs() {
t := suite.T()
// Create a new context for this test.
ctx, cncl := context.WithCancel(context.Background())
defer cncl()
// editsByStatus returns all test edits by the given status with ID.
editsByStatus := func(status *gtsmodel.Status) []*gtsmodel.StatusEdit {
var edits []*gtsmodel.StatusEdit
for _, edit := range suite.testStatusEdits {
if edit.StatusID == status.ID {
edits = append(edits, edit)
}
}
return edits
}
for _, status := range suite.testStatuses {
// Get test status edit models
// that should be found for status.
check := editsByStatus(status)
// Fetch edits for the slice of IDs attached to status from database.
edits, err := suite.state.DB.GetStatusEditsByIDs(ctx, status.EditIDs)
suite.NoError(err)
// Ensure both slices
// sorted the same.
sortEdits(check)
sortEdits(edits)
// Check whether slices of status edits match.
if !slices.EqualFunc(check, edits, areEditsEqual) {
t.Error("status edit slices do not match")
}
}
}
func (suite *StatusEditTestSuite) TestDeleteStatusEdits() {
// Create a new context for this test.
ctx, cncl := context.WithCancel(context.Background())
defer cncl()
for _, status := range suite.testStatuses {
// Delete all edits for status with given IDs from database.
err := suite.state.DB.DeleteStatusEdits(ctx, status.EditIDs)
suite.NoError(err)
// Now attempt to fetch these edits from database, should be empty.
edits, err := suite.state.DB.GetStatusEditsByIDs(ctx, status.EditIDs)
suite.NoError(err)
suite.Empty(edits)
}
}
func TestStatusEditTestSuite(t *testing.T) {
suite.Run(t, new(StatusEditTestSuite))
}
func areEditsEqual(e1, e2 *gtsmodel.StatusEdit) bool {
// Clone the 1st status edit.
e1Copy := new(gtsmodel.StatusEdit)
*e1Copy = *e1
e1 = e1Copy
// Clone the 2nd status edit.
e2Copy := new(gtsmodel.StatusEdit)
*e2Copy = *e2
e2 = e2Copy
// Clear populated sub-models.
e1.Attachments = nil
e2.Attachments = nil
// Clear database-set fields.
e1.CreatedAt = time.Time{}
e2.CreatedAt = time.Time{}
return reflect.DeepEqual(*e1, *e2)
}
func sortEdits(edits []*gtsmodel.StatusEdit) {
slices.SortFunc(edits, func(a, b *gtsmodel.StatusEdit) int {
if a.CreatedAt.Before(b.CreatedAt) {
return +1
} else if b.CreatedAt.Before(a.CreatedAt) {
return -1
}
return 0
})
}

View file

@ -123,13 +123,8 @@ func (t *timelineDB) GetHomeTimeline(ctx context.Context, accountID string, maxI
if maxID == "" || maxID >= id.Highest {
const future = 24 * time.Hour
var err error
// don't return statuses more than 24hr in the future
maxID, err = id.NewULIDFromTime(time.Now().Add(future))
if err != nil {
return nil, err
}
maxID = id.NewULIDFromTime(time.Now().Add(future))
}
// return only statuses LOWER (ie., older) than maxID
@ -223,13 +218,8 @@ func (t *timelineDB) GetPublicTimeline(ctx context.Context, maxID string, sinceI
if maxID == "" || maxID >= id.Highest {
const future = 24 * time.Hour
var err error
// don't return statuses more than 24hr in the future
maxID, err = id.NewULIDFromTime(time.Now().Add(future))
if err != nil {
return nil, err
}
maxID = id.NewULIDFromTime(time.Now().Add(future))
}
// return only statuses LOWER (ie., older) than maxID
@ -409,13 +399,8 @@ func (t *timelineDB) GetListTimeline(
if maxID == "" || maxID >= id.Highest {
const future = 24 * time.Hour
var err error
// don't return statuses more than 24hr in the future
maxID, err = id.NewULIDFromTime(time.Now().Add(future))
if err != nil {
return nil, err
}
maxID = id.NewULIDFromTime(time.Now().Add(future))
}
// return only statuses LOWER (ie., older) than maxID
@ -508,13 +493,8 @@ func (t *timelineDB) GetTagTimeline(
if maxID == "" || maxID >= id.Highest {
const future = 24 * time.Hour
var err error
// don't return statuses more than 24hr in the future
maxID, err = id.NewULIDFromTime(time.Now().Add(future))
if err != nil {
return nil, err
}
maxID = id.NewULIDFromTime(time.Now().Add(future))
}
// return only statuses LOWER (ie., older) than maxID

View file

@ -37,10 +37,7 @@ type TimelineTestSuite struct {
func getFutureStatus() *gtsmodel.Status {
theDistantFuture := time.Now().Add(876600 * time.Hour)
id, err := id.NewULIDFromTime(theDistantFuture)
if err != nil {
panic(err)
}
id := id.NewULIDFromTime(theDistantFuture)
return &gtsmodel.Status{
ID: id,
@ -182,7 +179,7 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineIgnoreExclusive() {
if err != nil {
suite.FailNow(err.Error())
}
suite.checkStatuses(s, id.Highest, id.Lowest, 8)
suite.checkStatuses(s, id.Highest, id.Lowest, 9)
// Remove admin account from the exclusive list.
listEntry := suite.testListEntries["local_account_1_list_1_entry_2"]
@ -196,7 +193,7 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineIgnoreExclusive() {
if err != nil {
suite.FailNow(err.Error())
}
suite.checkStatuses(s, id.Highest, id.Lowest, 12)
suite.checkStatuses(s, id.Highest, id.Lowest, 13)
}
func (suite *TimelineTestSuite) TestGetHomeTimelineNoFollowing() {
@ -228,7 +225,7 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineNoFollowing() {
suite.FailNow(err.Error())
}
suite.checkStatuses(s, id.Highest, id.Lowest, 8)
suite.checkStatuses(s, id.Highest, id.Lowest, 9)
}
func (suite *TimelineTestSuite) TestGetHomeTimelineWithFutureStatus() {
@ -281,8 +278,8 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineFromHighest() {
}
suite.checkStatuses(s, id.Highest, id.Lowest, 5)
suite.Equal("01J2M1HPFSS54S60Y0KYV23KJE", s[0].ID)
suite.Equal("01G36SF3V6Y6V5BF9P4R7PQG7G", s[len(s)-1].ID)
suite.Equal("01JDPZEZ77X1NX0TY9M10BK1HM", s[0].ID)
suite.Equal("01HEN2RZ8BG29Y5Z9VJC73HZW7", s[len(s)-1].ID)
}
func (suite *TimelineTestSuite) TestGetListTimelineNoParams() {
@ -296,7 +293,7 @@ func (suite *TimelineTestSuite) TestGetListTimelineNoParams() {
suite.FailNow(err.Error())
}
suite.checkStatuses(s, id.Highest, id.Lowest, 12)
suite.checkStatuses(s, id.Highest, id.Lowest, 13)
}
func (suite *TimelineTestSuite) TestGetListTimelineMaxID() {
@ -311,8 +308,8 @@ func (suite *TimelineTestSuite) TestGetListTimelineMaxID() {
}
suite.checkStatuses(s, id.Highest, id.Lowest, 5)
suite.Equal("01HEN2PRXT0TF4YDRA64FZZRN7", s[0].ID)
suite.Equal("01FF25D5Q0DH7CHD57CTRS6WK0", s[len(s)-1].ID)
suite.Equal("01JDPZEZ77X1NX0TY9M10BK1HM", s[0].ID)
suite.Equal("01FN3VJGFH10KR7S2PB0GFJZYG", s[len(s)-1].ID)
}
func (suite *TimelineTestSuite) TestGetListTimelineMinID() {

View file

@ -51,6 +51,7 @@ type DB interface {
SinBinStatus
Status
StatusBookmark
StatusEdit
StatusFave
Tag
Thread

43
internal/db/statusedit.go Normal file
View file

@ -0,0 +1,43 @@
// 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 db
import (
"context"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
type StatusEdit interface {
// GetStatusEditByID fetches the StatusEdit with given ID from the database.
GetStatusEditByID(ctx context.Context, id string) (*gtsmodel.StatusEdit, error)
// GetStatusEditsByIDs fetches all StatusEdits with given IDs from database,
// this is optimized and faster than multiple calls to GetStatusEditByID.
GetStatusEditsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.StatusEdit, error)
// PopulateStatusEdit ensures the given StatusEdit's sub-models are populated.
PopulateStatusEdit(ctx context.Context, edit *gtsmodel.StatusEdit) error
// PutStatusEdit inserts the given new StatusEdit into the database.
PutStatusEdit(ctx context.Context, edit *gtsmodel.StatusEdit) error
// DeleteStatusEdits deletes the StatusEdits with given IDs from the database.
DeleteStatusEdits(ctx context.Context, ids []string) error
}