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; }