feat(tasks): enforce unique (project_id, index) via migration

This commit is contained in:
kolaente 2026-04-11 01:43:00 +02:00 committed by kolaente
parent 8f9b50bdcb
commit 9206f98d64
5 changed files with 147 additions and 5 deletions

View File

@ -221,7 +221,7 @@
done: false
created_by_id: 1
project_id: 1
index: 12
index: 18
created: 2018-12-01 01:12:04
updated: 2018-12-01 01:12:04
start_date: 2018-11-30 22:25:24

View File

@ -0,0 +1,127 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// 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 <https://www.gnu.org/licenses/>.
package migration
import (
"fmt"
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
"xorm.io/xorm/schemas"
)
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20260411013328",
Description: "add unique constraint on (project_id, index) for tasks",
Migrate: func(tx *xorm.Engine) error {
// `index` is MySQL-reserved; Postgres rejects backticks, MySQL
// rejects double quotes — pick the quote char at runtime.
idx := `"index"`
if tx.Dialect().URI().DBType == schemas.MYSQL {
idx = "`index`"
}
// Heal pre-existing duplicates before adding the constraint —
// setNewTaskIndex's Go-level guard isn't race-safe. Wrapped in a
// session transaction so a mid-way failure can't leave rows
// partially re-indexed without the constraint in place.
s := tx.NewSession()
defer s.Close()
if err := s.Begin(); err != nil {
return err
}
type dupRow struct {
ProjectID int64 `xorm:"project_id"`
Index int64 `xorm:"'index'"`
}
var dupes []dupRow
err := s.SQL(`
SELECT project_id, ` + idx + ` FROM tasks
GROUP BY project_id, ` + idx + `
HAVING COUNT(*) > 1
`).Find(&dupes)
if err != nil {
_ = s.Rollback()
return fmt.Errorf("failed to scan duplicate task indexes: %w", err)
}
for _, d := range dupes {
// Keep rows[0] at its current index, push the rest past max(index).
type taskRow struct {
ID int64
}
var rows []taskRow
err := s.SQL(
"SELECT id FROM tasks WHERE project_id = ? AND "+idx+" = ? ORDER BY id ASC",
d.ProjectID, d.Index,
).Find(&rows)
if err != nil {
_ = s.Rollback()
return err
}
if len(rows) < 2 {
continue
}
var maxIdx struct {
M int64 `xorm:"m"`
}
_, err = s.SQL(
"SELECT COALESCE(MAX("+idx+"), 0) AS m FROM tasks WHERE project_id = ?",
d.ProjectID,
).Get(&maxIdx)
if err != nil {
_ = s.Rollback()
return err
}
for i := 1; i < len(rows); i++ {
maxIdx.M++
_, err = s.Exec(
"UPDATE tasks SET "+idx+" = ? WHERE id = ?",
maxIdx.M, rows[i].ID,
)
if err != nil {
_ = s.Rollback()
return err
}
}
}
if err := s.Commit(); err != nil {
return err
}
// MySQL lacks IF NOT EXISTS on CREATE INDEX.
var query string
switch tx.Dialect().URI().DBType {
case schemas.MYSQL:
query = "CREATE UNIQUE INDEX UQE_tasks_project_index ON tasks (project_id, " + idx + ")"
default:
query = "CREATE UNIQUE INDEX IF NOT EXISTS UQE_tasks_project_index ON tasks (project_id, " + idx + ")"
}
_, err = tx.Exec(query)
return err
},
Rollback: func(_ *xorm.Engine) error {
return nil
},
})
}

View File

@ -472,8 +472,8 @@ func TestTaskCollection_ReadAll(t *testing.T) {
task27 := &Task{
ID: 27,
Title: "task #27 with reminders and start_date",
Identifier: "test1-12",
Index: 12,
Identifier: "test1-18",
Index: 18,
CreatedByID: 1,
CreatedBy: user1,
Reminders: []*TaskReminder{

View File

@ -76,7 +76,7 @@ type Task struct {
// An array of reminders that are associated with this task.
Reminders []*TaskReminder `xorm:"-" json:"reminders"`
// The project this task belongs to.
ProjectID int64 `xorm:"bigint INDEX not null" json:"project_id" param:"project"`
ProjectID int64 `xorm:"bigint INDEX not null unique(tasks_project_index)" json:"project_id" param:"project"`
// An amount in seconds this task repeats itself. If this is set, when marking the task as done, it will mark itself as "undone" and then increase all remindes and the due date by its amount.
RepeatAfter int64 `xorm:"bigint INDEX null" json:"repeat_after" valid:"range(0|9223372036854775807)"`
// Can have three possible values which will trigger when the task is marked as done: 0 = repeats after the amount specified in repeat_after, 1 = repeats all dates each months (ignoring repeat_after), 3 = repeats from the current date rather than the last set date.
@ -99,7 +99,7 @@ type Task struct {
// The task identifier, based on the project identifier and the task's index
Identifier string `xorm:"-" json:"identifier"`
// The task index, calculated per project
Index int64 `xorm:"bigint not null default 0" json:"index" param:"index"`
Index int64 `xorm:"bigint not null default 0 unique(tasks_project_index)" json:"index" param:"index"`
// The UID is currently not used for anything other than CalDAV, which is why we don't expose it over json
UID string `xorm:"varchar(250) null" json:"-"`

View File

@ -1363,3 +1363,18 @@ func TestGetTaskByProjectAndIndex(t *testing.T) {
assert.True(t, IsErrTaskDoesNotExist(err))
})
}
func TestTaskIndexUniqueConstraint(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// (project_id=1, index=1) is already taken by task 1 in fixtures.
_, err := s.Insert(&Task{
Title: "duplicate index",
ProjectID: 1,
Index: 1,
CreatedByID: 1,
})
require.Error(t, err, "unique constraint on (project_id, index) must reject duplicates")
}