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.