mirror of
https://github.com/mattermost/focalboard.git
synced 2024-12-24 13:43:12 +02:00
891 lines
25 KiB
Go
891 lines
25 KiB
Go
package sqlstore
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
|
|
sq "github.com/Masterminds/squirrel"
|
|
"github.com/wiggin77/merror"
|
|
|
|
"github.com/mattermost/focalboard/server/model"
|
|
"github.com/mattermost/focalboard/server/utils"
|
|
|
|
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
|
)
|
|
|
|
const (
|
|
// we group the inserts on batches of 1000 because PostgreSQL
|
|
// supports a limit of around 64K values (not rows) on an insert
|
|
// query, so we want to stay safely below.
|
|
CategoryInsertBatch = 1000
|
|
|
|
TemplatesToTeamsMigrationKey = "TemplatesToTeamsMigrationComplete"
|
|
UniqueIDsMigrationKey = "UniqueIDsMigrationComplete"
|
|
CategoryUUIDIDMigrationKey = "CategoryUuidIdMigrationComplete"
|
|
TeamLessBoardsMigrationKey = "TeamLessBoardsMigrationComplete"
|
|
DeletedMembershipBoardsMigrationKey = "DeletedMembershipBoardsMigrationComplete"
|
|
DeDuplicateCategoryBoardTableMigrationKey = "DeDuplicateCategoryBoardTableComplete"
|
|
)
|
|
|
|
func (s *SQLStore) getBlocksWithSameID(db sq.BaseRunner) ([]*model.Block, error) {
|
|
subquery, _, _ := s.getQueryBuilder(db).
|
|
Select("id").
|
|
From(s.tablePrefix + "blocks").
|
|
Having("count(id) > 1").
|
|
GroupBy("id").
|
|
ToSql()
|
|
|
|
blocksFields := []string{
|
|
"id",
|
|
"parent_id",
|
|
"root_id",
|
|
"created_by",
|
|
"modified_by",
|
|
s.escapeField("schema"),
|
|
"type",
|
|
"title",
|
|
"COALESCE(fields, '{}')",
|
|
s.timestampToCharField("insert_at", "insertAt"),
|
|
"create_at",
|
|
"update_at",
|
|
"delete_at",
|
|
"COALESCE(workspace_id, '0')",
|
|
}
|
|
|
|
rows, err := s.getQueryBuilder(db).
|
|
Select(blocksFields...).
|
|
From(s.tablePrefix + "blocks").
|
|
Where(fmt.Sprintf("id IN (%s)", subquery)).
|
|
Query()
|
|
if err != nil {
|
|
s.logger.Error(`getBlocksWithSameID ERROR`, mlog.Err(err))
|
|
return nil, err
|
|
}
|
|
defer s.CloseRows(rows)
|
|
|
|
return s.blocksFromRows(rows)
|
|
}
|
|
|
|
func (s *SQLStore) RunUniqueIDsMigration() error {
|
|
setting, err := s.GetSystemSetting(UniqueIDsMigrationKey)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot get migration state: %w", err)
|
|
}
|
|
|
|
// If the migration is already completed, do not run it again.
|
|
if hasAlreadyRun, _ := strconv.ParseBool(setting); hasAlreadyRun {
|
|
return nil
|
|
}
|
|
|
|
s.logger.Debug("Running Unique IDs migration")
|
|
|
|
tx, txErr := s.db.BeginTx(context.Background(), nil)
|
|
if txErr != nil {
|
|
return txErr
|
|
}
|
|
|
|
blocks, err := s.getBlocksWithSameID(tx)
|
|
if err != nil {
|
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
|
s.logger.Error("Unique IDs transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "getBlocksWithSameID"))
|
|
}
|
|
return fmt.Errorf("cannot get blocks with same ID: %w", err)
|
|
}
|
|
|
|
blocksByID := map[string][]*model.Block{}
|
|
for _, block := range blocks {
|
|
blocksByID[block.ID] = append(blocksByID[block.ID], block)
|
|
}
|
|
|
|
for _, blocks := range blocksByID {
|
|
for i, block := range blocks {
|
|
if i == 0 {
|
|
// do nothing for the first ID, only updating the others
|
|
continue
|
|
}
|
|
|
|
newID := utils.NewID(model.BlockType2IDType(block.Type))
|
|
if err := s.replaceBlockID(tx, block.ID, newID, block.WorkspaceID); err != nil {
|
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
|
s.logger.Error("Unique IDs transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "replaceBlockID"))
|
|
}
|
|
return fmt.Errorf("cannot replace blockID %s: %w", block.ID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := s.setSystemSetting(tx, UniqueIDsMigrationKey, strconv.FormatBool(true)); err != nil {
|
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
|
s.logger.Error("Unique IDs transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "setSystemSetting"))
|
|
}
|
|
return fmt.Errorf("cannot mark migration as completed: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("cannot commit unique IDs transaction: %w", err)
|
|
}
|
|
|
|
s.logger.Debug("Unique IDs migration finished successfully")
|
|
return nil
|
|
}
|
|
|
|
// RunCategoryUUIDIDMigration takes care of deriving the categories
|
|
// from the boards and its memberships. The name references UUID
|
|
// because of the preexisting purpose of this migration, and has been
|
|
// preserved for compatibility with already migrated instances.
|
|
func (s *SQLStore) RunCategoryUUIDIDMigration() error {
|
|
setting, err := s.GetSystemSetting(CategoryUUIDIDMigrationKey)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot get migration state: %w", err)
|
|
}
|
|
|
|
// If the migration is already completed, do not run it again.
|
|
if hasAlreadyRun, _ := strconv.ParseBool(setting); hasAlreadyRun {
|
|
return nil
|
|
}
|
|
|
|
s.logger.Debug("Running category UUID ID migration")
|
|
|
|
tx, txErr := s.db.BeginTx(context.Background(), nil)
|
|
if txErr != nil {
|
|
return txErr
|
|
}
|
|
|
|
if s.isPlugin {
|
|
if err := s.createCategories(tx); err != nil {
|
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
|
s.logger.Error("category UUIDs insert categories transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "setSystemSetting"))
|
|
}
|
|
return err
|
|
}
|
|
|
|
if err := s.createCategoryBoards(tx); err != nil {
|
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
|
s.logger.Error("category UUIDs insert category boards transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "setSystemSetting"))
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := s.setSystemSetting(tx, CategoryUUIDIDMigrationKey, strconv.FormatBool(true)); err != nil {
|
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
|
s.logger.Error("category UUIDs transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "setSystemSetting"))
|
|
}
|
|
return fmt.Errorf("cannot mark migration as completed: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("cannot commit category UUIDs transaction: %w", err)
|
|
}
|
|
|
|
s.logger.Debug("category UUIDs migration finished successfully")
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLStore) createCategories(db sq.BaseRunner) error {
|
|
rows, err := s.getQueryBuilder(db).
|
|
Select("c.DisplayName, cm.UserId, c.TeamId, cm.ChannelId").
|
|
From(s.tablePrefix + "boards boards").
|
|
Join("ChannelMembers cm on boards.channel_id = cm.ChannelId").
|
|
Join("Channels c on cm.ChannelId = c.id and (c.Type = 'O' or c.Type = 'P')").
|
|
GroupBy("cm.UserId, c.TeamId, cm.ChannelId, c.DisplayName").
|
|
Query()
|
|
|
|
if err != nil {
|
|
s.logger.Error("get boards data error", mlog.Err(err))
|
|
return err
|
|
}
|
|
defer s.CloseRows(rows)
|
|
|
|
initQuery := func() sq.InsertBuilder {
|
|
return s.getQueryBuilder(db).
|
|
Insert(s.tablePrefix+"categories").
|
|
Columns(
|
|
"id",
|
|
"name",
|
|
"user_id",
|
|
"team_id",
|
|
"channel_id",
|
|
"create_at",
|
|
"update_at",
|
|
"delete_at",
|
|
)
|
|
}
|
|
// query will accumulate the insert values until the limit is
|
|
// reached, and then it will be stored and reset
|
|
query := initQuery()
|
|
// queryList stores those queries that already reached the limit
|
|
// to be run when all the data is processed
|
|
queryList := []sq.InsertBuilder{}
|
|
counter := 0
|
|
now := model.GetMillis()
|
|
|
|
for rows.Next() {
|
|
var displayName string
|
|
var userID string
|
|
var teamID string
|
|
var channelID string
|
|
|
|
err := rows.Scan(
|
|
&displayName,
|
|
&userID,
|
|
&teamID,
|
|
&channelID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot scan result while trying to create categories: %w", err)
|
|
}
|
|
|
|
query = query.Values(
|
|
utils.NewID(utils.IDTypeNone),
|
|
displayName,
|
|
userID,
|
|
teamID,
|
|
channelID,
|
|
now,
|
|
0,
|
|
0,
|
|
)
|
|
|
|
counter++
|
|
if counter%CategoryInsertBatch == 0 {
|
|
queryList = append(queryList, query)
|
|
query = initQuery()
|
|
}
|
|
}
|
|
|
|
if counter%CategoryInsertBatch != 0 {
|
|
queryList = append(queryList, query)
|
|
}
|
|
|
|
for _, q := range queryList {
|
|
if _, err := q.Exec(); err != nil {
|
|
return fmt.Errorf("cannot create category values: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLStore) createCategoryBoards(db sq.BaseRunner) error {
|
|
rows, err := s.getQueryBuilder(db).
|
|
Select("categories.user_id, categories.id, boards.id").
|
|
From(s.tablePrefix + "categories categories").
|
|
Join(s.tablePrefix + "boards boards on categories.channel_id = boards.channel_id AND boards.is_template = false").
|
|
Query()
|
|
|
|
if err != nil {
|
|
s.logger.Error("get categories data error", mlog.Err(err))
|
|
return err
|
|
}
|
|
defer s.CloseRows(rows)
|
|
|
|
initQuery := func() sq.InsertBuilder {
|
|
return s.getQueryBuilder(db).
|
|
Insert(s.tablePrefix+"category_boards").
|
|
Columns(
|
|
"id",
|
|
"user_id",
|
|
"category_id",
|
|
"board_id",
|
|
"create_at",
|
|
"update_at",
|
|
"delete_at",
|
|
)
|
|
}
|
|
// query will accumulate the insert values until the limit is
|
|
// reached, and then it will be stored and reset
|
|
query := initQuery()
|
|
// queryList stores those queries that already reached the limit
|
|
// to be run when all the data is processed
|
|
queryList := []sq.InsertBuilder{}
|
|
counter := 0
|
|
now := model.GetMillis()
|
|
|
|
for rows.Next() {
|
|
var userID string
|
|
var categoryID string
|
|
var boardID string
|
|
|
|
err := rows.Scan(
|
|
&userID,
|
|
&categoryID,
|
|
&boardID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot scan result while trying to create category boards: %w", err)
|
|
}
|
|
|
|
query = query.Values(
|
|
utils.NewID(utils.IDTypeNone),
|
|
userID,
|
|
categoryID,
|
|
boardID,
|
|
now,
|
|
0,
|
|
0,
|
|
)
|
|
|
|
counter++
|
|
if counter%CategoryInsertBatch == 0 {
|
|
queryList = append(queryList, query)
|
|
query = initQuery()
|
|
}
|
|
}
|
|
|
|
if counter%CategoryInsertBatch != 0 {
|
|
queryList = append(queryList, query)
|
|
}
|
|
|
|
for _, q := range queryList {
|
|
if _, err := q.Exec(); err != nil {
|
|
return fmt.Errorf("cannot create category boards values: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// We no longer support boards existing in DMs and private
|
|
// group messages. This function migrates all boards
|
|
// belonging to a DM to the best possible team.
|
|
func (s *SQLStore) RunTeamLessBoardsMigration() error {
|
|
if !s.isPlugin {
|
|
return nil
|
|
}
|
|
|
|
setting, err := s.GetSystemSetting(TeamLessBoardsMigrationKey)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot get teamless boards migration state: %w", err)
|
|
}
|
|
|
|
// If the migration is already completed, do not run it again.
|
|
if hasAlreadyRun, _ := strconv.ParseBool(setting); hasAlreadyRun {
|
|
return nil
|
|
}
|
|
|
|
boards, err := s.getDMBoards(s.db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.logger.Debug("Migrating teamless boards to a team", mlog.Int("count", len(boards)))
|
|
|
|
// cache for best suitable team for a DM. Since a DM can
|
|
// contain multiple boards, caching this avoids
|
|
// duplicate queries for the same DM.
|
|
channelToTeamCache := map[string]string{}
|
|
|
|
tx, err := s.db.BeginTx(context.Background(), nil)
|
|
if err != nil {
|
|
s.logger.Error("error starting transaction in runTeamLessBoardsMigration", mlog.Err(err))
|
|
return err
|
|
}
|
|
|
|
for i := range boards {
|
|
// check the cache first
|
|
teamID, ok := channelToTeamCache[boards[i].ChannelID]
|
|
|
|
// query DB if entry not found in cache
|
|
if !ok {
|
|
teamID, err = s.getBestTeamForBoard(s.db, boards[i])
|
|
if err != nil {
|
|
// don't let one board's error spoil
|
|
// the mood for others
|
|
s.logger.Error("could not find the best team for board during team less boards migration. Continuing", mlog.String("boardID", boards[i].ID))
|
|
continue
|
|
}
|
|
}
|
|
|
|
channelToTeamCache[boards[i].ChannelID] = teamID
|
|
boards[i].TeamID = teamID
|
|
|
|
query := s.getQueryBuilder(tx).
|
|
Update(s.tablePrefix+"boards").
|
|
Set("team_id", teamID).
|
|
Set("type", model.BoardTypePrivate).
|
|
Where(sq.Eq{"id": boards[i].ID})
|
|
|
|
if _, err := query.Exec(); err != nil {
|
|
s.logger.Error("failed to set team id for board", mlog.String("board_id", boards[i].ID), mlog.String("team_id", teamID), mlog.Err(err))
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := s.setSystemSetting(tx, TeamLessBoardsMigrationKey, strconv.FormatBool(true)); err != nil {
|
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
|
s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "runTeamLessBoardsMigration"))
|
|
}
|
|
return fmt.Errorf("cannot mark migration as completed: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
s.logger.Error("failed to commit runTeamLessBoardsMigration transaction", mlog.Err(err))
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLStore) getDMBoards(tx sq.BaseRunner) ([]*model.Board, error) {
|
|
conditions := sq.And{
|
|
sq.Eq{"team_id": ""},
|
|
sq.Or{
|
|
sq.Eq{"type": "D"},
|
|
sq.Eq{"type": "G"},
|
|
},
|
|
}
|
|
|
|
boards, err := s.getLegacyBoardsByCondition(tx, conditions)
|
|
if err != nil && model.IsErrNotFound(err) {
|
|
return []*model.Board{}, nil
|
|
}
|
|
|
|
return boards, err
|
|
}
|
|
|
|
// The destination is selected as the first team where all members
|
|
// of the DM are a part of. If no such team exists,
|
|
// we use the first team to which DM creator belongs to.
|
|
func (s *SQLStore) getBestTeamForBoard(tx sq.BaseRunner, board *model.Board) (string, error) {
|
|
userTeams, err := s.getBoardUserTeams(tx, board)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
teams := [][]interface{}{}
|
|
for _, userTeam := range userTeams {
|
|
userTeamInterfaces := make([]interface{}, len(userTeam))
|
|
for i := range userTeam {
|
|
userTeamInterfaces[i] = userTeam[i]
|
|
}
|
|
teams = append(teams, userTeamInterfaces)
|
|
}
|
|
|
|
commonTeams := utils.Intersection(teams...)
|
|
var teamID string
|
|
if len(commonTeams) > 0 {
|
|
teamID = commonTeams[0].(string)
|
|
} else {
|
|
// no common teams found. Let's try finding the best suitable team
|
|
if board.Type == "D" {
|
|
// get DM's creator and pick one of their team
|
|
channel, err := (s.servicesAPI).GetChannelByID(board.ChannelID)
|
|
if err != nil {
|
|
s.logger.Error("failed to fetch DM channel for board",
|
|
mlog.String("board_id", board.ID),
|
|
mlog.String("channel_id", board.ChannelID),
|
|
mlog.Err(err),
|
|
)
|
|
return "", err
|
|
}
|
|
|
|
if _, ok := userTeams[channel.CreatorId]; !ok {
|
|
s.logger.Error("channel creator not found in user teams",
|
|
mlog.String("board_id", board.ID),
|
|
mlog.String("channel_id", board.ChannelID),
|
|
mlog.String("creator_id", channel.CreatorId),
|
|
)
|
|
err := fmt.Errorf("%w board_id: %s, channel_id: %s, creator_id: %s", errChannelCreatorNotInTeam, board.ID, board.ChannelID, channel.CreatorId)
|
|
return "", err
|
|
}
|
|
|
|
teamID = userTeams[channel.CreatorId][0]
|
|
} else if board.Type == "G" {
|
|
// pick the team that has the most users as members
|
|
teamFrequency := map[string]int{}
|
|
highestFrequencyTeam := ""
|
|
highestFrequencyTeamFrequency := -1
|
|
|
|
for _, teams := range userTeams {
|
|
for _, teamID := range teams {
|
|
teamFrequency[teamID]++
|
|
|
|
if teamFrequency[teamID] > highestFrequencyTeamFrequency {
|
|
highestFrequencyTeamFrequency = teamFrequency[teamID]
|
|
highestFrequencyTeam = teamID
|
|
}
|
|
}
|
|
}
|
|
|
|
teamID = highestFrequencyTeam
|
|
}
|
|
}
|
|
|
|
return teamID, nil
|
|
}
|
|
|
|
func (s *SQLStore) getBoardUserTeams(tx sq.BaseRunner, board *model.Board) (map[string][]string, error) {
|
|
query := s.getQueryBuilder(tx).
|
|
Select("tm.UserId", "tm.TeamId").
|
|
From("ChannelMembers cm").
|
|
Join("TeamMembers tm ON cm.UserId = tm.UserId").
|
|
Join("Teams t ON tm.TeamId = t.Id").
|
|
Where(sq.Eq{
|
|
"cm.ChannelId": board.ChannelID,
|
|
"t.DeleteAt": 0,
|
|
"tm.DeleteAt": 0,
|
|
})
|
|
|
|
rows, err := query.Query()
|
|
if err != nil {
|
|
s.logger.Error("failed to fetch user teams for board", mlog.String("boardID", board.ID), mlog.String("channelID", board.ChannelID), mlog.Err(err))
|
|
return nil, err
|
|
}
|
|
|
|
defer rows.Close()
|
|
|
|
userTeams := map[string][]string{}
|
|
|
|
for rows.Next() {
|
|
var userID, teamID string
|
|
err := rows.Scan(&userID, &teamID)
|
|
if err != nil {
|
|
s.logger.Error("getBoardUserTeams failed to scan SQL query result", mlog.String("boardID", board.ID), mlog.String("channelID", board.ChannelID), mlog.Err(err))
|
|
return nil, err
|
|
}
|
|
|
|
userTeams[userID] = append(userTeams[userID], teamID)
|
|
}
|
|
|
|
return userTeams, nil
|
|
}
|
|
|
|
func (s *SQLStore) RunDeletedMembershipBoardsMigration() error {
|
|
if !s.isPlugin {
|
|
return nil
|
|
}
|
|
|
|
setting, err := s.GetSystemSetting(DeletedMembershipBoardsMigrationKey)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot get deleted membership boards migration state: %w", err)
|
|
}
|
|
|
|
// If the migration is already completed, do not run it again.
|
|
if hasAlreadyRun, _ := strconv.ParseBool(setting); hasAlreadyRun {
|
|
return nil
|
|
}
|
|
|
|
boards, err := s.getDeletedMembershipBoards(s.db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(boards) == 0 {
|
|
s.logger.Debug("No boards with owner not anymore on their team found, marking runDeletedMembershipBoardsMigration as done")
|
|
if sErr := s.SetSystemSetting(DeletedMembershipBoardsMigrationKey, strconv.FormatBool(true)); sErr != nil {
|
|
return fmt.Errorf("cannot mark migration as completed: %w", sErr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
s.logger.Debug("Migrating boards with owner not anymore on their team", mlog.Int("count", len(boards)))
|
|
|
|
tx, err := s.db.BeginTx(context.Background(), nil)
|
|
if err != nil {
|
|
s.logger.Error("error starting transaction in runDeletedMembershipBoardsMigration", mlog.Err(err))
|
|
return err
|
|
}
|
|
|
|
for i := range boards {
|
|
teamID, err := s.getBestTeamForBoard(s.db, boards[i])
|
|
if err != nil {
|
|
// don't let one board's error spoil
|
|
// the mood for others
|
|
s.logger.Error("could not find the best team for board during deleted membership boards migration. Continuing", mlog.String("boardID", boards[i].ID))
|
|
continue
|
|
}
|
|
|
|
boards[i].TeamID = teamID
|
|
|
|
query := s.getQueryBuilder(tx).
|
|
Update(s.tablePrefix+"boards").
|
|
Set("team_id", teamID).
|
|
Where(sq.Eq{"id": boards[i].ID})
|
|
|
|
if _, err := query.Exec(); err != nil {
|
|
s.logger.Error("failed to set team id for board", mlog.String("board_id", boards[i].ID), mlog.String("team_id", teamID), mlog.Err(err))
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := s.setSystemSetting(tx, DeletedMembershipBoardsMigrationKey, strconv.FormatBool(true)); err != nil {
|
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
|
s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "runDeletedMembershipBoardsMigration"))
|
|
}
|
|
return fmt.Errorf("cannot mark migration as completed: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
s.logger.Error("failed to commit runDeletedMembershipBoardsMigration transaction", mlog.Err(err))
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getDeletedMembershipBoards retrieves those boards whose creator is
|
|
// associated to the board's team with a deleted team membership.
|
|
func (s *SQLStore) getDeletedMembershipBoards(tx sq.BaseRunner) ([]*model.Board, error) {
|
|
rows, err := s.getQueryBuilder(tx).
|
|
Select(legacyBoardFields("b.")...).
|
|
From(s.tablePrefix + "boards b").
|
|
Join("TeamMembers tm ON b.created_by = tm.UserId").
|
|
Where("b.team_id = tm.TeamId").
|
|
Where(sq.NotEq{"tm.DeleteAt": 0}).
|
|
Query()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer s.CloseRows(rows)
|
|
|
|
boards, err := s.boardsFromRows(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return boards, err
|
|
}
|
|
|
|
func (s *SQLStore) RunFixCollationsAndCharsetsMigration() error {
|
|
// This is for MySQL only
|
|
if s.dbType != model.MysqlDBType {
|
|
return nil
|
|
}
|
|
|
|
// get collation and charSet setting that Channels is using.
|
|
// when personal server or unit testing, no channels tables exist so just set to a default.
|
|
var collation string
|
|
var charSet string
|
|
var err error
|
|
if !s.isPlugin || os.Getenv("FOCALBOARD_UNIT_TESTING") == "1" {
|
|
collation = "utf8mb4_general_ci"
|
|
charSet = "utf8mb4"
|
|
} else {
|
|
collation, charSet, err = s.getCollationAndCharset("Channels")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// get all FocalBoard tables
|
|
tableNames, err := s.getFocalBoardTableNames()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
merr := merror.New()
|
|
|
|
// alter each table if there is a collation or charset mismatch
|
|
for _, name := range tableNames {
|
|
tableCollation, tableCharSet, err := s.getCollationAndCharset(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if collation == tableCollation && charSet == tableCharSet {
|
|
// nothing to do
|
|
continue
|
|
}
|
|
|
|
s.logger.Warn(
|
|
"found collation/charset mismatch, fixing table",
|
|
mlog.String("tableName", name),
|
|
mlog.String("tableCollation", tableCollation),
|
|
mlog.String("tableCharSet", tableCharSet),
|
|
mlog.String("collation", collation),
|
|
mlog.String("charSet", charSet),
|
|
)
|
|
|
|
sql := fmt.Sprintf("ALTER TABLE %s CONVERT TO CHARACTER SET '%s' COLLATE '%s'", name, charSet, collation)
|
|
result, err := s.db.Exec(sql)
|
|
if err != nil {
|
|
merr.Append(err)
|
|
continue
|
|
}
|
|
num, err := result.RowsAffected()
|
|
if err != nil {
|
|
merr.Append(err)
|
|
}
|
|
if num > 0 {
|
|
s.logger.Debug("table collation and/or charSet fixed",
|
|
mlog.String("table_name", name),
|
|
)
|
|
}
|
|
}
|
|
return merr.ErrorOrNil()
|
|
}
|
|
|
|
func (s *SQLStore) getFocalBoardTableNames() ([]string, error) {
|
|
if s.dbType != model.MysqlDBType {
|
|
return nil, newErrInvalidDBType("getFocalBoardTableNames requires MySQL")
|
|
}
|
|
|
|
query := s.getQueryBuilder(s.db).
|
|
Select("table_name").
|
|
From("information_schema.tables").
|
|
Where(sq.Like{"table_name": s.tablePrefix + "%"}).
|
|
Where("table_schema=(SELECT DATABASE())")
|
|
|
|
rows, err := query.Query()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error fetching FocalBoard table names: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
names := make([]string, 0)
|
|
|
|
for rows.Next() {
|
|
var tableName string
|
|
|
|
err := rows.Scan(&tableName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot scan result while fetching table names: %w", err)
|
|
}
|
|
|
|
names = append(names, tableName)
|
|
}
|
|
|
|
return names, nil
|
|
}
|
|
|
|
func (s *SQLStore) getCollationAndCharset(tableName string) (string, string, error) {
|
|
if s.dbType != model.MysqlDBType {
|
|
return "", "", newErrInvalidDBType("getCollationAndCharset requires MySQL")
|
|
}
|
|
|
|
query := s.getQueryBuilder(s.db).
|
|
Select("table_collation").
|
|
From("information_schema.tables").
|
|
Where(sq.Eq{"table_name": tableName}).
|
|
Where("table_schema=(SELECT DATABASE())")
|
|
|
|
row := query.QueryRow()
|
|
|
|
var collation string
|
|
err := row.Scan(&collation)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("error fetching collation for table %s: %w", tableName, err)
|
|
}
|
|
|
|
// obtains the charset from the first column that has it set
|
|
query = s.getQueryBuilder(s.db).
|
|
Select("CHARACTER_SET_NAME").
|
|
From("information_schema.columns").
|
|
Where(sq.Eq{
|
|
"table_name": tableName,
|
|
}).
|
|
Where("table_schema=(SELECT DATABASE())").
|
|
Where(sq.NotEq{"CHARACTER_SET_NAME": "NULL"}).
|
|
Limit(1)
|
|
|
|
row = query.QueryRow()
|
|
|
|
var charSet string
|
|
err = row.Scan(&charSet)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("error fetching charSet: %w", err)
|
|
}
|
|
|
|
return collation, charSet, nil
|
|
}
|
|
|
|
func (s *SQLStore) RunDeDuplicateCategoryBoardsMigration(currentMigration int) error {
|
|
// not supported for SQLite
|
|
if s.dbType == model.SqliteDBType {
|
|
if mErr := s.setSystemSetting(s.db, DeDuplicateCategoryBoardTableMigrationKey, strconv.FormatBool(true)); mErr != nil {
|
|
return fmt.Errorf("cannot mark migration %s as completed: %w", "RunDeDuplicateCategoryBoardsMigration", mErr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
setting, err := s.GetSystemSetting(DeDuplicateCategoryBoardTableMigrationKey)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot get DeDuplicateCategoryBoardTableMigration state: %w", err)
|
|
}
|
|
|
|
// If the migration is already completed, do not run it again.
|
|
if hasAlreadyRun, _ := strconv.ParseBool(setting); hasAlreadyRun {
|
|
return nil
|
|
}
|
|
|
|
if currentMigration >= (deDuplicateCategoryBoards + 1) {
|
|
// if the migration for which we're fixing the data is already applied,
|
|
// no need to check fix anything
|
|
|
|
if mErr := s.setSystemSetting(s.db, DeDuplicateCategoryBoardTableMigrationKey, strconv.FormatBool(true)); mErr != nil {
|
|
return fmt.Errorf("cannot mark migration %s as completed: %w", "RunDeDuplicateCategoryBoardsMigration", mErr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
needed, err := s.doesDuplicateCategoryBoardsExist()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !needed {
|
|
if mErr := s.setSystemSetting(s.db, DeDuplicateCategoryBoardTableMigrationKey, strconv.FormatBool(true)); mErr != nil {
|
|
return fmt.Errorf("cannot mark migration %s as completed: %w", "RunDeDuplicateCategoryBoardsMigration", mErr)
|
|
}
|
|
}
|
|
|
|
if s.dbType == model.MysqlDBType {
|
|
return s.runMySQLDeDuplicateCategoryBoardsMigration()
|
|
} else if s.dbType == model.PostgresDBType {
|
|
return s.runPostgresDeDuplicateCategoryBoardsMigration()
|
|
}
|
|
|
|
if mErr := s.setSystemSetting(s.db, DeDuplicateCategoryBoardTableMigrationKey, strconv.FormatBool(true)); mErr != nil {
|
|
return fmt.Errorf("cannot mark migration %s as completed: %w", "RunDeDuplicateCategoryBoardsMigration", mErr)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLStore) doesDuplicateCategoryBoardsExist() (bool, error) {
|
|
subQuery := s.getQueryBuilder(s.db).
|
|
Select("user_id", "board_id", "count(*) AS count").
|
|
From(s.tablePrefix+"category_boards").
|
|
GroupBy("user_id", "board_id").
|
|
Having("count(*) > 1")
|
|
|
|
query := s.getQueryBuilder(s.db).
|
|
Select("COUNT(user_id)").
|
|
FromSelect(subQuery, "duplicate_dataset")
|
|
|
|
row := query.QueryRow()
|
|
|
|
count := 0
|
|
if err := row.Scan(&count); err != nil {
|
|
s.logger.Error("Error occurred reading number of duplicate records in category_boards table", mlog.Err(err))
|
|
return false, err
|
|
}
|
|
|
|
return count > 0, nil
|
|
}
|
|
|
|
func (s *SQLStore) runMySQLDeDuplicateCategoryBoardsMigration() error {
|
|
query := "DELETE FROM " + s.tablePrefix + "category_boards WHERE id NOT IN " +
|
|
"(SELECT * FROM ( SELECT min(id) FROM " + s.tablePrefix + "category_boards GROUP BY user_id, board_id ) as data)"
|
|
if _, err := s.db.Exec(query); err != nil {
|
|
s.logger.Error("Failed to de-duplicate data in category_boards table", mlog.Err(err))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLStore) runPostgresDeDuplicateCategoryBoardsMigration() error {
|
|
query := "WITH duplicates AS (SELECT id, ROW_NUMBER() OVER(PARTITION BY user_id, board_id) AS rownum " +
|
|
"FROM " + s.tablePrefix + "category_boards) " +
|
|
"DELETE FROM " + s.tablePrefix + "category_boards USING duplicates " +
|
|
"WHERE " + s.tablePrefix + "category_boards.id = duplicates.id AND duplicates.rownum > 1;"
|
|
if _, err := s.db.Exec(query); err != nil {
|
|
s.logger.Error("Failed to de-duplicate data in category_boards table", mlog.Err(err))
|
|
}
|
|
|
|
return nil
|
|
}
|