From 44f36c093b2281de9540f60d2b36135bdd10dc79 Mon Sep 17 00:00:00 2001
From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
Date: Tue, 3 Nov 2026 15:27:05 -0800
Subject: [PATCH 1/2] Harden structured data mutation boundaries
---
.../Demos/StructuredDataPlugin/Product.cs | 48 ++++
.../Demos/StructuredDataPlugin/Program.cs | 27 +-
.../StructuredDataOperation.cs | 7 +-
.../StructuredDataPluginFactory.cs | 37 ++-
.../StructuredDataServiceExtensions.cs | 192 +++++++++++--
.../StructuredDataServiceTests.cs | 257 ++++++++++++++++++
6 files changed, 518 insertions(+), 50 deletions(-)
diff --git a/dotnet/samples/Demos/StructuredDataPlugin/Product.cs b/dotnet/samples/Demos/StructuredDataPlugin/Product.cs
index 5a8c39231afb..c8d583be46d9 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 name used by the application to find the product.
+ ///
+ [Description("The current name of the product to update.")]
+ public string? Name { 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 name used by the application to find the product.
+ ///
+ [Description("The name of the product to delete.")]
+ public string? Name { get; set; }
+}
diff --git a/dotnet/samples/Demos/StructuredDataPlugin/Program.cs b/dotnet/samples/Demos/StructuredDataPlugin/Program.cs
index 41ac15a37149..734460dec1d3 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 for the mutation.
+ return await structuredDataService.Context.Set()
+ .SingleOrDefaultAsync(product => product.Name == input.Name, cancellationToken);
+ },
+ (input, product) => product.Price = input.Price);
+ var deleteFunction = structuredDataService.CreateDeleteFunction(
+ async (input, cancellationToken) =>
+ {
+ // This application-owned query is the row-scope boundary for the mutation.
+ return await structuredDataService.Context.Set()
+ .SingleOrDefaultAsync(product => product.Name == input.Name, cancellationToken);
+ });
+
+ var databasePlugin = KernelPluginFactory.CreateFromFunctions(
+ "ProductDatabasePlugin",
+ "Allows approved operations against products in the database",
+ [selectFunction, insertFunction, updateFunction, deleteFunction]);
kernelBuilder.Plugins.Add(databasePlugin);
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);
+ }
+ }
}
From 37a5c356147d16b1c3200b27fb0cbe4ae8f51c67 Mon Sep 17 00:00:00 2001
From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
Date: Tue, 3 Nov 2026 15:34:47 -0800
Subject: [PATCH 2/2] Use unique keys for sample mutations
---
.../samples/Demos/StructuredDataPlugin/Product.cs | 12 ++++++------
.../samples/Demos/StructuredDataPlugin/Program.cs | 14 +++++++-------
2 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/dotnet/samples/Demos/StructuredDataPlugin/Product.cs b/dotnet/samples/Demos/StructuredDataPlugin/Product.cs
index c8d583be46d9..f2f2269a5691 100644
--- a/dotnet/samples/Demos/StructuredDataPlugin/Product.cs
+++ b/dotnet/samples/Demos/StructuredDataPlugin/Product.cs
@@ -62,10 +62,10 @@ internal sealed class CreateProductRequest
internal sealed class UpdateProductRequest
{
///
- /// Gets or sets the name used by the application to find the product.
+ /// Gets or sets the unique product identifier.
///
- [Description("The current name of the product to update.")]
- public string? Name { get; set; }
+ [Description("The unique identifier of the product to update.")]
+ public Guid Id { get; set; }
///
/// Gets or sets the replacement price.
@@ -80,8 +80,8 @@ internal sealed class UpdateProductRequest
internal sealed class DeleteProductRequest
{
///
- /// Gets or sets the name used by the application to find the product.
+ /// Gets or sets the unique product identifier.
///
- [Description("The name of the product to delete.")]
- public string? Name { get; set; }
+ [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 734460dec1d3..e4f81afa8f2d 100644
--- a/dotnet/samples/Demos/StructuredDataPlugin/Program.cs
+++ b/dotnet/samples/Demos/StructuredDataPlugin/Program.cs
@@ -37,17 +37,17 @@ internal static async Task Main(string[] args)
var updateFunction = structuredDataService.CreateUpdateFunction(
async (input, cancellationToken) =>
{
- // This application-owned query is the row-scope boundary for the mutation.
+ // 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.Name == input.Name, cancellationToken);
+ .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 for the mutation.
+ // 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.Name == input.Name, cancellationToken);
+ .SingleOrDefaultAsync(product => product.Id == input.Id, cancellationToken);
});
var databasePlugin = KernelPluginFactory.CreateFromFunctions(
@@ -80,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}");
@@ -95,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)
{