From a8b950990177f396e10f37f326913e0b5f528d8a Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Wed, 23 Sep 2026 00:08:23 +0200 Subject: [PATCH] Keep SQLite GUID defaults consistent with inserted foreign keys --- docs/migration-guide-12.1-to-13.md | 18 ++++- src/Migrator.Tests/SQLiteGuidDefaultTests.cs | 77 +++++++++++++++++++ .../Providers/Impl/SQLite/SQLiteDialect.cs | 3 + .../SQLite/SQLiteTransformationProvider.cs | 5 +- 4 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 src/Migrator.Tests/SQLiteGuidDefaultTests.cs diff --git a/docs/migration-guide-12.1-to-13.md b/docs/migration-guide-12.1-to-13.md index 95ae2cf0..9512bd38 100644 --- a/docs/migration-guide-12.1-to-13.md +++ b/docs/migration-guide-12.1-to-13.md @@ -203,7 +203,23 @@ version does not insert it twice. History for another scope does not skip a step in the current scope. Transaction rollback still applies to baseline schema and history changes according to the selected transaction mode. -## Adding SQLite identity to an existing table +## SQLite defaults and identity + +### SQLite GUID defaults + +SQLite now renders a CLR `Guid` default as a blob using `Guid.ToByteArray()`, +matching GUID parameters inserted by the provider. Previously a GUID default +was text, so a defaulted foreign-key value did not match an explicitly inserted +parent GUID even when both represented the same identifier. + +This fixes new table/column definitions, including backfilling a new column. +Existing text GUID defaults and data are preserved during unrelated rebuilds; +column inspection retains their SQL as `RawSql` so storage classes are not +silently converted. Databases already containing mixed text/blob GUIDs require +an explicit data migration that converts related keys consistently. A string +default remains text; use a CLR `Guid` when authoring a GUID default. + +### SQLite identity columns SQLite requires the identity column and its single-column primary key in the same table definition. Separate `AddColumn` and `AddPrimaryKey` calls create an diff --git a/src/Migrator.Tests/SQLiteGuidDefaultTests.cs b/src/Migrator.Tests/SQLiteGuidDefaultTests.cs new file mode 100644 index 00000000..dd1456d4 --- /dev/null +++ b/src/Migrator.Tests/SQLiteGuidDefaultTests.cs @@ -0,0 +1,77 @@ +using System; +using System.Data; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using DotNetProjects.Migrator.Providers.Impl.SQLite; +using NUnit.Framework; +using ForeignKeyConstraint = DotNetProjects.Migrator.Framework.ForeignKeyConstraint; + +namespace Migrator.Tests; + +[Category("SQLite")] +public class SQLiteGuidDefaultTests +{ + private static IDbConnection OpenConnection(bool systemData) + { + IDbConnection connection = systemData + ? new System.Data.SQLite.SQLiteConnection("Data Source=:memory:") + : new Microsoft.Data.Sqlite.SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + [TestCase(false, "00000000-0000-0000-0000-000000000000")] + [TestCase(true, "00000000-0000-0000-0000-000000000000")] + [TestCase(false, "00112233-4455-6677-8899-aabbccddeeff")] + [TestCase(true, "00112233-4455-6677-8899-aabbccddeeff")] + public void GuidDefaultsMatchInsertedKeysBeforeAndAfterRebuild(bool systemData, string value) + { + using var connection = OpenConnection(systemData); + using var provider = (SQLiteTransformationProvider)ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + var id = Guid.Parse(value); + provider.AddTable("Parents", new Column("Id", DbType.Guid), new PrimaryKeyConstraint("PK_Parents", "Id")); + provider.Insert("Parents", ["Id"], [id]); + provider.AddTable("Children", new Column("Id", DbType.Int32), + new Column("ParentId", DbType.Guid) { DefaultValue = id }, + new ForeignKeyConstraint("FK_Children_Parents", "Parents", ["Id"], "Children", ["ParentId"])); + provider.Insert("Children", ["Id"], [1]); + Assert.That(provider.CheckForeignKeyIntegrity(), Is.True); + Assert.That(provider.ExecuteScalar("SELECT hex(ParentId) FROM Children"), Is.EqualTo(Convert.ToHexString(id.ToByteArray()))); + provider.AddColumn("Children", new Column("Extra", DbType.Int32)); + provider.Insert("Children", ["Id"], [2]); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM Children JOIN Parents ON Children.ParentId = Parents.Id"), Is.EqualTo(2)); + Assert.That(provider.CheckForeignKeyIntegrity(), Is.True); + } + + [TestCase(false)] + [TestCase(true)] + public void AddingGuidColumnBackfillsTheSameRepresentationAsParameters(bool systemData) + { + using var connection = OpenConnection(systemData); + using var provider = (SQLiteTransformationProvider)ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + var id = Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"); + provider.AddTable("Parents", new Column("Id", DbType.Guid), new PrimaryKeyConstraint("PK_Parents", "Id")); + provider.Insert("Parents", ["Id"], [id]); + provider.AddTable("Children", new Column("Id", DbType.Int32)); + provider.Insert("Children", ["Id"], [1]); + provider.AddColumn("Children", new Column("ParentId", DbType.Guid) { DefaultValue = id, IsNullable = false }); + provider.AddForeignKey("FK_Children_Parents", "Children", "ParentId", "Parents", "Id"); + Assert.That(provider.CheckForeignKeyIntegrity(), Is.True); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM Children JOIN Parents ON Children.ParentId = Parents.Id"), Is.EqualTo(1)); + } + + [TestCase(false)] + [TestCase(true)] + public void RebuildingLegacyTextGuidDefaultsPreservesTheirStorageRepresentation(bool systemData) + { + using var connection = OpenConnection(systemData); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + const string value = "00112233-4455-6677-8899-aabbccddeeff"; + provider.ExecuteNonQuery("CREATE TABLE Legacy (Id INTEGER, Token UNIQUEIDENTIFIER DEFAULT '" + value + "')"); + provider.Insert("Legacy", ["Id"], [1]); + provider.AddColumn("Legacy", new Column("Extra", DbType.Int32)); + provider.Insert("Legacy", ["Id"], [2]); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM Legacy WHERE typeof(Token) = 'text' AND Token = '" + value + "'"), Is.EqualTo(2)); + } +} diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteDialect.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteDialect.cs index 63fe8266..26cab38b 100644 --- a/src/Migrator/Providers/Impl/SQLite/SQLiteDialect.cs +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteDialect.cs @@ -1,3 +1,4 @@ +using System; using System.Data; using DotNetProjects.Migrator.Framework; @@ -66,6 +67,8 @@ public SQLiteDialect() public override string Default(object defaultValue) { if (defaultValue is RawSql expression) return "DEFAULT (" + expression.Sql + ")"; + // Match SQLiteTransformationProvider's GUID parameter representation, including byte order. + if (defaultValue is Guid guid) return "DEFAULT X'" + Convert.ToHexString(guid.ToByteArray()) + "'"; if (defaultValue is bool) { return string.Format("DEFAULT {0}", (bool)defaultValue ? "1" : "0"); diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs index c5d64db5..1bdebf18 100644 --- a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs @@ -1137,8 +1137,11 @@ public override Column[] GetColumns(string tableName) var defValue = pragmaTableInfoItem.DfltValue == DBNull.Value ? null : pragmaTableInfoItem.DfltValue; + // Keep legacy text GUID defaults as text during unrelated rebuilds. New Guid + // values render as blobs, so parsing an old SQL literal into Guid would change its storage class. column.DefaultValue = defValue is string sqlDefault - ? CatalogDefaultValue.Parse(sqlDefault, column.Type) : defValue; + ? column.Type == DbType.Guid ? RawSql.Insert(sqlDefault) : CatalogDefaultValue.Parse(sqlDefault, column.Type) + : defValue; var columnTableInfoItem = pragmaTableInfoItems.First(x => x.Name.Equals(column.Name, StringComparison.OrdinalIgnoreCase));