diff options
Diffstat (limited to 'internal/db')
22 files changed, 994 insertions, 189 deletions
diff --git a/internal/db/bundb/account_test.go b/internal/db/bundb/account_test.go index 7dcc0f9e7..879250408 100644 --- a/internal/db/bundb/account_test.go +++ b/internal/db/bundb/account_test.go @@ -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) diff --git a/internal/db/bundb/basic_test.go b/internal/db/bundb/basic_test.go index 56159dc25..e20aab765 100644 --- a/internal/db/bundb/basic_test.go +++ b/internal/db/bundb/basic_test.go @@ -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() { diff --git a/internal/db/bundb/bundb.go b/internal/db/bundb/bundb.go index 70132fe58..cf612fd2e 100644 --- a/internal/db/bundb/bundb.go +++ b/internal/db/bundb/bundb.go @@ -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, diff --git a/internal/db/bundb/bundb_test.go b/internal/db/bundb/bundb_test.go index e976199e4..2fcf61aed 100644 --- a/internal/db/bundb/bundb_test.go +++ b/internal/db/bundb/bundb_test.go @@ -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() { diff --git a/internal/db/bundb/instance_test.go b/internal/db/bundb/instance_test.go index 4b8ec9962..1364bacc2 100644 --- a/internal/db/bundb/instance_test.go +++ b/internal/db/bundb/instance_test.go @@ -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() { diff --git a/internal/db/bundb/interaction_test.go b/internal/db/bundb/interaction_test.go index 37684f18c..1eb8154c1 100644 --- a/internal/db/bundb/interaction_test.go +++ b/internal/db/bundb/interaction_test.go @@ -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())  		} diff --git a/internal/db/bundb/media.go b/internal/db/bundb/media.go index 453ad856a..09c8188f0 100644 --- a/internal/db/bundb/media.go +++ b/internal/db/bundb/media.go @@ -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). diff --git a/internal/db/bundb/migrations/20240809134448_interaction_requests_client_api.go b/internal/db/bundb/migrations/20240809134448_interaction_requests_client_api.go index 82c2b4016..a3fb8675e 100644 --- a/internal/db/bundb/migrations/20240809134448_interaction_requests_client_api.go +++ b/internal/db/bundb/migrations/20240809134448_interaction_requests_client_api.go @@ -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(). diff --git a/internal/db/bundb/migrations/20241113151042_remove_mention_updated_at.go b/internal/db/bundb/migrations/20241113151042_remove_mention_updated_at.go new file mode 100644 index 000000000..bd72dc109 --- /dev/null +++ b/internal/db/bundb/migrations/20241113151042_remove_mention_updated_at.go @@ -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) +	} +} diff --git a/internal/db/bundb/migrations/20241113152126_add_status_edits.go b/internal/db/bundb/migrations/20241113152126_add_status_edits.go new file mode 100644 index 000000000..aa0b0d4b9 --- /dev/null +++ b/internal/db/bundb/migrations/20241113152126_add_status_edits.go @@ -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) +	} +} diff --git a/internal/db/bundb/migrations/20241113152126_add_status_edits/status.go b/internal/db/bundb/migrations/20241113152126_add_status_edits/status.go new file mode 100644 index 000000000..1b7d93f70 --- /dev/null +++ b/internal/db/bundb/migrations/20241113152126_add_status_edits/status.go @@ -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 +) diff --git a/internal/db/bundb/migrations/20241113152126_add_status_edits/statusedit.go b/internal/db/bundb/migrations/20241113152126_add_status_edits/statusedit.go new file mode 100644 index 000000000..b27c3b343 --- /dev/null +++ b/internal/db/bundb/migrations/20241113152126_add_status_edits/statusedit.go @@ -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. +} diff --git a/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints.go b/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints.go index 10ae95c17..7621ddc6c 100644 --- a/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints.go +++ b/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints.go @@ -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{ diff --git a/internal/db/bundb/migrations/20241203124608_remove_media_attachment_updated_at.go b/internal/db/bundb/migrations/20241203124608_remove_media_attachment_updated_at.go new file mode 100644 index 000000000..344168b38 --- /dev/null +++ b/internal/db/bundb/migrations/20241203124608_remove_media_attachment_updated_at.go @@ -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) +	} +} diff --git a/internal/db/bundb/migrations/util.go b/internal/db/bundb/migrations/util.go index 47de09e23..edf7c1d05 100644 --- a/internal/db/bundb/migrations/util.go +++ b/internal/db/bundb/migrations/util.go @@ -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 diff --git a/internal/db/bundb/status.go b/internal/db/bundb/status.go index 45e9864a3..fa31f3459 100644 --- a/internal/db/bundb/status.go +++ b/internal/db/bundb/status.go @@ -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). diff --git a/internal/db/bundb/statusedit.go b/internal/db/bundb/statusedit.go new file mode 100644 index 000000000..c932968fd --- /dev/null +++ b/internal/db/bundb/statusedit.go @@ -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 +} diff --git a/internal/db/bundb/statusedit_test.go b/internal/db/bundb/statusedit_test.go new file mode 100644 index 000000000..b6a15e825 --- /dev/null +++ b/internal/db/bundb/statusedit_test.go @@ -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 +	}) +} diff --git a/internal/db/bundb/timeline.go b/internal/db/bundb/timeline.go index bcb7953d4..fcea0178a 100644 --- a/internal/db/bundb/timeline.go +++ b/internal/db/bundb/timeline.go @@ -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 diff --git a/internal/db/bundb/timeline_test.go b/internal/db/bundb/timeline_test.go index 00df2b3a6..75a335512 100644 --- a/internal/db/bundb/timeline_test.go +++ b/internal/db/bundb/timeline_test.go @@ -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 >smodel.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() { diff --git a/internal/db/db.go b/internal/db/db.go index c42985912..11dd2e507 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -51,6 +51,7 @@ type DB interface {  	SinBinStatus  	Status  	StatusBookmark +	StatusEdit  	StatusFave  	Tag  	Thread diff --git a/internal/db/statusedit.go b/internal/db/statusedit.go new file mode 100644 index 000000000..32e770fb9 --- /dev/null +++ b/internal/db/statusedit.go @@ -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 +}  | 
