From c2f130d3527903bdea638f177b1b32ff7fae9763 Mon Sep 17 00:00:00 2001 From: bircni Date: Thu, 25 Jun 2026 14:38:39 +0200 Subject: [PATCH] fix(mssql): convert legacy DATETIME columns to DATETIME2 (#38216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem On MSSQL databases created by old Gitea versions, the real datetime columns `external_login_user.expires_at` and `lfs_lock.created` were created as `DATETIME`. `DATETIME` parses datetime literals in a locale-dependent way, so the ISO string `'YYYY-MM-DD HH:MM:SS'` that xorm sends fails to convert when the session language is not English (e.g. German defaults to `dmy`): ``` mssql: Bei der Konvertierung eines nvarchar-Datentyps in einen datetime-Datentyp liegt der Wert außerhalb des gültigen Bereichs. ``` This breaks linking an external (OAuth/Keycloak) account to an existing user, and LFS lock creation, with a 500 error. ## Fix Current xorm already maps `time.Time` to the locale-independent `DATETIME2` for new installs, so only legacy databases are affected. This adds migration `341` that converts these columns to `DATETIME2` on legacy MSSQL databases (no-op on other databases and on columns already using `DATETIME2`). A full audit of persisted `time.Time` columns in `models/` confirmed these two are the only real datetime columns affected — every other time value is stored as a unix-timestamp integer. A regression test (MSSQL-only, mirroring the existing v338 pattern) downgrades the columns to legacy `DATETIME`, runs the migration, asserts the type becomes `DATETIME2`, and verifies an ISO datetime insert succeeds under `SET LANGUAGE German`. Fixes #38211 --- models/migrations/migrations.go | 1 + models/migrations/v1_27/v341.go | 80 ++++++++++++++++++++++++++++ models/migrations/v1_27/v341_test.go | 80 ++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 models/migrations/v1_27/v341.go create mode 100644 models/migrations/v1_27/v341_test.go diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 006016f447..ab580710f3 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -418,6 +418,7 @@ func prepareMigrationTasks() []*migration { newMigration(338, "Expand legacy MSSQL issue/comment long-text columns", v1_27.ExpandIssueAndCommentLongTextFieldsForMSSQL), newMigration(339, "Extend action c_u index to include created_unix for faster dashboard feed queries", v1_27.AddCreatedUnixToActionUserIsDeletedIndex), newMigration(340, "Add ContinueOnError column to ActionRunJob", v1_27.AddContinueOnErrorToActionRunJob), + newMigration(341, "Convert legacy MSSQL DATETIME columns to DATETIME2", v1_27.FixLegacyMSSQLDateTimeColumns), } return preparedMigrations } diff --git a/models/migrations/v1_27/v341.go b/models/migrations/v1_27/v341.go new file mode 100644 index 0000000000..8630cb97fa --- /dev/null +++ b/models/migrations/v1_27/v341.go @@ -0,0 +1,80 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "fmt" + "strings" + "time" + + "gitea.dev/models/db" + "gitea.dev/models/migrations/base" + + "xorm.io/xorm/schemas" +) + +// legacyDateTimeColumns are the persisted real datetime columns that old Gitea +// versions created as MSSQL DATETIME. Every other time value is stored as a +// unix timestamp integer, so these are the only columns affected. +var legacyDateTimeColumns = []struct { + bean any + column string +}{ + {new(externalLoginUserWithExpiresAt), "expires_at"}, + {new(lfsLockWithCreated), "created"}, +} + +type externalLoginUserWithExpiresAt struct { + ExpiresAt time.Time +} + +func (externalLoginUserWithExpiresAt) TableName() string { + return "external_login_user" +} + +type lfsLockWithCreated struct { + Created time.Time `xorm:"created"` +} + +func (lfsLockWithCreated) TableName() string { + return "lfs_lock" +} + +// FixLegacyMSSQLDateTimeColumns converts legacy locale-dependent DATETIME columns +// to DATETIME2. Databases created by old Gitea versions stored these columns as +// DATETIME, which fails to parse ISO datetime strings ('YYYY-MM-DD HH:MM:SS') +// when the MSSQL session language is not English, breaking external account +// linking and LFS lock creation. New installs already use DATETIME2, so only +// legacy MSSQL columns need converting. +func FixLegacyMSSQLDateTimeColumns(x db.EngineMigration) error { + if x.Dialect().URI().DBType != schemas.MSSQL { + return nil + } + + for _, c := range legacyDateTimeColumns { + table, err := x.TableInfo(c.bean) + if err != nil { + return err + } + + var dataType string + has, err := x.SQL("SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ? AND COLUMN_NAME = ?", table.Name, c.column).Get(&dataType) + if err != nil { + return err + } + if !has || !strings.EqualFold(dataType, "datetime") { + continue + } + + column := table.GetColumn(c.column) + if column == nil { + return fmt.Errorf("column %s does not exist in table %s", c.column, table.Name) + } + if err := base.ModifyColumn(x, table.Name, column); err != nil { + return fmt.Errorf("modify %s.%s: %w", table.Name, c.column, err) + } + } + + return nil +} diff --git a/models/migrations/v1_27/v341_test.go b/models/migrations/v1_27/v341_test.go new file mode 100644 index 0000000000..b0fe6d24df --- /dev/null +++ b/models/migrations/v1_27/v341_test.go @@ -0,0 +1,80 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "testing" + "time" + + "gitea.dev/models/db" + "gitea.dev/models/migrations/migrationtest" + "gitea.dev/modules/setting" + + "github.com/stretchr/testify/require" +) + +type externalLoginUserBeforeDateTimeMigration struct { + ExternalID string `xorm:"pk NOT NULL"` + LoginSourceID int64 `xorm:"pk NOT NULL"` + ExpiresAt time.Time // sync creates DATETIME2; downgraded to legacy DATETIME via raw SQL below +} + +func (externalLoginUserBeforeDateTimeMigration) TableName() string { + return "external_login_user" +} + +type lfsLockBeforeDateTimeMigration struct { + ID int64 `xorm:"pk autoincr"` + Created time.Time `xorm:"created"` +} + +func (lfsLockBeforeDateTimeMigration) TableName() string { + return "lfs_lock" +} + +func Test_FixLegacyMSSQLDateTimeColumns(t *testing.T) { + if !setting.Database.Type.IsMSSQL() { + t.Skip("Only MSSQL needs to convert the legacy locale-dependent DATETIME columns") + } + + x, deferrable := migrationtest.PrepareTestEnv(t, 0, + new(externalLoginUserBeforeDateTimeMigration), + new(lfsLockBeforeDateTimeMigration), + ) + defer deferrable() + + // Force the legacy DATETIME column type that old Gitea versions created. + _, err := x.Exec("ALTER TABLE [external_login_user] ALTER COLUMN [expires_at] DATETIME") + require.NoError(t, err) + _, err = x.Exec("ALTER TABLE [lfs_lock] ALTER COLUMN [created] DATETIME") + require.NoError(t, err) + require.Equal(t, "datetime", mssqlColumnType(t, x, "external_login_user", "expires_at")) + require.Equal(t, "datetime", mssqlColumnType(t, x, "lfs_lock", "created")) + + require.NoError(t, FixLegacyMSSQLDateTimeColumns(x)) + require.NoError(t, FixLegacyMSSQLDateTimeColumns(x)) // idempotent + + require.Equal(t, "datetime2", mssqlColumnType(t, x, "external_login_user", "expires_at")) + require.Equal(t, "datetime2", mssqlColumnType(t, x, "lfs_lock", "created")) + + // Inserting an ISO-formatted datetime must succeed even under a non-English + // locale, which is the failure the legacy DATETIME columns produced. The + // SET LANGUAGE and INSERT run in one Exec so they share a single connection. + _, err = x.Exec("SET LANGUAGE German; " + + "INSERT INTO [external_login_user] ([external_id], [login_source_id], [expires_at]) " + + "VALUES ('ext-id', 1, '2026-06-25 11:58:39')") + require.NoError(t, err) + _, err = x.Exec("SET LANGUAGE German; " + + "INSERT INTO [lfs_lock] ([created]) VALUES ('2026-06-25 11:58:39')") + require.NoError(t, err) +} + +func mssqlColumnType(t *testing.T, x db.EngineMigration, table, column string) string { + t.Helper() + var dataType string + has, err := x.SQL("SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ? AND COLUMN_NAME = ?", table, column).Get(&dataType) + require.NoError(t, err) + require.True(t, has) + return dataType +}