diff --git a/pkg/db/fixtures/tasks.yml b/pkg/db/fixtures/tasks.yml index 741f87802..66fd92778 100644 --- a/pkg/db/fixtures/tasks.yml +++ b/pkg/db/fixtures/tasks.yml @@ -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 diff --git a/pkg/migration/20260411013328.go b/pkg/migration/20260411013328.go new file mode 100644 index 000000000..9c9f49b84 --- /dev/null +++ b/pkg/migration/20260411013328.go @@ -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 . + +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 + }, + }) +} diff --git a/pkg/models/task_collection_test.go b/pkg/models/task_collection_test.go index 9b6c83c0c..07f7c26d1 100644 --- a/pkg/models/task_collection_test.go +++ b/pkg/models/task_collection_test.go @@ -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{ diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 42aa602df..b7515b40a 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -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:"-"` diff --git a/pkg/models/tasks_test.go b/pkg/models/tasks_test.go index 375377b04..caa897740 100644 --- a/pkg/models/tasks_test.go +++ b/pkg/models/tasks_test.go @@ -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") +}