diff --git a/dotnet/samples/Demos/StructuredDataPlugin/Product.cs b/dotnet/samples/Demos/StructuredDataPlugin/Product.cs index 5a8c39231afb..f2f2269a5691 100644 --- a/dotnet/samples/Demos/StructuredDataPlugin/Product.cs +++ b/dotnet/samples/Demos/StructuredDataPlugin/Product.cs @@ -37,3 +37,51 @@ public sealed class Product [DatabaseGenerated(DatabaseGeneratedOption.Computed)] public DateTime? DateCreated { get; set; } } + +/// +/// Information the model may provide when creating a product. +/// +internal sealed class CreateProductRequest +{ + /// + /// Gets or sets the product name. + /// + [Description("The name of the product.")] + public string? Name { get; set; } + + /// + /// Gets or sets the product price. + /// + [Description("The price of the product in USD.")] + public decimal? Price { get; set; } +} + +/// +/// Information the model may provide when updating a product. +/// +internal sealed class UpdateProductRequest +{ + /// + /// Gets or sets the unique product identifier. + /// + [Description("The unique identifier of the product to update.")] + public Guid Id { get; set; } + + /// + /// Gets or sets the replacement price. + /// + [Description("The new price of the product in USD.")] + public decimal? Price { get; set; } +} + +/// +/// Information the model may provide when deleting a product. +/// +internal sealed class DeleteProductRequest +{ + /// + /// Gets or sets the unique product identifier. + /// + [Description("The unique identifier of the product to delete.")] + public Guid Id { get; set; } +} diff --git a/dotnet/samples/Demos/StructuredDataPlugin/Program.cs b/dotnet/samples/Demos/StructuredDataPlugin/Program.cs index 41ac15a37149..e4f81afa8f2d 100644 --- a/dotnet/samples/Demos/StructuredDataPlugin/Program.cs +++ b/dotnet/samples/Demos/StructuredDataPlugin/Program.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Data.Entity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.SemanticKernel; @@ -30,9 +31,29 @@ internal static async Task Main(string[] args) modelId: "gpt-4o", apiKey: config["OpenAI:ApiKey"]!); - // Add the database plugin using the factory with default operations - var databasePlugin = StructuredDataPluginFactory.CreateStructuredDataPlugin( - structuredDataService); + var selectFunction = structuredDataService.CreateSelectFunction(); + var insertFunction = structuredDataService.CreateInsertFunction( + input => new Product { Name = input.Name, Price = input.Price }); + var updateFunction = structuredDataService.CreateUpdateFunction( + async (input, cancellationToken) => + { + // This application-owned query is the row-scope boundary; real applications can add tenant or owner filters. + return await structuredDataService.Context.Set() + .SingleOrDefaultAsync(product => product.Id == input.Id, cancellationToken); + }, + (input, product) => product.Price = input.Price); + var deleteFunction = structuredDataService.CreateDeleteFunction( + async (input, cancellationToken) => + { + // This application-owned query is the row-scope boundary; real applications can add tenant or owner filters. + return await structuredDataService.Context.Set() + .SingleOrDefaultAsync(product => product.Id == input.Id, cancellationToken); + }); + + var databasePlugin = KernelPluginFactory.CreateFromFunctions( + "ProductDatabasePlugin", + "Allows approved operations against products in the database", + [selectFunction, insertFunction, updateFunction, deleteFunction]); kernelBuilder.Plugins.Add(databasePlugin); @@ -59,13 +80,13 @@ internal static async Task Main(string[] args) // Example 3: Updating records Console.WriteLine("\nUpdating a record..."); - var updatePrompt = "Update the price of 'Book' to 39.99 and keep its name"; + var updatePrompt = "Find the product named 'Book', then update its price to 39.99 using its ID"; var updateResult = await kernel.InvokePromptAsync(updatePrompt, new(settings)); Console.WriteLine($"Update Result: {updateResult}"); // Example 3: Updating records Console.WriteLine("\nDeleting a record..."); - var deletePrompt = "Delete the product 'Book'"; + var deletePrompt = "Find the product named 'Book', then delete it using its ID"; var deleteResult = await kernel.InvokePromptAsync(deletePrompt, new(settings)); Console.WriteLine($"Delete Result: {deleteResult}"); @@ -74,7 +95,7 @@ internal static async Task Main(string[] args) Console.WriteLine("You can try queries like:"); Console.WriteLine("- Find all products under $50"); Console.WriteLine("- Insert a new product with name 'Table' and price 19.99"); - Console.WriteLine("- Update the price of 'Table' to 25.99"); + Console.WriteLine("- Find 'Table', then update its price to 25.99 using its ID"); while (true) { diff --git a/dotnet/src/Plugins/Plugins.StructuredData.EntityFramework/StructuredDataOperation.cs b/dotnet/src/Plugins/Plugins.StructuredData.EntityFramework/StructuredDataOperation.cs index 1720d3cbff99..3ffeca05e954 100644 --- a/dotnet/src/Plugins/Plugins.StructuredData.EntityFramework/StructuredDataOperation.cs +++ b/dotnet/src/Plugins/Plugins.StructuredData.EntityFramework/StructuredDataOperation.cs @@ -31,14 +31,11 @@ namespace Microsoft.SemanticKernel; public static StructuredDataOperation Delete { get; } = new("Delete"); /// - /// The default set of supported operations. + /// The default set of supported operations. Mutation operations require explicit application mapping and scoped resolution. /// public static readonly HashSet Default = [ - Select, - Insert, - Update, - Delete + Select ]; /// diff --git a/dotnet/src/Plugins/Plugins.StructuredData.EntityFramework/StructuredDataPluginFactory.cs b/dotnet/src/Plugins/Plugins.StructuredData.EntityFramework/StructuredDataPluginFactory.cs index 12b917031a3a..68061bc2ab29 100644 --- a/dotnet/src/Plugins/Plugins.StructuredData.EntityFramework/StructuredDataPluginFactory.cs +++ b/dotnet/src/Plugins/Plugins.StructuredData.EntityFramework/StructuredDataPluginFactory.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.Data.Entity; -using System.Reflection; namespace Microsoft.SemanticKernel; @@ -18,10 +17,13 @@ public static class StructuredDataPluginFactory /// The type of DbContext to use for database operations. /// The entity type to perform operations on. /// The structured data service instance to use. - /// Optional collection of operations to support. Defaults to StructuredDataOperations.Default if not specified. + /// Optional collection of operations to support. Defaults to query-only operations if not specified. /// Optional description for the plugin. If not provided, a default description will be generated. /// A KernelPlugin instance configured for the specified entity type and operations. /// Thrown if the specified operation is not supported by the service. + /// + /// Thrown if a mutation operation is requested without the explicit mapping and scoped resolution required by the safe mutation APIs. + /// public static KernelPlugin CreateStructuredDataPlugin( StructuredDataService service, IEnumerable? operations = null, @@ -30,34 +32,29 @@ public static KernelPlugin CreateStructuredDataPlugin( where TEntity : class { operations ??= StructuredDataOperation.Default; - description ??= $"Allows CRUD operations against the {typeof(TEntity).Name} entity in the database"; + description ??= $"Allows queries against the {typeof(TEntity).Name} entity in the database"; var functions = new List(); - var extensionsType = typeof(StructuredDataServiceExtensions); foreach (var operation in operations) { - // Look for Create{Operation}Function method in the extensions - var methodName = $"Create{operation}Function"; - var method = extensionsType.GetMethod(methodName, BindingFlags.Public | BindingFlags.Static); - - if (method == null) + if (operation == StructuredDataOperation.Select) { - throw new InvalidOperationException( - $"Operation '{operation}' is not supported. Extension method '{methodName}' not found."); + functions.Add(service.CreateSelectFunction()); + continue; } - try - { - var genericMethod = method.MakeGenericMethod(typeof(TContext), typeof(TEntity)); - var function = (KernelFunction)genericMethod.Invoke(null, new object?[] { service, null })!; - functions.Add(function); - } - catch (Exception ex) when (ex is not InvalidOperationException) + if (operation == StructuredDataOperation.Insert || + operation == StructuredDataOperation.Update || + operation == StructuredDataOperation.Delete) { - throw new InvalidOperationException( - $"Failed to create function for operation '{operation}': {ex.Message}", ex); + // Mutation functions need application-owned request mapping and row scope, which this convenience factory cannot infer. + throw new NotSupportedException( + $"Operation '{operation}' requires an explicitly created safe mutation function. " + + $"Create it with a separate request type, then compose the plugin with {nameof(KernelPluginFactory)}."); } + + throw new InvalidOperationException($"Operation '{operation}' is not supported."); } return KernelPluginFactory.CreateFromFunctions( diff --git a/dotnet/src/Plugins/Plugins.StructuredData.EntityFramework/StructuredDataServiceExtensions.cs b/dotnet/src/Plugins/Plugins.StructuredData.EntityFramework/StructuredDataServiceExtensions.cs index f96f306c9a90..e5129eadbd9b 100644 --- a/dotnet/src/Plugins/Plugins.StructuredData.EntityFramework/StructuredDataServiceExtensions.cs +++ b/dotnet/src/Plugins/Plugins.StructuredData.EntityFramework/StructuredDataServiceExtensions.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Data.Entity; using System.Linq; @@ -14,38 +15,72 @@ namespace Microsoft.SemanticKernel; public static class StructuredDataServiceExtensions { /// - /// Creates a from the entity insert method. + /// Legacy insertion overload that accepted a persistence entity directly. /// /// The database context type. /// The entity type. /// Structured data service. /// Kernel function options. - /// Kernel function for entity insertion. + /// This method does not return. + /// + /// Always thrown because binding model input directly to a persistence entity does not provide a safe mutation boundary. + /// public static KernelFunction CreateInsertFunction( this StructuredDataService service, KernelFunctionFromMethodOptions? options = null) where TContext : DbContext where TEntity : class { + throw new NotSupportedException(DirectEntityMutationNotSupportedMessage); + } + + /// + /// Creates a that maps a separate input type to an entity before insertion. + /// + /// The database context type. + /// The entity type. + /// The model-visible insertion request type. + /// Structured data service. + /// Application-controlled mapping from model input to a persistence entity. + /// Kernel function options. + /// Kernel function for entity insertion. + /// + /// Thrown when is the entity type or belongs to the same inheritance hierarchy. + /// + public static KernelFunction CreateInsertFunction( + this StructuredDataService service, + Func mapInput, + KernelFunctionFromMethodOptions? options = null) + where TContext : DbContext + where TEntity : class + where TInput : class + { + Verify.NotNull(service); + Verify.NotNull(mapInput); + EnsureSeparateInputType(); + options ??= new KernelFunctionFromMethodOptions { FunctionName = $"Insert{typeof(TEntity).Name}Record", Description = $"Insert a {typeof(TEntity).Name} record into the database.", Parameters = [ - new KernelParameterMetadata("entity") + new KernelParameterMetadata("input") { - ParameterType = typeof(TEntity), - Schema = KernelJsonSchemaBuilder.Build(typeof(TEntity)), - Description = "Entity record information", + ParameterType = typeof(TInput), + Schema = KernelJsonSchemaBuilder.Build(typeof(TInput)), + Description = "Information for the record to insert", IsRequired = true }, ], ReturnParameter = new() { ParameterType = typeof(TEntity) }, }; - async Task InsertAsync(TEntity entity, CancellationToken cancellationToken) + async Task InsertAsync(TInput input, CancellationToken cancellationToken) { + // Only the application mapper may create the persistence entity, so model input cannot set unexposed members. + var entity = mapInput(input); + Verify.NotNull(entity); return await service.InsertAsync(entity, cancellationToken).ConfigureAwait(false); } @@ -59,7 +94,7 @@ async Task InsertAsync(TEntity entity, CancellationToken cancellationTo /// The entity type. /// Structured data service. /// Kernel function options. - /// Kernel function for entity insertion. + /// Kernel function for entity selection. public static KernelFunction CreateSelectFunction( this StructuredDataService service, KernelFunctionFromMethodOptions? options = null) @@ -98,78 +133,191 @@ Task> Select(string? filter = null, CancellationToken cancellatio } /// - /// Creates a from the entity update method. + /// Legacy update overload that accepted a persistence entity directly. /// /// The database context type. /// The entity type. /// Structured data service. /// Kernel function options. - /// Kernel function for entity update. + /// This method does not return. + /// + /// Always thrown because binding model input directly to a persistence entity does not provide a safe mutation boundary. + /// public static KernelFunction CreateUpdateFunction( this StructuredDataService service, KernelFunctionFromMethodOptions? options = null) where TContext : DbContext where TEntity : class { + throw new NotSupportedException(DirectEntityMutationNotSupportedMessage); + } + + /// + /// Creates a that resolves an authorized entity and applies an explicit update mapping. + /// + /// The database context type. + /// The entity type. + /// The model-visible update request type. + /// Structured data service. + /// + /// Application-controlled resolver that applies the caller's row scope and returns an entity tracked by + /// . + /// + /// Application-controlled mapping of allowed input members to the resolved entity. + /// Kernel function options. + /// Kernel function for entity update. + /// + /// Thrown when is the entity type or belongs to the same inheritance hierarchy. + /// + /// + /// Thrown during invocation when the resolver does not return an entity tracked by . + /// + public static KernelFunction CreateUpdateFunction( + this StructuredDataService service, + Func> resolveEntity, + Action applyUpdates, + KernelFunctionFromMethodOptions? options = null) + where TContext : DbContext + where TEntity : class + where TInput : class + { + Verify.NotNull(service); + Verify.NotNull(resolveEntity); + Verify.NotNull(applyUpdates); + EnsureSeparateInputType(); + options ??= new KernelFunctionFromMethodOptions { FunctionName = $"Update{typeof(TEntity).Name}Record", Description = $"Update a {typeof(TEntity).Name} record in the database.", Parameters = [ - new KernelParameterMetadata("entity") + new KernelParameterMetadata("input") { - ParameterType = typeof(TEntity), - Description = "Entity record information to update", + ParameterType = typeof(TInput), + Schema = KernelJsonSchemaBuilder.Build(typeof(TInput)), + Description = "Information identifying the record and allowed updates", IsRequired = true }, ], ReturnParameter = new() { ParameterType = typeof(int), Description = "Number of affected rows" }, }; - async Task UpdateAsync(TEntity entity, CancellationToken cancellationToken) + async Task UpdateAsync(TInput input, CancellationToken cancellationToken) { - return await service.UpdateAsync(entity, cancellationToken).ConfigureAwait(false); + // The resolver is the row-authorization boundary and must use this context so the entity cannot be swapped later. + var entity = await resolveEntity(input, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The requested entity is not available for update."); + + if (service.Context.Entry(entity).State == EntityState.Detached) + { + throw new InvalidOperationException("The update resolver must return an entity tracked by the structured data service."); + } + + // Only explicitly mapped request members may change server-managed entity state. + applyUpdates(input, entity); + return await service.Context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } return KernelFunctionFactory.CreateFromMethod(UpdateAsync, options); } /// - /// Creates a from the entity delete method. + /// Legacy deletion overload that accepted a persistence entity directly. /// /// The database context type. /// The entity type. /// Structured data service. /// Kernel function options. - /// Kernel function for entity deletion. + /// This method does not return. + /// + /// Always thrown because binding model input directly to a persistence entity does not provide a safe mutation boundary. + /// public static KernelFunction CreateDeleteFunction( this StructuredDataService service, KernelFunctionFromMethodOptions? options = null) where TContext : DbContext where TEntity : class { + throw new NotSupportedException(DirectEntityMutationNotSupportedMessage); + } + + /// + /// Creates a that resolves an authorized entity before deletion. + /// + /// The database context type. + /// The entity type. + /// The model-visible deletion request type. + /// Structured data service. + /// + /// Application-controlled resolver that applies the caller's row scope and returns an entity tracked by + /// . + /// + /// Kernel function options. + /// Kernel function for entity deletion. + /// + /// Thrown when is the entity type or belongs to the same inheritance hierarchy. + /// + /// + /// Thrown during invocation when the resolver does not return an entity tracked by . + /// + public static KernelFunction CreateDeleteFunction( + this StructuredDataService service, + Func> resolveEntity, + KernelFunctionFromMethodOptions? options = null) + where TContext : DbContext + where TEntity : class + where TInput : class + { + Verify.NotNull(service); + Verify.NotNull(resolveEntity); + EnsureSeparateInputType(); + options ??= new KernelFunctionFromMethodOptions { FunctionName = $"Delete{typeof(TEntity).Name}Record", Description = $"Delete a {typeof(TEntity).Name} record from the database.", Parameters = [ - new KernelParameterMetadata("entity") + new KernelParameterMetadata("input") { - ParameterType = typeof(TEntity), - Description = "Entity record to delete", + ParameterType = typeof(TInput), + Schema = KernelJsonSchemaBuilder.Build(typeof(TInput)), + Description = "Information identifying the record to delete", IsRequired = true }, ], ReturnParameter = new() { ParameterType = typeof(int), Description = "Number of affected rows" }, }; - async Task DeleteAsync(TEntity entity, CancellationToken cancellationToken) + async Task DeleteAsync(TInput input, CancellationToken cancellationToken) { - return await service.DeleteAsync(entity, cancellationToken).ConfigureAwait(false); + // The resolver is the row-authorization boundary and must use this context so only an approved row is removed. + var entity = await resolveEntity(input, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The requested entity is not available for deletion."); + + if (service.Context.Entry(entity).State == EntityState.Detached) + { + throw new InvalidOperationException("The deletion resolver must return an entity tracked by the structured data service."); + } + + service.Context.Set().Remove(entity); + return await service.Context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } return KernelFunctionFactory.CreateFromMethod(DeleteAsync, options); } + + private const string DirectEntityMutationNotSupportedMessage = + "Direct persistence-entity arguments are not supported for structured data mutation functions. " + + "Use the overload with a separate request type and application-controlled mapping or scoped entity resolution."; + + private static void EnsureSeparateInputType() + { + if (typeof(TEntity).IsAssignableFrom(typeof(TInput)) || typeof(TInput).IsAssignableFrom(typeof(TEntity))) + { + throw new NotSupportedException( + $"The mutation input type '{typeof(TInput).Name}' must be separate from the persistence entity type '{typeof(TEntity).Name}'."); + } + } } diff --git a/dotnet/src/Plugins/Plugins.UnitTests/StructuredData/StructuredDataServiceTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/StructuredData/StructuredDataServiceTests.cs index 5f328b9a1f00..50577bc9cc7a 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/StructuredData/StructuredDataServiceTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/StructuredData/StructuredDataServiceTests.cs @@ -4,6 +4,7 @@ using System.Data.Entity; using System.Data.Entity.Infrastructure; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.SemanticKernel; using Moq; @@ -31,6 +32,228 @@ public void ConstructorWithNullDbContextThrowsArgumentNullException() Assert.Throws(() => new StructuredDataService(dbContext: null!)); } + [Fact] + public void CreateStructuredDataPluginWithoutOperationsIncludesOnlySelect() + { + // Arrange + var mockContext = new Mock("TestConnection"); + using var service = new StructuredDataService(mockContext.Object); + + // Act + var plugin = StructuredDataPluginFactory.CreateStructuredDataPlugin(service); + + // Assert + Assert.Equal(1, plugin.FunctionCount); + Assert.True(plugin.TryGetFunction("SelectTestEntityRecords", out _)); + } + + [Fact] + public void CreateInsertFunctionWithoutInputMapperThrowsNotSupportedException() + { + // Arrange + var mockContext = new Mock("TestConnection"); + using var service = new StructuredDataService(mockContext.Object); + + // Act & Assert + Assert.Throws( + () => service.CreateInsertFunction()); + } + + [Fact] + public void CreateUpdateFunctionWithoutScopedResolverThrowsNotSupportedException() + { + // Arrange + var mockContext = new Mock("TestConnection"); + using var service = new StructuredDataService(mockContext.Object); + + // Act & Assert + Assert.Throws( + () => service.CreateUpdateFunction()); + } + + [Fact] + public void CreateDeleteFunctionWithoutScopedResolverThrowsNotSupportedException() + { + // Arrange + var mockContext = new Mock("TestConnection"); + using var service = new StructuredDataService(mockContext.Object); + + // Act & Assert + Assert.Throws( + () => service.CreateDeleteFunction()); + } + + [Fact] + public void CreateStructuredDataPluginWithMutationThrowsNotSupportedException() + { + // Arrange + var mockContext = new Mock("TestConnection"); + using var service = new StructuredDataService(mockContext.Object); + + // Act & Assert + Assert.Throws( + () => StructuredDataPluginFactory.CreateStructuredDataPlugin( + service, + [StructuredDataOperation.Insert])); + } + + [Fact] + public async Task CreateInsertFunctionUsesApplicationMapperAsync() + { + // Arrange + TestEntity? insertedEntity = null; + var mockContext = new Mock("TestConnection"); + var mockSet = new Mock>(); + mockSet + .Setup(set => set.Add(It.IsAny())) + .Callback(entity => insertedEntity = entity) + .Returns(entity => entity); + mockContext.Setup(context => context.Set()).Returns(mockSet.Object); + mockContext.Setup(context => context.SaveChangesAsync(It.IsAny())).ReturnsAsync(1); + using var service = new StructuredDataService(mockContext.Object); + var function = service.CreateInsertFunction( + input => new TestEntity + { + Name = input.Name, + ServerManagedValue = "server" + }); + + // Act + var result = await function.InvokeAsync( + new Kernel(), + new() { ["input"] = """{"Name":"client","ServerManagedValue":"client"}""" }); + + // Assert + Assert.NotNull(insertedEntity); + Assert.Equal("client", insertedEntity.Name); + Assert.Equal("server", insertedEntity.ServerManagedValue); + Assert.Same(insertedEntity, result.GetValue()); + Assert.DoesNotContain( + nameof(TestEntity.ServerManagedValue), + function.Metadata.Parameters.Single().Schema!.ToString(), + StringComparison.Ordinal); + } + + [Fact] + public void CreateInsertFunctionRejectsPersistenceEntityInput() + { + // Arrange + var mockContext = new Mock("TestConnection"); + using var service = new StructuredDataService(mockContext.Object); + + // Act & Assert + Assert.Throws( + () => service.CreateInsertFunction(input => input)); + } + + [Fact] + public async Task CreateUpdateFunctionRejectsEntityOutsideApplicationScopeAsync() + { + // Arrange + var updatesApplied = false; + var mockContext = new Mock("TestConnection"); + using var service = new StructuredDataService(mockContext.Object); + var function = service.CreateUpdateFunction( + (_, _) => Task.FromResult(null), + (_, _) => updatesApplied = true); + + // Act + var exception = await Assert.ThrowsAsync( + () => function.InvokeAsync(new Kernel(), new() { ["input"] = """{"Name":"missing"}""" })); + + // Assert + Assert.Equal("The requested entity is not available for update.", exception.Message); + Assert.False(updatesApplied); + mockContext.Verify(context => context.SaveChangesAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task CreateUpdateFunctionUsesScopedTrackedEntityAndExplicitMapperAsync() + { + // Arrange + using var context = new TrackingTestDbContext(); + var entity = new TestEntity + { + Id = 1, + Name = "original", + ServerManagedValue = "server" + }; + context.Set().Attach(entity); + using var service = new StructuredDataService(context); + var function = service.CreateUpdateFunction( + (input, _) => Task.FromResult(input.Name == "updated" ? entity : null), + (input, existingEntity) => existingEntity.Name = input.Name); + + // Act + var result = await function.InvokeAsync( + new Kernel(), + new() { ["input"] = """{"Name":"updated","ServerManagedValue":"client"}""" }); + + // Assert + Assert.Equal(1, result.GetValue()); + Assert.Equal("updated", entity.Name); + Assert.Equal("server", entity.ServerManagedValue); + } + + [Fact] + public async Task CreateUpdateFunctionRejectsDetachedResolverResultAsync() + { + // Arrange + using var context = new TrackingTestDbContext(); + using var service = new StructuredDataService(context); + var function = service.CreateUpdateFunction( + (_, _) => Task.FromResult(new TestEntity()), + (input, entity) => entity.Name = input.Name); + + // Act + var exception = await Assert.ThrowsAsync( + () => function.InvokeAsync(new Kernel(), new() { ["input"] = """{"Name":"updated"}""" })); + + // Assert + Assert.Equal("The update resolver must return an entity tracked by the structured data service.", exception.Message); + Assert.Equal(0, context.SaveChangesCallCount); + } + + [Fact] + public async Task CreateDeleteFunctionRejectsEntityOutsideApplicationScopeAsync() + { + // Arrange + var mockContext = new Mock("TestConnection"); + using var service = new StructuredDataService(mockContext.Object); + var function = service.CreateDeleteFunction( + (_, _) => Task.FromResult(null)); + + // Act + var exception = await Assert.ThrowsAsync( + () => function.InvokeAsync(new Kernel(), new() { ["input"] = """{"Name":"missing"}""" })); + + // Assert + Assert.Equal("The requested entity is not available for deletion.", exception.Message); + mockContext.Verify(context => context.SaveChangesAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task CreateDeleteFunctionUsesScopedTrackedEntityAsync() + { + // Arrange + using var context = new TrackingTestDbContext(); + var entity = new TestEntity { Id = 1, Name = "delete" }; + context.Set().Attach(entity); + using var service = new StructuredDataService(context); + var function = service.CreateDeleteFunction( + (input, _) => Task.FromResult(input.Name == "delete" ? entity : null)); + + // Act + var result = await function.InvokeAsync( + new Kernel(), + new() { ["input"] = """{"Name":"delete"}""" }); + + // Assert + Assert.Equal(1, result.GetValue()); + Assert.Equal(EntityState.Deleted, context.Entry(entity).State); + Assert.Equal(1, context.SaveChangesCallCount); + } + [Fact] public void SelectWithNoQueryReturnsAllEntities() { @@ -469,6 +692,7 @@ public class TestEntity { public int Id { get; set; } public string? Name { get; set; } + public string? ServerManagedValue { get; set; } public DateTimeOffset? NullableDate { get; set; } public int? NullableInt { get; set; } @@ -476,6 +700,21 @@ public class TestEntity public bool? NullableBool { get; set; } } + public sealed class InsertTestRequest + { + public string? Name { get; set; } + } + + public sealed class UpdateTestRequest + { + public string? Name { get; set; } + } + + public sealed class DeleteTestRequest + { + public string? Name { get; set; } + } + public class TestDbContext : DbContext { public TestDbContext(string nameOrConnectionString) : base(nameOrConnectionString) @@ -502,4 +741,22 @@ public virtual void Entry(TEntity entity, Action public DbSet? TestEntities { get; set; } } + + public sealed class TrackingTestDbContext : DbContext + { + public TrackingTestDbContext() : base("TestConnection") + { + Database.SetInitializer(null); + } + + public int SaveChangesCallCount { get; private set; } + + public DbSet? TestEntities { get; set; } + + public override Task SaveChangesAsync(CancellationToken cancellationToken) + { + this.SaveChangesCallCount++; + return Task.FromResult(1); + } + } }