Skip to content

Commit c4f47fd

Browse files
msallinclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 81f12d5 commit c4f47fd

8 files changed

Lines changed: 204 additions & 2 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,15 @@ The following features are supported:
2828
- Tables from classes (supported annotations: `Table`)
2929
- Columns from properties (supported annotations: `Column`, `Key`, `MaxLength`, `Required`, `NotMapped`, `DatabaseGenerated`, `Index`)
3030
- PrimaryKey constraint (`Key` annotation, key composites are supported)
31-
- ForeignKey constraint (1-n relationships, support for 'Cascade on delete')
31+
- ForeignKey constraint (1-n relationships, support for 'Cascade on delete' and 'Cascade on update')
3232
- Not Null constraint
3333
- Auto increment (An int PrimaryKey will automatically be incremented and you can explicitly set the "AUTOINCREMENT" constraint to a PrimaryKey using the Autoincrement-Attribute)
3434
- 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`)
3535
- Unique constraint (Decorate columns with the `UniqueAttribute`, which is part of this library)
3636
- Collate constraint (Decorate columns with the `CollateAttribute`, which is part of this library. Use `CollationFunction.Custom` to specify your own collation function.)
3737
- Default collation (pass an instance of Collation as constructor parameter for an initializer to specify a default collation).
3838
- SQL default value (Decorate columns with the `SqlDefaultValueAttribute`, which is part of this library)
39+
- 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.)
3940

4041
## Install
4142

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
using System.Collections.Generic;
2+
using System.Data.Common;
3+
using System.Data.Entity;
4+
using System.Data.Entity.Infrastructure;
5+
using System.Data.SQLite;
6+
using System.Linq;
7+
using Microsoft.VisualStudio.TestTools.UnitTesting;
8+
9+
namespace SQLite.CodeFirst.Test.IntegrationTests
10+
{
11+
/// <summary>
12+
/// Verifies that the <see cref="CascadeOnUpdateAttribute"/> placed on a foreign key property
13+
/// results in 'ON UPDATE CASCADE' being emitted for the corresponding foreign key constraint.
14+
/// This exercises the full pipeline: the attribute is registered as a column annotation, survives
15+
/// the EF model build and is read back from the store model when the SQL is generated.
16+
/// </summary>
17+
[TestClass]
18+
public class CascadeOnUpdateTest
19+
{
20+
private static string generatedSql;
21+
22+
[TestMethod]
23+
public void CascadeOnUpdateAttributeEmitsOnUpdateCascade()
24+
{
25+
using (DbConnection connection = new SQLiteConnection("FullUri=file::memory:"))
26+
{
27+
// This is important! Else the in memory database will not work.
28+
connection.Open();
29+
30+
using (var context = new CascadeDbContext(connection))
31+
{
32+
// Touching the set forces the initializer (and therefore the SQL generation) to run.
33+
context.Set<Order>().FirstOrDefault();
34+
}
35+
}
36+
37+
// Decorated FK with cascade-on-delete: both keywords, delete before update.
38+
StringAssert.Contains(generatedSql, "FOREIGN KEY ([CustomerId]) REFERENCES \"Customers\"([Id]) ON DELETE CASCADE ON UPDATE CASCADE");
39+
40+
// Decorated FK without cascade-on-delete: only the update keyword.
41+
StringAssert.Contains(generatedSql, "FOREIGN KEY ([WarehouseId]) REFERENCES \"Warehouses\"([Id]) ON UPDATE CASCADE");
42+
43+
// Undecorated FK: no cascade keyword at all.
44+
Assert.IsFalse(generatedSql.Contains("[RegionId]) REFERENCES \"Regions\"([Id]) ON"),
45+
"The foreign key without the attribute must not emit any cascade clause.");
46+
}
47+
48+
private class Customer
49+
{
50+
public int Id { get; set; }
51+
}
52+
53+
private class Warehouse
54+
{
55+
public int Id { get; set; }
56+
}
57+
58+
private class Region
59+
{
60+
public int Id { get; set; }
61+
}
62+
63+
private class Order
64+
{
65+
public int Id { get; set; }
66+
67+
[CascadeOnUpdate]
68+
public int CustomerId { get; set; }
69+
public Customer Customer { get; set; }
70+
71+
[CascadeOnUpdate]
72+
public int? WarehouseId { get; set; }
73+
public Warehouse Warehouse { get; set; }
74+
75+
public int? RegionId { get; set; }
76+
public Region Region { get; set; }
77+
}
78+
79+
private class CascadeDbContext : DbContext
80+
{
81+
public CascadeDbContext(DbConnection connection)
82+
: base(connection, false)
83+
{
84+
}
85+
86+
protected override void OnModelCreating(DbModelBuilder modelBuilder)
87+
{
88+
modelBuilder.Entity<Order>()
89+
.HasRequired(o => o.Customer)
90+
.WithMany()
91+
.HasForeignKey(o => o.CustomerId)
92+
.WillCascadeOnDelete(true);
93+
94+
modelBuilder.Entity<Order>()
95+
.HasOptional(o => o.Warehouse)
96+
.WithMany()
97+
.HasForeignKey(o => o.WarehouseId)
98+
.WillCascadeOnDelete(false);
99+
100+
modelBuilder.Entity<Order>()
101+
.HasOptional(o => o.Region)
102+
.WithMany()
103+
.HasForeignKey(o => o.RegionId)
104+
.WillCascadeOnDelete(false);
105+
106+
Database.SetInitializer(new CaptureInitializer(modelBuilder));
107+
}
108+
}
109+
110+
private class CaptureInitializer : SqliteInitializerBase<CascadeDbContext>
111+
{
112+
public CaptureInitializer(DbModelBuilder modelBuilder)
113+
: base(modelBuilder)
114+
{
115+
}
116+
117+
public override void InitializeDatabase(CascadeDbContext context)
118+
{
119+
DbModel model = ModelBuilder.Build(context.Database.Connection);
120+
generatedSql = new SqliteSqlGenerator().Generate(model.StoreModel);
121+
base.InitializeDatabase(context);
122+
}
123+
}
124+
}
125+
}

SQLite.CodeFirst.Test/UnitTests/Statement/ForeignKeyStatementTest.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,37 @@ public void CreateStatementOneForeignKeyCascadeDeleteTest()
3737
Assert.AreEqual("FOREIGN KEY ([dummyForeignKey1]) REFERENCES dummyForeignTable([dummForeignPrimaryKey1]) ON DELETE CASCADE", output);
3838
}
3939

40+
[TestMethod]
41+
public void CreateStatementOneForeignKeyCascadeUpdateTest()
42+
{
43+
var foreignKeyStatement = new ForeignKeyStatement
44+
{
45+
CascadeUpdate = true,
46+
ForeignKey = new List<string> { "dummyForeignKey1" },
47+
ForeignPrimaryKey = new List<string> { "dummForeignPrimaryKey1" },
48+
ForeignTable = "dummyForeignTable"
49+
};
50+
51+
string output = foreignKeyStatement.CreateStatement();
52+
Assert.AreEqual("FOREIGN KEY ([dummyForeignKey1]) REFERENCES dummyForeignTable([dummForeignPrimaryKey1]) ON UPDATE CASCADE", output);
53+
}
54+
55+
[TestMethod]
56+
public void CreateStatementOneForeignKeyCascadeDeleteAndUpdateTest()
57+
{
58+
var foreignKeyStatement = new ForeignKeyStatement
59+
{
60+
CascadeDelete = true,
61+
CascadeUpdate = true,
62+
ForeignKey = new List<string> { "dummyForeignKey1" },
63+
ForeignPrimaryKey = new List<string> { "dummForeignPrimaryKey1" },
64+
ForeignTable = "dummyForeignTable"
65+
};
66+
67+
string output = foreignKeyStatement.CreateStatement();
68+
Assert.AreEqual("FOREIGN KEY ([dummyForeignKey1]) REFERENCES dummyForeignTable([dummForeignPrimaryKey1]) ON DELETE CASCADE ON UPDATE CASCADE", output);
69+
}
70+
4071
[TestMethod]
4172
public void CreateStatementTwoForeignKeyTest()
4273
{

SQLite.CodeFirst/Internal/Builder/ForeignKeyStatementBuilder.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ private IEnumerable<ForeignKeyStatement> GetForeignKeyStatements()
2929
ForeignKey = associationType.ForeignKey,
3030
ForeignTable = associationType.FromTableName,
3131
ForeignPrimaryKey = associationType.ForeignPrimaryKey,
32-
CascadeDelete = associationType.CascadeDelete
32+
CascadeDelete = associationType.CascadeDelete,
33+
CascadeUpdate = associationType.CascadeUpdate
3334
};
3435
}
3536
}

SQLite.CodeFirst/Internal/Statement/ForeignKeyStatement.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ internal class ForeignKeyStatement : IStatement
99
{
1010
private const string Template = "FOREIGN KEY ({foreign-key}) REFERENCES {referenced-table}({referenced-id})";
1111
private const string CascadeDeleteStatement = "ON DELETE CASCADE";
12+
private const string CascadeUpdateStatement = "ON UPDATE CASCADE";
1213

1314
public IEnumerable<string> ForeignKey { get; set; }
1415
public string ForeignTable { get; set; }
1516
public IEnumerable<string> ForeignPrimaryKey { get; set; }
1617
public bool CascadeDelete { get; set; }
18+
public bool CascadeUpdate { get; set; }
1719

1820
public string CreateStatement()
1921
{
@@ -25,6 +27,10 @@ public string CreateStatement()
2527
{
2628
sb.Append(" " + CascadeDeleteStatement);
2729
}
30+
if (CascadeUpdate)
31+
{
32+
sb.Append(" " + CascadeUpdateStatement);
33+
}
2834

2935
return sb.ToString();
3036
}

SQLite.CodeFirst/Internal/Utility/SqliteAssociationType.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Data.Entity.Core.Metadata.Edm;
33
using System.Linq;
44
using SQLite.CodeFirst.Builder.NameCreators;
5+
using SQLite.CodeFirst.Extensions;
56

67
namespace SQLite.CodeFirst.Utility
78
{
@@ -30,6 +31,13 @@ public SqliteAssociationType(AssociationType associationType, EntityContainer co
3031
ForeignKey = associationType.Constraint.ToProperties.Select(x => x.Name);
3132
ForeignPrimaryKey = associationType.Constraint.FromProperties.Select(x => x.Name);
3233
CascadeDelete = associationType.Constraint.FromRole.DeleteBehavior == OperationAction.Cascade;
34+
35+
// EF has no notion of update-cascade, so it is opt-in via the CascadeOnUpdateAttribute placed on the
36+
// dependent foreign key property. The attribute is registered as a column annotation and therefore
37+
// available on the store model foreign key columns (the constraint's ToProperties).
38+
CascadeUpdate = associationType.Constraint.ToProperties
39+
.Select(property => property.GetCustomAnnotation<CascadeOnUpdateAttribute>())
40+
.Any(attribute => attribute != null && attribute.CanCascade);
3341
}
3442

3543
private static bool IsSelfReferencing(AssociationType associationType)
@@ -47,5 +55,6 @@ private static bool IsSelfReferencing(AssociationType associationType)
4755
public string ToTableName { get; }
4856
public IEnumerable<string> ForeignPrimaryKey { get; }
4957
public bool CascadeDelete { get; }
58+
public bool CascadeUpdate { get; }
5059
}
5160
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using System;
2+
3+
namespace SQLite.CodeFirst
4+
{
5+
/// <summary>
6+
/// Adds 'ON UPDATE CASCADE' to the foreign key constraint of the relationship the decorated property belongs to.
7+
/// Entity Framework has no concept of update-cascade (it assumes primary keys are immutable), so neither the
8+
/// <see cref="System.ComponentModel.DataAnnotations.Schema.ForeignKeyAttribute"/> nor the fluent API can express it.
9+
/// This opt-in attribute is the only way to emit the keyword.
10+
/// Place it on the dependent foreign key property (e.g. the 'TeamId' property), not on the navigation property (e.g. 'Team').
11+
/// Requires an explicit foreign key property; relationships with a shadow foreign key column cannot be decorated.
12+
/// </summary>
13+
[AttributeUsage(AttributeTargets.Property)]
14+
public sealed class CascadeOnUpdateAttribute : Attribute
15+
{
16+
public CascadeOnUpdateAttribute()
17+
{
18+
CanCascade = true;
19+
}
20+
21+
public CascadeOnUpdateAttribute(bool canCascade)
22+
{
23+
CanCascade = canCascade;
24+
}
25+
26+
public bool CanCascade { get; }
27+
}
28+
}

SQLite.CodeFirst/Public/DbInitializers/SqliteInitializerBase.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ protected SqliteInitializerBase(DbModelBuilder modelBuilder, Collation defaultCo
4040
modelBuilder.RegisterAttributeAsColumnAnnotation<CollateAttribute>();
4141
modelBuilder.RegisterAttributeAsColumnAnnotation<AutoincrementAttribute>();
4242
modelBuilder.RegisterAttributeAsColumnAnnotation<SqlDefaultValueAttribute>();
43+
modelBuilder.RegisterAttributeAsColumnAnnotation<CascadeOnUpdateAttribute>();
4344

4445
// By default there is a 'ForeignKeyIndexConvention' but it can be removed.
4546
// And there is no "Contains" and no way to enumerate the ConventionsCollection.

0 commit comments

Comments
 (0)