From c4f47fdd0d4a4c004282da42dcb5fd17d1ac78eb Mon Sep 17 00:00:00 2001 From: Marc Sallin Date: Tue, 16 Jun 2026 02:48:33 +0200 Subject: [PATCH] feat: support ON UPDATE CASCADE via CascadeOnUpdate attribute Entity Framework has no concept of update-cascade (it assumes immutable keys), so the generated DDL only ever emitted ON DELETE CASCADE. Neither ForeignKeyAttribute nor the fluent API can express ON UPDATE CASCADE. Add an opt-in CascadeOnUpdateAttribute placed on the dependent foreign key property. It is registered as a column annotation, survives the EF model build, and is read back from the store model during SQL generation to append the ON UPDATE CASCADE clause to the foreign key constraint. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 3 +- .../IntegrationTests/CascadeOnUpdateTest.cs | 125 ++++++++++++++++++ .../Statement/ForeignKeyStatementTest.cs | 31 +++++ .../Builder/ForeignKeyStatementBuilder.cs | 3 +- .../Internal/Statement/ForeignKeyStatement.cs | 6 + .../Internal/Utility/SqliteAssociationType.cs | 9 ++ .../Attributes/CascadeOnUpdateAttribute.cs | 28 ++++ .../DbInitializers/SqliteInitializerBase.cs | 1 + 8 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 SQLite.CodeFirst.Test/IntegrationTests/CascadeOnUpdateTest.cs create mode 100644 SQLite.CodeFirst/Public/Attributes/CascadeOnUpdateAttribute.cs diff --git a/README.md b/README.md index 9bb4fef..a6b9367 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ The following features are supported: - Tables from classes (supported annotations: `Table`) - Columns from properties (supported annotations: `Column`, `Key`, `MaxLength`, `Required`, `NotMapped`, `DatabaseGenerated`, `Index`) - PrimaryKey constraint (`Key` annotation, key composites are supported) -- ForeignKey constraint (1-n relationships, support for 'Cascade on delete') +- ForeignKey constraint (1-n relationships, support for 'Cascade on delete' and 'Cascade on update') - Not Null constraint - Auto increment (An int PrimaryKey will automatically be incremented and you can explicitly set the "AUTOINCREMENT" constraint to a PrimaryKey using the Autoincrement-Attribute) - Index (Decorate columns with the `Index` attribute. Indices are automatically created for foreign keys by default. To prevent this you can remove the convention `ForeignKeyIndexConvention`) @@ -36,6 +36,7 @@ The following features are supported: - Collate constraint (Decorate columns with the `CollateAttribute`, which is part of this library. Use `CollationFunction.Custom` to specify your own collation function.) - Default collation (pass an instance of Collation as constructor parameter for an initializer to specify a default collation). - SQL default value (Decorate columns with the `SqlDefaultValueAttribute`, which is part of this library) +- Cascade on update (Decorate the foreign key property with the `CascadeOnUpdateAttribute`, which is part of this library. Entity Framework cannot express `ON UPDATE CASCADE`, so this opt-in attribute must be placed on the dependent foreign key property, e.g. `TeamId`, not on the navigation property. It requires an explicit foreign key property.) ## Install diff --git a/SQLite.CodeFirst.Test/IntegrationTests/CascadeOnUpdateTest.cs b/SQLite.CodeFirst.Test/IntegrationTests/CascadeOnUpdateTest.cs new file mode 100644 index 0000000..1e7b45c --- /dev/null +++ b/SQLite.CodeFirst.Test/IntegrationTests/CascadeOnUpdateTest.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; +using System.Data.Common; +using System.Data.Entity; +using System.Data.Entity.Infrastructure; +using System.Data.SQLite; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace SQLite.CodeFirst.Test.IntegrationTests +{ + /// + /// Verifies that the placed on a foreign key property + /// results in 'ON UPDATE CASCADE' being emitted for the corresponding foreign key constraint. + /// This exercises the full pipeline: the attribute is registered as a column annotation, survives + /// the EF model build and is read back from the store model when the SQL is generated. + /// + [TestClass] + public class CascadeOnUpdateTest + { + private static string generatedSql; + + [TestMethod] + public void CascadeOnUpdateAttributeEmitsOnUpdateCascade() + { + using (DbConnection connection = new SQLiteConnection("FullUri=file::memory:")) + { + // This is important! Else the in memory database will not work. + connection.Open(); + + using (var context = new CascadeDbContext(connection)) + { + // Touching the set forces the initializer (and therefore the SQL generation) to run. + context.Set().FirstOrDefault(); + } + } + + // Decorated FK with cascade-on-delete: both keywords, delete before update. + StringAssert.Contains(generatedSql, "FOREIGN KEY ([CustomerId]) REFERENCES \"Customers\"([Id]) ON DELETE CASCADE ON UPDATE CASCADE"); + + // Decorated FK without cascade-on-delete: only the update keyword. + StringAssert.Contains(generatedSql, "FOREIGN KEY ([WarehouseId]) REFERENCES \"Warehouses\"([Id]) ON UPDATE CASCADE"); + + // Undecorated FK: no cascade keyword at all. + Assert.IsFalse(generatedSql.Contains("[RegionId]) REFERENCES \"Regions\"([Id]) ON"), + "The foreign key without the attribute must not emit any cascade clause."); + } + + private class Customer + { + public int Id { get; set; } + } + + private class Warehouse + { + public int Id { get; set; } + } + + private class Region + { + public int Id { get; set; } + } + + private class Order + { + public int Id { get; set; } + + [CascadeOnUpdate] + public int CustomerId { get; set; } + public Customer Customer { get; set; } + + [CascadeOnUpdate] + public int? WarehouseId { get; set; } + public Warehouse Warehouse { get; set; } + + public int? RegionId { get; set; } + public Region Region { get; set; } + } + + private class CascadeDbContext : DbContext + { + public CascadeDbContext(DbConnection connection) + : base(connection, false) + { + } + + protected override void OnModelCreating(DbModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasRequired(o => o.Customer) + .WithMany() + .HasForeignKey(o => o.CustomerId) + .WillCascadeOnDelete(true); + + modelBuilder.Entity() + .HasOptional(o => o.Warehouse) + .WithMany() + .HasForeignKey(o => o.WarehouseId) + .WillCascadeOnDelete(false); + + modelBuilder.Entity() + .HasOptional(o => o.Region) + .WithMany() + .HasForeignKey(o => o.RegionId) + .WillCascadeOnDelete(false); + + Database.SetInitializer(new CaptureInitializer(modelBuilder)); + } + } + + private class CaptureInitializer : SqliteInitializerBase + { + public CaptureInitializer(DbModelBuilder modelBuilder) + : base(modelBuilder) + { + } + + public override void InitializeDatabase(CascadeDbContext context) + { + DbModel model = ModelBuilder.Build(context.Database.Connection); + generatedSql = new SqliteSqlGenerator().Generate(model.StoreModel); + base.InitializeDatabase(context); + } + } + } +} diff --git a/SQLite.CodeFirst.Test/UnitTests/Statement/ForeignKeyStatementTest.cs b/SQLite.CodeFirst.Test/UnitTests/Statement/ForeignKeyStatementTest.cs index 0a8ac9f..65dd2d7 100644 --- a/SQLite.CodeFirst.Test/UnitTests/Statement/ForeignKeyStatementTest.cs +++ b/SQLite.CodeFirst.Test/UnitTests/Statement/ForeignKeyStatementTest.cs @@ -37,6 +37,37 @@ public void CreateStatementOneForeignKeyCascadeDeleteTest() Assert.AreEqual("FOREIGN KEY ([dummyForeignKey1]) REFERENCES dummyForeignTable([dummForeignPrimaryKey1]) ON DELETE CASCADE", output); } + [TestMethod] + public void CreateStatementOneForeignKeyCascadeUpdateTest() + { + var foreignKeyStatement = new ForeignKeyStatement + { + CascadeUpdate = true, + ForeignKey = new List { "dummyForeignKey1" }, + ForeignPrimaryKey = new List { "dummForeignPrimaryKey1" }, + ForeignTable = "dummyForeignTable" + }; + + string output = foreignKeyStatement.CreateStatement(); + Assert.AreEqual("FOREIGN KEY ([dummyForeignKey1]) REFERENCES dummyForeignTable([dummForeignPrimaryKey1]) ON UPDATE CASCADE", output); + } + + [TestMethod] + public void CreateStatementOneForeignKeyCascadeDeleteAndUpdateTest() + { + var foreignKeyStatement = new ForeignKeyStatement + { + CascadeDelete = true, + CascadeUpdate = true, + ForeignKey = new List { "dummyForeignKey1" }, + ForeignPrimaryKey = new List { "dummForeignPrimaryKey1" }, + ForeignTable = "dummyForeignTable" + }; + + string output = foreignKeyStatement.CreateStatement(); + Assert.AreEqual("FOREIGN KEY ([dummyForeignKey1]) REFERENCES dummyForeignTable([dummForeignPrimaryKey1]) ON DELETE CASCADE ON UPDATE CASCADE", output); + } + [TestMethod] public void CreateStatementTwoForeignKeyTest() { diff --git a/SQLite.CodeFirst/Internal/Builder/ForeignKeyStatementBuilder.cs b/SQLite.CodeFirst/Internal/Builder/ForeignKeyStatementBuilder.cs index e5fd81a..0dd595c 100644 --- a/SQLite.CodeFirst/Internal/Builder/ForeignKeyStatementBuilder.cs +++ b/SQLite.CodeFirst/Internal/Builder/ForeignKeyStatementBuilder.cs @@ -29,7 +29,8 @@ private IEnumerable GetForeignKeyStatements() ForeignKey = associationType.ForeignKey, ForeignTable = associationType.FromTableName, ForeignPrimaryKey = associationType.ForeignPrimaryKey, - CascadeDelete = associationType.CascadeDelete + CascadeDelete = associationType.CascadeDelete, + CascadeUpdate = associationType.CascadeUpdate }; } } diff --git a/SQLite.CodeFirst/Internal/Statement/ForeignKeyStatement.cs b/SQLite.CodeFirst/Internal/Statement/ForeignKeyStatement.cs index 58b4974..6b862a7 100644 --- a/SQLite.CodeFirst/Internal/Statement/ForeignKeyStatement.cs +++ b/SQLite.CodeFirst/Internal/Statement/ForeignKeyStatement.cs @@ -9,11 +9,13 @@ internal class ForeignKeyStatement : IStatement { private const string Template = "FOREIGN KEY ({foreign-key}) REFERENCES {referenced-table}({referenced-id})"; private const string CascadeDeleteStatement = "ON DELETE CASCADE"; + private const string CascadeUpdateStatement = "ON UPDATE CASCADE"; public IEnumerable ForeignKey { get; set; } public string ForeignTable { get; set; } public IEnumerable ForeignPrimaryKey { get; set; } public bool CascadeDelete { get; set; } + public bool CascadeUpdate { get; set; } public string CreateStatement() { @@ -25,6 +27,10 @@ public string CreateStatement() { sb.Append(" " + CascadeDeleteStatement); } + if (CascadeUpdate) + { + sb.Append(" " + CascadeUpdateStatement); + } return sb.ToString(); } diff --git a/SQLite.CodeFirst/Internal/Utility/SqliteAssociationType.cs b/SQLite.CodeFirst/Internal/Utility/SqliteAssociationType.cs index 6ab4f49..7532614 100644 --- a/SQLite.CodeFirst/Internal/Utility/SqliteAssociationType.cs +++ b/SQLite.CodeFirst/Internal/Utility/SqliteAssociationType.cs @@ -2,6 +2,7 @@ using System.Data.Entity.Core.Metadata.Edm; using System.Linq; using SQLite.CodeFirst.Builder.NameCreators; +using SQLite.CodeFirst.Extensions; namespace SQLite.CodeFirst.Utility { @@ -30,6 +31,13 @@ public SqliteAssociationType(AssociationType associationType, EntityContainer co ForeignKey = associationType.Constraint.ToProperties.Select(x => x.Name); ForeignPrimaryKey = associationType.Constraint.FromProperties.Select(x => x.Name); CascadeDelete = associationType.Constraint.FromRole.DeleteBehavior == OperationAction.Cascade; + + // EF has no notion of update-cascade, so it is opt-in via the CascadeOnUpdateAttribute placed on the + // dependent foreign key property. The attribute is registered as a column annotation and therefore + // available on the store model foreign key columns (the constraint's ToProperties). + CascadeUpdate = associationType.Constraint.ToProperties + .Select(property => property.GetCustomAnnotation()) + .Any(attribute => attribute != null && attribute.CanCascade); } private static bool IsSelfReferencing(AssociationType associationType) @@ -47,5 +55,6 @@ private static bool IsSelfReferencing(AssociationType associationType) public string ToTableName { get; } public IEnumerable ForeignPrimaryKey { get; } public bool CascadeDelete { get; } + public bool CascadeUpdate { get; } } } \ No newline at end of file diff --git a/SQLite.CodeFirst/Public/Attributes/CascadeOnUpdateAttribute.cs b/SQLite.CodeFirst/Public/Attributes/CascadeOnUpdateAttribute.cs new file mode 100644 index 0000000..5870ccf --- /dev/null +++ b/SQLite.CodeFirst/Public/Attributes/CascadeOnUpdateAttribute.cs @@ -0,0 +1,28 @@ +using System; + +namespace SQLite.CodeFirst +{ + /// + /// Adds 'ON UPDATE CASCADE' to the foreign key constraint of the relationship the decorated property belongs to. + /// Entity Framework has no concept of update-cascade (it assumes primary keys are immutable), so neither the + /// nor the fluent API can express it. + /// This opt-in attribute is the only way to emit the keyword. + /// Place it on the dependent foreign key property (e.g. the 'TeamId' property), not on the navigation property (e.g. 'Team'). + /// Requires an explicit foreign key property; relationships with a shadow foreign key column cannot be decorated. + /// + [AttributeUsage(AttributeTargets.Property)] + public sealed class CascadeOnUpdateAttribute : Attribute + { + public CascadeOnUpdateAttribute() + { + CanCascade = true; + } + + public CascadeOnUpdateAttribute(bool canCascade) + { + CanCascade = canCascade; + } + + public bool CanCascade { get; } + } +} diff --git a/SQLite.CodeFirst/Public/DbInitializers/SqliteInitializerBase.cs b/SQLite.CodeFirst/Public/DbInitializers/SqliteInitializerBase.cs index 1309b1f..4e20b33 100644 --- a/SQLite.CodeFirst/Public/DbInitializers/SqliteInitializerBase.cs +++ b/SQLite.CodeFirst/Public/DbInitializers/SqliteInitializerBase.cs @@ -40,6 +40,7 @@ protected SqliteInitializerBase(DbModelBuilder modelBuilder, Collation defaultCo modelBuilder.RegisterAttributeAsColumnAnnotation(); modelBuilder.RegisterAttributeAsColumnAnnotation(); modelBuilder.RegisterAttributeAsColumnAnnotation(); + modelBuilder.RegisterAttributeAsColumnAnnotation(); // By default there is a 'ForeignKeyIndexConvention' but it can be removed. // And there is no "Contains" and no way to enumerate the ConventionsCollection.