fix(mssql): convert legacy DATETIME columns to DATETIME2 (#38216)

## 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
This commit is contained in:
bircni
2026-06-25 14:38:39 +02:00
committed by GitHub
parent 2e1be0b114
commit c2f130d352
3 changed files with 161 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}