diff --git a/src/EFCore.Cosmos/Extensions/CosmosEntityTypeBuilderExtensions.cs b/src/EFCore.Cosmos/Extensions/CosmosEntityTypeBuilderExtensions.cs
index 24efef0d0fc..3ea42adeda6 100644
--- a/src/EFCore.Cosmos/Extensions/CosmosEntityTypeBuilderExtensions.cs
+++ b/src/EFCore.Cosmos/Extensions/CosmosEntityTypeBuilderExtensions.cs
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using Microsoft.Azure.Cosmos.Scripts;
using Microsoft.EntityFrameworkCore.Cosmos.Metadata.Internal;
// ReSharper disable once CheckNamespace
@@ -1007,4 +1008,43 @@ public static bool CanSetThroughput(
? existingThroughput?.Throughput == throughput
: existingThroughput?.AutoscaleMaxThroughput == throughput;
}
+
+ ///
+ /// Configures a database trigger on the entity.
+ ///
+ /// The builder for the entity type being configured.
+ /// The name of the trigger.
+ /// The trigger type.
+ /// The trigger operation.
+ /// A builder that can be used to configure the database trigger.
+ ///
+ /// See Database triggers for more information and examples.
+ ///
+ public static TriggerBuilder HasTrigger(this EntityTypeBuilder entityTypeBuilder, string modelName, TriggerType triggerType, TriggerOperation triggerOperation)
+ {
+ var triggerBuilder = EntityTypeBuilder.HasTrigger(entityTypeBuilder.Metadata, modelName);
+ triggerBuilder.Metadata.SetTriggerType(triggerType);
+ triggerBuilder.Metadata.SetTriggerOperation(triggerOperation);
+ return triggerBuilder;
+ }
+
+ ///
+ /// Configures a database trigger on the entity.
+ ///
+ /// The builder for the entity type being configured.
+ /// The name of the trigger.
+ /// The trigger type.
+ /// The trigger operation.
+ /// A builder that can be used to configure the database trigger.
+ ///
+ /// See Database triggers for more information and examples.
+ ///
+ public static TriggerBuilder HasTrigger(this EntityTypeBuilder entityTypeBuilder, string modelName, TriggerType triggerType, TriggerOperation triggerOperation)
+ where TEntity : class
+ {
+ var triggerBuilder = EntityTypeBuilder.HasTrigger(entityTypeBuilder.Metadata, modelName);
+ triggerBuilder.Metadata.SetTriggerType(triggerType);
+ triggerBuilder.Metadata.SetTriggerOperation(triggerOperation);
+ return triggerBuilder;
+ }
}
diff --git a/src/EFCore.Cosmos/Extensions/CosmosTriggerBuilderExtensions.cs b/src/EFCore.Cosmos/Extensions/CosmosTriggerBuilderExtensions.cs
new file mode 100644
index 00000000000..e5743c73d0d
--- /dev/null
+++ b/src/EFCore.Cosmos/Extensions/CosmosTriggerBuilderExtensions.cs
@@ -0,0 +1,92 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Azure.Cosmos.Scripts;
+using Microsoft.EntityFrameworkCore.Cosmos.Metadata.Internal;
+
+// ReSharper disable once CheckNamespace
+namespace Microsoft.EntityFrameworkCore;
+
+///
+/// Cosmos DB specific extension methods for .
+///
+///
+/// See Database triggers for more information and examples.
+///
+public static class CosmosTriggerBuilderExtensions
+{
+
+ ///
+ /// Configures the Cosmos DB trigger type for this trigger.
+ ///
+ /// The builder for the trigger being configured.
+ /// The Cosmos DB trigger type.
+ /// Indicates whether the configuration was specified using a data annotation.
+ ///
+ /// The same builder instance if the configuration was applied,
+ /// otherwise.
+ ///
+ public static IConventionTriggerBuilder? HasTriggerType(
+ this IConventionTriggerBuilder triggerBuilder,
+ TriggerType? triggerType,
+ bool fromDataAnnotation = false)
+ {
+ if (!triggerBuilder.CanSetTriggerType(triggerType, fromDataAnnotation))
+ {
+ return null;
+ }
+
+ triggerBuilder.Metadata.SetTriggerType(triggerType, fromDataAnnotation);
+ return triggerBuilder;
+ }
+
+ ///
+ /// Returns a value indicating whether the given Cosmos DB trigger type can be set for this trigger.
+ ///
+ /// The builder for the trigger being configured.
+ /// The Cosmos DB trigger type.
+ /// Indicates whether the configuration was specified using a data annotation.
+ /// if the Cosmos DB trigger type can be set for this trigger.
+ public static bool CanSetTriggerType(
+ this IConventionTriggerBuilder triggerBuilder,
+ TriggerType? triggerType,
+ bool fromDataAnnotation = false)
+ => triggerBuilder.CanSetAnnotation(CosmosAnnotationNames.TriggerType, triggerType, fromDataAnnotation);
+
+ ///
+ /// Configures the Cosmos DB trigger operation for this trigger.
+ ///
+ /// The builder for the trigger being configured.
+ /// The Cosmos DB trigger operation.
+ /// Indicates whether the configuration was specified using a data annotation.
+ ///
+ /// The same builder instance if the configuration was applied,
+ /// otherwise.
+ ///
+ public static IConventionTriggerBuilder? HasTriggerOperation(
+ this IConventionTriggerBuilder triggerBuilder,
+ TriggerOperation? triggerOperation,
+ bool fromDataAnnotation = false)
+ {
+ if (!triggerBuilder.CanSetTriggerOperation(triggerOperation, fromDataAnnotation))
+ {
+ return null;
+ }
+
+ triggerBuilder.Metadata.SetTriggerOperation(triggerOperation, fromDataAnnotation);
+ return triggerBuilder;
+ }
+
+ ///
+ /// Returns a value indicating whether the given Cosmos DB trigger operation can be set for this trigger.
+ ///
+ /// The builder for the trigger being configured.
+ /// The Cosmos DB trigger operation.
+ /// Indicates whether the configuration was specified using a data annotation.
+ /// if the Cosmos DB trigger operation can be set for this trigger.
+ public static bool CanSetTriggerOperation(
+ this IConventionTriggerBuilder triggerBuilder,
+ TriggerOperation? triggerOperation,
+ bool fromDataAnnotation = false)
+ => triggerBuilder.CanSetAnnotation(CosmosAnnotationNames.TriggerOperation, triggerOperation, fromDataAnnotation);
+}
diff --git a/src/EFCore.Cosmos/Extensions/CosmosTriggerExtensions.cs b/src/EFCore.Cosmos/Extensions/CosmosTriggerExtensions.cs
new file mode 100644
index 00000000000..f7a7e314d2e
--- /dev/null
+++ b/src/EFCore.Cosmos/Extensions/CosmosTriggerExtensions.cs
@@ -0,0 +1,91 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Azure.Cosmos.Scripts;
+using Microsoft.EntityFrameworkCore.Cosmos.Metadata.Internal;
+
+// ReSharper disable once CheckNamespace
+namespace Microsoft.EntityFrameworkCore;
+
+///
+/// Cosmos DB specific extension methods for and related types.
+///
+///
+/// See Database triggers for more information and examples.
+///
+public static class CosmosTriggerExtensions
+{
+ ///
+ /// Gets the Cosmos DB trigger type for this trigger.
+ ///
+ /// The trigger.
+ /// The Cosmos DB trigger type.
+ public static TriggerType? GetTriggerType(this IReadOnlyTrigger trigger)
+ => (TriggerType?)trigger[CosmosAnnotationNames.TriggerType];
+
+ ///
+ /// Sets the Cosmos DB trigger type for this trigger.
+ ///
+ /// The trigger.
+ /// The Cosmos DB trigger type.
+ public static void SetTriggerType(this IMutableTrigger trigger, TriggerType? triggerType)
+ => trigger.SetOrRemoveAnnotation(CosmosAnnotationNames.TriggerType, triggerType);
+
+ ///
+ /// Sets the Cosmos DB trigger type for this trigger.
+ ///
+ /// The trigger.
+ /// The Cosmos DB trigger type.
+ /// Indicates whether the configuration was specified using a data annotation.
+ /// The configured value.
+ public static TriggerType? SetTriggerType(
+ this IConventionTrigger trigger,
+ TriggerType? triggerType,
+ bool fromDataAnnotation = false)
+ => (TriggerType?)trigger.SetOrRemoveAnnotation(CosmosAnnotationNames.TriggerType, triggerType, fromDataAnnotation)?.Value;
+
+ ///
+ /// Gets the for the Cosmos DB trigger type.
+ ///
+ /// The trigger.
+ /// The for the Cosmos DB trigger type.
+ public static ConfigurationSource? GetTriggerTypeConfigurationSource(this IConventionTrigger trigger)
+ => trigger.FindAnnotation(CosmosAnnotationNames.TriggerType)?.GetConfigurationSource();
+
+ ///
+ /// Gets the Cosmos DB trigger operation for this trigger.
+ ///
+ /// The trigger.
+ /// The Cosmos DB trigger operation.
+ public static TriggerOperation? GetTriggerOperation(this IReadOnlyTrigger trigger)
+ => (TriggerOperation?)trigger[CosmosAnnotationNames.TriggerOperation];
+
+ ///
+ /// Sets the Cosmos DB trigger operation for this trigger.
+ ///
+ /// The trigger.
+ /// The Cosmos DB trigger operation.
+ public static void SetTriggerOperation(this IMutableTrigger trigger, TriggerOperation? triggerOperation)
+ => trigger.SetOrRemoveAnnotation(CosmosAnnotationNames.TriggerOperation, triggerOperation);
+
+ ///
+ /// Sets the Cosmos DB trigger operation for this trigger.
+ ///
+ /// The trigger.
+ /// The Cosmos DB trigger operation.
+ /// Indicates whether the configuration was specified using a data annotation.
+ /// The configured value.
+ public static TriggerOperation? SetTriggerOperation(
+ this IConventionTrigger trigger,
+ TriggerOperation? triggerOperation,
+ bool fromDataAnnotation = false)
+ => (TriggerOperation?)trigger.SetOrRemoveAnnotation(CosmosAnnotationNames.TriggerOperation, triggerOperation, fromDataAnnotation)?.Value;
+
+ ///
+ /// Gets the for the Cosmos DB trigger operation.
+ ///
+ /// The trigger.
+ /// The for the Cosmos DB trigger operation.
+ public static ConfigurationSource? GetTriggerOperationConfigurationSource(this IConventionTrigger trigger)
+ => trigger.FindAnnotation(CosmosAnnotationNames.TriggerOperation)?.GetConfigurationSource();
+}
diff --git a/src/EFCore.Cosmos/Infrastructure/Internal/CosmosModelValidator.cs b/src/EFCore.Cosmos/Infrastructure/Internal/CosmosModelValidator.cs
index 8fd7565bc71..b02f420ce26 100644
--- a/src/EFCore.Cosmos/Infrastructure/Internal/CosmosModelValidator.cs
+++ b/src/EFCore.Cosmos/Infrastructure/Internal/CosmosModelValidator.cs
@@ -633,4 +633,41 @@ protected override void ValidatePropertyMapping(
}
}
}
+
+ ///
+ /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
+ /// the same compatibility standards as public APIs. It may be changed or removed without notice in
+ /// any release. You should only use it directly in your code with extreme caution and knowing that
+ /// doing so can result in application failures when updating to a new Entity Framework Core release.
+ ///
+ protected override void ValidateTriggers(
+ IModel model,
+ IDiagnosticsLogger logger)
+ {
+ base.ValidateTriggers(model, logger);
+
+ foreach (var entityType in model.GetEntityTypes())
+ {
+ foreach (var trigger in entityType.GetDeclaredTriggers())
+ {
+ if (entityType.BaseType != null)
+ {
+ throw new InvalidOperationException(
+ CosmosStrings.TriggerOnDerivedType(trigger.ModelName, entityType.DisplayName(), entityType.BaseType.DisplayName()));
+ }
+
+ if (trigger.GetTriggerType() == null)
+ {
+ throw new InvalidOperationException(
+ CosmosStrings.TriggerMissingType(trigger.ModelName, entityType.DisplayName()));
+ }
+
+ if (trigger.GetTriggerOperation() == null)
+ {
+ throw new InvalidOperationException(
+ CosmosStrings.TriggerMissingOperation(trigger.ModelName, entityType.DisplayName()));
+ }
+ }
+ }
+ }
}
diff --git a/src/EFCore.Cosmos/Metadata/Internal/CosmosAnnotationNames.cs b/src/EFCore.Cosmos/Metadata/Internal/CosmosAnnotationNames.cs
index a6f87a77521..5aac3bede02 100644
--- a/src/EFCore.Cosmos/Metadata/Internal/CosmosAnnotationNames.cs
+++ b/src/EFCore.Cosmos/Metadata/Internal/CosmosAnnotationNames.cs
@@ -162,4 +162,20 @@ public static class CosmosAnnotationNames
/// doing so can result in application failures when updating to a new Entity Framework Core release.
///
public const string ModelDependencies = Prefix + "ModelDependencies";
+
+ ///
+ /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
+ /// the same compatibility standards as public APIs. It may be changed or removed without notice in
+ /// any release. You should only use it directly in your code with extreme caution and knowing that
+ /// doing so can result in application failures when updating to a new Entity Framework Core release.
+ ///
+ public const string TriggerType = Prefix + "TriggerType";
+
+ ///
+ /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
+ /// the same compatibility standards as public APIs. It may be changed or removed without notice in
+ /// any release. You should only use it directly in your code with extreme caution and knowing that
+ /// doing so can result in application failures when updating to a new Entity Framework Core release.
+ ///
+ public const string TriggerOperation = Prefix + "TriggerOperation";
}
diff --git a/src/EFCore.Cosmos/Properties/CosmosStrings.Designer.cs b/src/EFCore.Cosmos/Properties/CosmosStrings.Designer.cs
index ff9b790d385..3adc676ae81 100644
--- a/src/EFCore.Cosmos/Properties/CosmosStrings.Designer.cs
+++ b/src/EFCore.Cosmos/Properties/CosmosStrings.Designer.cs
@@ -513,6 +513,30 @@ public static string ToPageAsyncAtTopLevelOnly
public static string TransactionsNotSupported
=> GetString("TransactionsNotSupported");
+ ///
+ /// Trigger '{trigger}' on entity type '{entityType}' does not have a trigger operation configured. Use 'HasTriggerOperation()' to configure the trigger operation.
+ ///
+ public static string TriggerMissingOperation(object? trigger, object? entityType)
+ => string.Format(
+ GetString("TriggerMissingOperation", nameof(trigger), nameof(entityType)),
+ trigger, entityType);
+
+ ///
+ /// Trigger '{trigger}' on entity type '{entityType}' does not have a trigger type configured. Use 'HasTriggerType()' to configure the trigger type.
+ ///
+ public static string TriggerMissingType(object? trigger, object? entityType)
+ => string.Format(
+ GetString("TriggerMissingType", nameof(trigger), nameof(entityType)),
+ trigger, entityType);
+
+ ///
+ /// Trigger '{trigger}' is defined on entity type '{entityType}' which inherits from '{baseType}'. Triggers can only be defined on root entity types.
+ ///
+ public static string TriggerOnDerivedType(object? trigger, object? entityType, object? baseType)
+ => string.Format(
+ GetString("TriggerOnDerivedType", nameof(trigger), nameof(entityType), nameof(baseType)),
+ trigger, entityType, baseType);
+
///
/// Unable to bind '{memberType}' '{member}' to an entity projection of '{entityType}'.
///
diff --git a/src/EFCore.Cosmos/Properties/CosmosStrings.resx b/src/EFCore.Cosmos/Properties/CosmosStrings.resx
index b42289a5704..3f4c9e46bed 100644
--- a/src/EFCore.Cosmos/Properties/CosmosStrings.resx
+++ b/src/EFCore.Cosmos/Properties/CosmosStrings.resx
@@ -355,6 +355,15 @@
The Cosmos database provider does not support transactions.
+
+ Trigger '{trigger}' on entity type '{entityType}' does not have a trigger operation configured. Use 'HasTriggerOperation()' to configure the trigger operation.
+
+
+ Trigger '{trigger}' on entity type '{entityType}' does not have a trigger type configured. Use 'HasTriggerType()' to configure the trigger type.
+
+
+ Trigger '{trigger}' is defined on entity type '{entityType}' which inherits from '{baseType}'. Triggers can only be defined on root entity types.
+
Unable to bind '{memberType}' '{member}' to an entity projection of '{entityType}'.
diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs
index fb4d9ea066f..e9dd8ed71a0 100644
--- a/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs
+++ b/src/EFCore.Cosmos/Storage/Internal/CosmosClientWrapper.cs
@@ -9,6 +9,7 @@
using Microsoft.EntityFrameworkCore.Cosmos.Diagnostics.Internal;
using Microsoft.EntityFrameworkCore.Cosmos.Infrastructure.Internal;
using Microsoft.EntityFrameworkCore.Cosmos.Internal;
+using Microsoft.Azure.Cosmos.Scripts;
using Microsoft.EntityFrameworkCore.Cosmos.Metadata.Internal;
using Microsoft.EntityFrameworkCore.Internal;
using Newtonsoft.Json;
@@ -419,6 +420,20 @@ private static async Task CreateItemOnceAsync(
var container = wrapper.Client.GetDatabase(wrapper._databaseId).GetContainer(parameters.ContainerId);
var itemRequestOptions = CreateItemRequestOptions(entry, wrapper._enableContentResponseOnWrite);
var partitionKeyValue = ExtractPartitionKeyValue(entry);
+ var preTriggers = GetTriggers(entry, TriggerType.Pre, TriggerOperation.Create);
+ var postTriggers = GetTriggers(entry, TriggerType.Post, TriggerOperation.Create);
+ if (preTriggers != null || postTriggers != null)
+ {
+ itemRequestOptions ??= new ItemRequestOptions();
+ if (preTriggers != null)
+ {
+ itemRequestOptions.PreTriggers = preTriggers;
+ }
+ if (postTriggers != null)
+ {
+ itemRequestOptions.PostTriggers = postTriggers;
+ }
+ }
var response = await container.CreateItemStreamAsync(
stream,
@@ -495,6 +510,20 @@ private static async Task ReplaceItemOnceAsync(
var container = wrapper.Client.GetDatabase(wrapper._databaseId).GetContainer(parameters.ContainerId);
var itemRequestOptions = CreateItemRequestOptions(entry, wrapper._enableContentResponseOnWrite);
var partitionKeyValue = ExtractPartitionKeyValue(entry);
+ var preTriggers = GetTriggers(entry, TriggerType.Pre, TriggerOperation.Replace);
+ var postTriggers = GetTriggers(entry, TriggerType.Post, TriggerOperation.Replace);
+ if (preTriggers != null || postTriggers != null)
+ {
+ itemRequestOptions ??= new ItemRequestOptions();
+ if (preTriggers != null)
+ {
+ itemRequestOptions.PreTriggers = preTriggers;
+ }
+ if (postTriggers != null)
+ {
+ itemRequestOptions.PostTriggers = postTriggers;
+ }
+ }
using var response = await container.ReplaceItemStreamAsync(
stream,
@@ -562,6 +591,20 @@ private static async Task DeleteItemOnceAsync(
var itemRequestOptions = CreateItemRequestOptions(entry, wrapper._enableContentResponseOnWrite);
var partitionKeyValue = ExtractPartitionKeyValue(entry);
+ var preTriggers = GetTriggers(entry, TriggerType.Pre, TriggerOperation.Delete);
+ var postTriggers = GetTriggers(entry, TriggerType.Post, TriggerOperation.Delete);
+ if (preTriggers != null || postTriggers != null)
+ {
+ itemRequestOptions ??= new ItemRequestOptions();
+ if (preTriggers != null)
+ {
+ itemRequestOptions.PreTriggers = preTriggers;
+ }
+ if (postTriggers != null)
+ {
+ itemRequestOptions.PostTriggers = postTriggers;
+ }
+ }
using var response = await items.DeleteItemStreamAsync(
parameters.ResourceId,
@@ -628,6 +671,24 @@ private static async Task DeleteItemOnceAsync(
return new ItemRequestOptions { IfMatchEtag = (string?)etag, EnableContentResponseOnWrite = enabledContentResponse };
}
+ private static IReadOnlyList? GetTriggers(IUpdateEntry entry, TriggerType type, TriggerOperation operation)
+ {
+ var preTriggers = entry.EntityType.GetTriggers()
+ .Where(t => t.GetTriggerType() == type && ShouldExecuteTrigger(t, operation))
+ .Select(t => t.ModelName)
+ .ToList();
+
+ return preTriggers.Count > 0 ? preTriggers : null;
+ }
+
+ private static bool ShouldExecuteTrigger(ITrigger trigger, TriggerOperation currentOperation)
+ {
+ var triggerOperation = trigger.GetTriggerOperation();
+ return triggerOperation == null ||
+ triggerOperation == TriggerOperation.All ||
+ triggerOperation == currentOperation;
+ }
+
private static PartitionKey ExtractPartitionKeyValue(IUpdateEntry entry)
{
var partitionKeyProperties = entry.EntityType.GetPartitionKeyProperties();
diff --git a/src/EFCore/Metadata/IConventionEntityType.cs b/src/EFCore/Metadata/IConventionEntityType.cs
index f233d4c7006..366df15a396 100644
--- a/src/EFCore/Metadata/IConventionEntityType.cs
+++ b/src/EFCore/Metadata/IConventionEntityType.cs
@@ -803,6 +803,13 @@ public interface IConventionEntityType : IReadOnlyEntityType, IConventionTypeBas
///
new IEnumerable GetDeclaredTriggers();
+ ///
+ /// Gets all triggers defined on this entity type.
+ ///
+ /// The triggers defined on this entity type.
+ new IEnumerable GetTriggers()
+ => (BaseType?.GetTriggers() ?? Enumerable.Empty()).Concat(GetDeclaredTriggers());
+
///
/// Creates a new trigger with the given name on entity type. Throws an exception if a trigger with the same name exists on the same
/// entity type.
diff --git a/src/EFCore/Metadata/IEntityType.cs b/src/EFCore/Metadata/IEntityType.cs
index 67d85fb0dbd..457dc08f44a 100644
--- a/src/EFCore/Metadata/IEntityType.cs
+++ b/src/EFCore/Metadata/IEntityType.cs
@@ -598,6 +598,13 @@ IEnumerable ITypeBase.GetFlattenedPropertiesInHierarchy()
///
new IEnumerable GetDeclaredTriggers();
+ ///
+ /// Gets all triggers defined on this entity type.
+ ///
+ /// The triggers defined on this entity type.
+ new IEnumerable GetTriggers()
+ => (BaseType?.GetTriggers() ?? Enumerable.Empty()).Concat(GetDeclaredTriggers());
+
internal const DynamicallyAccessedMemberTypes DynamicallyAccessedMemberTypes =
System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors
| System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.NonPublicConstructors
diff --git a/src/EFCore/Metadata/IMutableEntityType.cs b/src/EFCore/Metadata/IMutableEntityType.cs
index 5259e57e24c..af8d11a6479 100644
--- a/src/EFCore/Metadata/IMutableEntityType.cs
+++ b/src/EFCore/Metadata/IMutableEntityType.cs
@@ -744,6 +744,13 @@ IMutableIndex AddIndex(IMutableProperty property, string name)
///
new IEnumerable GetDeclaredTriggers();
+ ///
+ /// Gets all triggers defined on this entity type.
+ ///
+ /// The triggers defined on this entity type.
+ new IEnumerable GetTriggers()
+ => (BaseType?.GetTriggers() ?? Enumerable.Empty()).Concat(GetDeclaredTriggers());
+
///
/// Creates a new trigger with the given name on entity type. Throws an exception if a trigger with the same name exists on the same
/// entity type.
diff --git a/src/EFCore/Metadata/IReadOnlyEntityType.cs b/src/EFCore/Metadata/IReadOnlyEntityType.cs
index 5d68684bab6..97f8d3ecec8 100644
--- a/src/EFCore/Metadata/IReadOnlyEntityType.cs
+++ b/src/EFCore/Metadata/IReadOnlyEntityType.cs
@@ -633,6 +633,13 @@ bool IsInOwnershipPath(IReadOnlyEntityType targetType)
///
IEnumerable GetDeclaredTriggers();
+ ///
+ /// Gets all triggers defined on this entity type.
+ ///
+ /// The triggers defined on this entity type.
+ IEnumerable GetTriggers()
+ => (BaseType?.GetTriggers() ?? Enumerable.Empty()).Concat(GetDeclaredTriggers());
+
///
/// Gets the being used for navigations of this entity type.
///
diff --git a/test/EFCore.Cosmos.FunctionalTests/CosmosTriggersTest.cs b/test/EFCore.Cosmos.FunctionalTests/CosmosTriggersTest.cs
new file mode 100644
index 00000000000..c84dbc3f3bc
--- /dev/null
+++ b/test/EFCore.Cosmos.FunctionalTests/CosmosTriggersTest.cs
@@ -0,0 +1,233 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Net;
+using Microsoft.Azure.Cosmos;
+using Microsoft.Azure.Cosmos.Scripts;
+
+namespace Microsoft.EntityFrameworkCore;
+
+public class CosmosTriggersTest(NonSharedFixture fixture) : NonSharedModelTestBase(fixture), IClassFixture
+{
+ protected override string StoreName => "CosmosTriggersTest";
+
+ protected override ITestStoreFactory TestStoreFactory => CosmosTestStoreFactory.Instance;
+
+ [ConditionalFact]
+ public async Task Triggers_are_executed_on_SaveChanges()
+ {
+ var contextFactory = await InitializeAsync(
+ shouldLogCategory: _ => true,
+ onConfiguring: o => o.ConfigureWarnings(w => w.Log(CosmosEventId.SyncNotSupported)));
+
+ using (var context = contextFactory.CreateContext())
+ {
+ await CreateTriggersInCosmosAsync(context);
+
+ Assert.Empty(await context.Set().ToListAsync());
+
+ var product = new Product { Id = 1, Name = "Test Product", Price = 10.00m };
+ context.Products.Add(product);
+
+ await context.SaveChangesAsync();
+
+ var logs = await context.Set().ToListAsync();
+
+ Assert.Contains(logs, l => l.TriggerName == "PreInsertTrigger" && l.Operation == "INSERT");
+ }
+
+ using (var context = contextFactory.CreateContext())
+ {
+ var product = await context.Products.SingleAsync();
+ product.Name = "Updated Product";
+
+ await context.SaveChangesAsync();
+
+ var logs = await context.Set().Where(l => l.Operation == "UPDATE").ToListAsync();
+
+ Assert.Contains(logs, l => l.TriggerName == "UpdateTrigger" && l.Operation == "UPDATE");
+ }
+
+ using (var context = contextFactory.CreateContext())
+ {
+ var product = await context.Products.SingleAsync();
+ context.Products.Remove(product);
+
+ await context.SaveChangesAsync();
+
+ var logs = await context.Set().Where(l => l.Operation == "DELETE").ToListAsync();
+
+ Assert.Contains(logs, l => l.TriggerName == "PostDeleteTrigger" && l.Operation == "DELETE");
+ }
+ }
+
+ private async Task CreateTriggersInCosmosAsync(TriggersContext context)
+ {
+ await context.Database.EnsureCreatedAsync();
+
+ var cosmosClient = context.Database.GetCosmosClient();
+ var databaseId = context.Database.GetCosmosDatabaseId();
+ var database = cosmosClient.GetDatabase(databaseId);
+
+ // Get the container name from the Product entity type metadata
+ var productEntityType = context.Model.FindEntityType(typeof(Product));
+ var containerName = productEntityType!.GetContainer()!;
+ var container = database.GetContainer(containerName);
+
+ var preInsertTriggerDefinition = new TriggerProperties
+ {
+ Id = "PreInsertTrigger",
+ TriggerType = TriggerType.Pre,
+ TriggerOperation = TriggerOperation.Create,
+ Body = @"
+function preInsertTrigger() {
+ var context = getContext();
+ var request = context.getRequest();
+ var doc = request.getBody();
+
+ // Log the trigger execution using the same partition key as the document being created
+ var logEntry = {
+ id: 'log_' + Math.random().toString().replace('.', ''),
+ $type: 'TriggerExecutionLog',
+ TriggerName: 'PreInsertTrigger',
+ Operation: 'INSERT',
+ DocumentId: doc.id,
+ ExecutedAt: new Date().toISOString(),
+ PartitionKey: doc.PartitionKey // Use the same partition key as the document
+ };
+
+ // Create a separate document to track trigger execution
+ var collection = context.getCollection();
+ var accepted = collection.createDocument(collection.getSelfLink(), logEntry);
+ if (!accepted) throw new Error('Failed to log trigger execution');
+}"
+ };
+
+ try
+ {
+ await container.Scripts.CreateTriggerAsync(preInsertTriggerDefinition);
+ }
+ catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict)
+ {
+ // Trigger already exists, replace it
+ await container.Scripts.ReplaceTriggerAsync(preInsertTriggerDefinition);
+ }
+
+ var postDeleteTriggerDefinition = new TriggerProperties
+ {
+ Id = "PostDeleteTrigger",
+ TriggerType = TriggerType.Post,
+ TriggerOperation = TriggerOperation.Delete,
+ Body = @"
+function postDeleteTrigger() {
+ var context = getContext();
+
+ // For delete operations, we can't access the deleted document
+ // So we'll just create a log entry with a timestamp-based ID
+ var logEntry = {
+ id: 'log_' + Math.random().toString().replace('.', ''),
+ $type: 'TriggerExecutionLog',
+ TriggerName: 'PostDeleteTrigger',
+ Operation: 'DELETE',
+ DocumentId: 'deleted_document',
+ ExecutedAt: new Date().toISOString(),
+ PartitionKey: 'Products' // Use the same partition key as Product documents
+ };
+
+ // Create a separate document to track trigger execution
+ var collection = context.getCollection();
+ var accepted = collection.createDocument(collection.getSelfLink(), logEntry);
+ if (!accepted) throw new Error('Failed to log trigger execution');
+}"
+ };
+
+ try
+ {
+ await container.Scripts.CreateTriggerAsync(postDeleteTriggerDefinition);
+ }
+ catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict)
+ {
+ // Trigger already exists, replace it
+ await container.Scripts.ReplaceTriggerAsync(postDeleteTriggerDefinition);
+ }
+
+ var updateTriggerDefinition = new TriggerProperties
+ {
+ Id = "UpdateTrigger",
+ TriggerType = TriggerType.Pre,
+ TriggerOperation = TriggerOperation.Replace,
+ Body = @"
+function updateTrigger() {
+ var context = getContext();
+ var request = context.getRequest();
+ var doc = request.getBody();
+
+ // Log the trigger execution using the same partition key as the document being updated
+ var logEntry = {
+ id: 'log_' + Math.random().toString().replace('.', ''),
+ $type: 'TriggerExecutionLog',
+ TriggerName: 'UpdateTrigger',
+ Operation: 'UPDATE',
+ DocumentId: doc.id,
+ ExecutedAt: new Date().toISOString(),
+ PartitionKey: doc.PartitionKey // Use the same partition key as the document
+ };
+
+ // Create a separate document to track trigger execution
+ var collection = context.getCollection();
+ var accepted = collection.createDocument(collection.getSelfLink(), logEntry);
+ if (!accepted) throw new Error('Failed to log trigger execution');
+}"
+ };
+
+ try
+ {
+ await container.Scripts.CreateTriggerAsync(updateTriggerDefinition);
+ }
+ catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict)
+ {
+ // Trigger already exists, replace it
+ await container.Scripts.ReplaceTriggerAsync(updateTriggerDefinition);
+ }
+ }
+
+ protected class TriggersContext(DbContextOptions options) : DbContext(options)
+ {
+ public DbSet Products { get; set; } = null!;
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.Property(e => e.Id);
+ entity.HasPartitionKey(e => e.PartitionKey);
+ entity.HasTrigger("PreInsertTrigger", TriggerType.Pre, TriggerOperation.Create);
+ entity.HasTrigger("PostDeleteTrigger", TriggerType.Post, TriggerOperation.Delete);
+ entity.HasTrigger("UpdateTrigger", TriggerType.Pre, TriggerOperation.Replace);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasPartitionKey(e => e.PartitionKey);
+ });
+ }
+ }
+
+ protected class Product
+ {
+ public int Id { get; set; }
+ public string? Name { get; set; }
+ public decimal Price { get; set; }
+ public string PartitionKey { get; set; } = "Products";
+ }
+
+ protected class TriggerExecutionLog
+ {
+ public string Id { get; set; } = null!;
+ public string TriggerName { get; set; } = null!;
+ public string Operation { get; set; } = null!;
+ public string DocumentId { get; set; } = null!;
+ public DateTime ExecutedAt { get; set; }
+ public string PartitionKey { get; set; } = "Products";
+ }
+}
diff --git a/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs b/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs
index 1aec9dff2ef..9bd78b48acf 100644
--- a/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs
+++ b/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs
@@ -658,8 +658,8 @@ public bool IsConceptualNull(IProperty property)
public class FakeEntityType : Annotatable, IEntityType
{
- public IEntityType BaseType
- => throw new NotImplementedException();
+ public IEntityType? BaseType
+ => null;
public string DefiningNavigationName
=> throw new NotImplementedException();
@@ -962,10 +962,10 @@ ITrigger IEntityType.FindDeclaredTrigger(string name)
=> throw new NotImplementedException();
IEnumerable IReadOnlyEntityType.GetDeclaredTriggers()
- => throw new NotImplementedException();
+ => Enumerable.Empty();
IEnumerable IEntityType.GetDeclaredTriggers()
- => throw new NotImplementedException();
+ => Enumerable.Empty();
public IComplexProperty FindComplexProperty(string name)
=> throw new NotImplementedException();
diff --git a/test/EFCore.Cosmos.Tests/Extensions/CosmosBuilderExtensionsTest.cs b/test/EFCore.Cosmos.Tests/Extensions/CosmosBuilderExtensionsTest.cs
index 8c10c4e3843..1888a84920f 100644
--- a/test/EFCore.Cosmos.Tests/Extensions/CosmosBuilderExtensionsTest.cs
+++ b/test/EFCore.Cosmos.Tests/Extensions/CosmosBuilderExtensionsTest.cs
@@ -3,6 +3,8 @@
// ReSharper disable once CheckNamespace
+using Microsoft.Azure.Cosmos.Scripts;
+
namespace Microsoft.EntityFrameworkCore.Cosmos;
public class CosmosBuilderExtensionsTest
@@ -187,6 +189,48 @@ public void Can_set_etag_concurrency_property()
Assert.Equal("_etag", etagProperty.GetJsonPropertyName());
}
+ [ConditionalFact]
+ public void Can_use_convention_trigger_builder()
+ {
+ var modelBuilder = CreateConventionModelBuilder();
+ var entityType = modelBuilder.Entity().Metadata;
+
+ var trigger = entityType.AddTrigger("TestTrigger");
+ var conventionTrigger = (IConventionTrigger)trigger;
+
+ var triggerBuilder = conventionTrigger.Builder;
+
+ Assert.NotNull(triggerBuilder.HasTriggerType(TriggerType.Pre, fromDataAnnotation: true));
+ Assert.Equal(TriggerType.Pre, trigger.GetTriggerType());
+ Assert.Equal(ConfigurationSource.DataAnnotation, conventionTrigger.GetTriggerTypeConfigurationSource());
+
+ Assert.Null(triggerBuilder.HasTriggerType(TriggerType.Post, fromDataAnnotation: false));
+ Assert.Equal(TriggerType.Pre, trigger.GetTriggerType());
+ Assert.Equal(ConfigurationSource.DataAnnotation, conventionTrigger.GetTriggerTypeConfigurationSource());
+
+ Assert.NotNull(triggerBuilder.HasTriggerType(TriggerType.Post, fromDataAnnotation: true));
+ Assert.Equal(TriggerType.Post, trigger.GetTriggerType());
+ Assert.Equal(ConfigurationSource.DataAnnotation, conventionTrigger.GetTriggerTypeConfigurationSource());
+ }
+
+ [ConditionalFact]
+ public void Can_create_trigger()
+ {
+ var modelBuilder = CreateConventionModelBuilder();
+
+ TriggerBuilder triggerBuilder = null!;
+ modelBuilder.Entity(entity =>
+ {
+ triggerBuilder = entity.HasTrigger("TestTrigger", TriggerType.Pre, TriggerOperation.Create);
+ });
+
+ var entityType = modelBuilder.Model.FindEntityType(typeof(Customer))!;
+ var trigger = entityType.FindDeclaredTrigger("TestTrigger")!;
+
+ Assert.Equal(TriggerType.Pre, trigger.GetTriggerType());
+ Assert.Equal(TriggerOperation.Create, trigger.GetTriggerOperation());
+ }
+
protected virtual ModelBuilder CreateConventionModelBuilder()
=> CosmosTestHelpers.Instance.CreateConventionBuilder();
diff --git a/test/EFCore.Cosmos.Tests/Infrastructure/CosmosModelValidatorTest.cs b/test/EFCore.Cosmos.Tests/Infrastructure/CosmosModelValidatorTest.cs
index 9c80faaa216..42a84f37529 100644
--- a/test/EFCore.Cosmos.Tests/Infrastructure/CosmosModelValidatorTest.cs
+++ b/test/EFCore.Cosmos.Tests/Infrastructure/CosmosModelValidatorTest.cs
@@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
using Microsoft.Azure.Cosmos;
+using Microsoft.Azure.Cosmos.Scripts;
using Microsoft.EntityFrameworkCore.Cosmos.Internal;
using Newtonsoft.Json.Linq;
@@ -581,6 +582,62 @@ private class ComplexTypeInCollection
public string Value { get; set; }
}
+ [ConditionalFact]
+ public virtual void Detects_trigger_on_derived_type()
+ {
+ var modelBuilder = CreateConventionModelBuilder();
+ modelBuilder.Entity();
+ modelBuilder.Entity().HasTrigger("TestTrigger", TriggerType.Pre, TriggerOperation.Create);
+
+ VerifyError(
+ CosmosStrings.TriggerOnDerivedType("TestTrigger", nameof(SpecialCustomer), nameof(Customer)),
+ modelBuilder);
+ }
+
+ [ConditionalFact]
+ public virtual void Detects_trigger_without_type()
+ {
+ var modelBuilder = CreateConventionModelBuilder();
+ var entityType = modelBuilder.Entity().Metadata;
+
+ entityType.AddTrigger("TestTrigger");
+
+ VerifyError(
+ CosmosStrings.TriggerMissingType("TestTrigger", nameof(Customer)),
+ modelBuilder);
+ }
+
+ [ConditionalFact]
+ public virtual void Detects_trigger_missing_operation()
+ {
+ var modelBuilder = CreateConventionModelBuilder();
+ var entityType = modelBuilder.Entity().Metadata;
+
+ var trigger = entityType.AddTrigger("TestTrigger");
+ trigger.SetTriggerType(TriggerType.Pre);
+
+ VerifyError(
+ CosmosStrings.TriggerMissingOperation("TestTrigger", nameof(Customer)),
+ modelBuilder);
+ }
+
+ [ConditionalFact]
+ public virtual void Passes_with_valid_triggers()
+ {
+ var modelBuilder = CreateConventionModelBuilder();
+ modelBuilder.Entity()
+ .HasTrigger("PreTrigger", TriggerType.Pre, TriggerOperation.Create);
+ modelBuilder.Entity()
+ .HasTrigger("PostTrigger", TriggerType.Post, TriggerOperation.Replace);
+
+ Validate(modelBuilder);
+ }
+
+ protected class SpecialCustomer : Customer
+ {
+ public string SpecialProperty { get; set; }
+ }
+
private class RememberMyName
{
public string Id { get; set; }