Skip to content

Commit f0ecf98

Browse files
authored
Merge branch 'main' into copilot/support-xml-data-type-mssql
2 parents 27c857f + f6dd0df commit f0ecf98

11 files changed

Lines changed: 239 additions & 37 deletions

File tree

schemas/dab.draft.schema.json

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -819,15 +819,23 @@
819819
"additionalProperties": false,
820820
"properties": {
821821
"mcp": {
822-
"type": "object",
823-
"description": "MCP endpoint configuration",
824-
"additionalProperties": false,
825-
"properties": {
826-
"dml-tools": {
822+
"oneOf": [
823+
{
827824
"$ref": "#/$defs/boolean-or-string",
828-
"description": "Enable/disable all DML tools with default settings."
825+
"description": "Boolean shorthand: true enables dml-tools only (custom-tool remains false), false disables all MCP functionality."
826+
},
827+
{
828+
"type": "object",
829+
"description": "MCP endpoint configuration",
830+
"additionalProperties": false,
831+
"properties": {
832+
"dml-tools": {
833+
"$ref": "#/$defs/boolean-or-string",
834+
"description": "Enable/disable all DML tools with default settings."
835+
}
836+
}
829837
}
830-
}
838+
]
831839
},
832840
"rest": {
833841
"type": "object",

src/Azure.DataApiBuilder.Mcp/BuiltInTools/AggregateRecordsTool.cs

Lines changed: 28 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,7 @@ public class AggregateRecordsTool : IMcpTool
8888
""orderby"": {
8989
""type"": ""string"",
9090
""enum"": [""asc"", ""desc""],
91-
""description"": ""Sort grouped results by the aggregated value. Requires groupby."",
92-
""default"": ""desc""
91+
""description"": ""Sort direction for grouped results by the aggregated value. Only applies when groupby is provided; ignored otherwise.""
9392
},
9493
""having"": {
9594
""type"": ""object"",
@@ -394,23 +393,10 @@ public async Task<CallToolResult> ExecuteAsync(
394393
// Parse filter
395394
string? filter = root.TryGetProperty("filter", out JsonElement filterElement) ? filterElement.GetString() : null;
396395

397-
// Parse orderby
396+
// Parse orderby (validation deferred until after groupby is known;
397+
// if groupby is absent, orderby is silently ignored per #3279)
398398
bool userProvidedOrderby = root.TryGetProperty("orderby", out JsonElement orderbyElement) && !string.IsNullOrWhiteSpace(orderbyElement.GetString());
399399
string orderby = "desc";
400-
if (userProvidedOrderby)
401-
{
402-
string normalizedOrderby = (orderbyElement.GetString() ?? string.Empty).Trim().ToLowerInvariant();
403-
if (normalizedOrderby != "asc" && normalizedOrderby != "desc")
404-
{
405-
return McpResponseBuilder.BuildErrorResult(
406-
toolName,
407-
"InvalidArguments",
408-
$"Argument 'orderby' must be either 'asc' or 'desc' when provided. Got: '{orderbyElement.GetString()}'.",
409-
logger);
410-
}
411-
412-
orderby = normalizedOrderby;
413-
}
414400

415401
// Parse first
416402
int? first = null;
@@ -443,12 +429,28 @@ public async Task<CallToolResult> ExecuteAsync(
443429

444430
// Validate groupby-dependent parameters
445431
CallToolResult? dependencyError = ValidateGroupByDependencies(
446-
groupby.Count, userProvidedOrderby, first, after, toolName, logger);
432+
groupby.Count, ref userProvidedOrderby, first, after, toolName, logger);
447433
if (dependencyError != null)
448434
{
449435
return dependencyError;
450436
}
451437

438+
// Validate orderby value only when groupby is present (orderby is ignored otherwise)
439+
if (userProvidedOrderby)
440+
{
441+
string normalizedOrderby = (orderbyElement.GetString() ?? string.Empty).Trim().ToLowerInvariant();
442+
if (normalizedOrderby != "asc" && normalizedOrderby != "desc")
443+
{
444+
return McpResponseBuilder.BuildErrorResult(
445+
toolName,
446+
"InvalidArguments",
447+
$"Argument 'orderby' must be either 'asc' or 'desc' when provided. Got: '{orderbyElement.GetString()}'.",
448+
logger);
449+
}
450+
451+
orderby = normalizedOrderby;
452+
}
453+
452454
// Parse having clause
453455
Dictionary<string, double>? havingOperators = null;
454456
List<double>? havingInValues = null;
@@ -481,24 +483,24 @@ public async Task<CallToolResult> ExecuteAsync(
481483
}
482484

483485
/// <summary>
484-
/// Validates that parameters requiring groupby (orderby, first, after) are only used when groupby is present.
486+
/// Validates that parameters requiring groupby (first, after) are only used when groupby is present.
485487
/// Also validates that 'after' requires 'first'.
488+
/// Note: 'orderby' without groupby is silently ignored rather than rejected (see #3279).
486489
/// </summary>
487-
private static CallToolResult? ValidateGroupByDependencies(
490+
internal static CallToolResult? ValidateGroupByDependencies(
488491
int groupbyCount,
489-
bool userProvidedOrderby,
492+
ref bool userProvidedOrderby,
490493
int? first,
491494
string? after,
492495
string toolName,
493496
ILogger? logger)
494497
{
495498
if (groupbyCount == 0)
496499
{
497-
if (userProvidedOrderby)
498-
{
499-
return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments",
500-
"The 'orderby' parameter requires 'groupby' to be specified. Sorting applies to grouped aggregation results.", logger);
501-
}
500+
// Silently ignore orderby when groupby is not provided.
501+
// LLMs may send orderby due to schema defaults; this is harmless
502+
// since ordering is meaningless without grouping.
503+
userProvidedOrderby = false;
502504

503505
if (first.HasValue)
504506
{

src/Cli.Tests/ModuleInitializer.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ public static void Init()
6565
VerifierSettings.IgnoreMember<Entity>(entity => entity.EntityFirst);
6666
// Ignore the entity IsLinkingEntity as that's unimportant from a test standpoint.
6767
VerifierSettings.IgnoreMember<Entity>(entity => entity.IsLinkingEntity);
68+
// Ignore the entity IsAutoentity as that's unimportant from a test standpoint.
69+
VerifierSettings.IgnoreMember<Entity>(entity => entity.IsAutoentity);
6870
// Ignore the UserProvidedTtlOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter.
6971
VerifierSettings.IgnoreMember<EntityCacheOptions>(cacheOptions => cacheOptions.UserProvidedTtlOptions);
7072
// Ignore the UserProvidedEnabledOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter.

src/Config/ObjectModel/Entity.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ public record Entity
4545
[JsonIgnore]
4646
public bool IsLinkingEntity { get; init; }
4747

48+
[JsonIgnore]
49+
public bool IsAutoentity { get; init; }
50+
4851
[JsonConstructor]
4952
public Entity(
5053
EntitySource Source,
@@ -58,7 +61,8 @@ public Entity(
5861
bool IsLinkingEntity = false,
5962
EntityHealthCheckConfig? Health = null,
6063
string? Description = null,
61-
EntityMcpOptions? Mcp = null)
64+
EntityMcpOptions? Mcp = null,
65+
bool IsAutoentity = false)
6266
{
6367
this.Health = Health;
6468
this.Source = Source;
@@ -72,6 +76,7 @@ public Entity(
7276
this.IsLinkingEntity = IsLinkingEntity;
7377
this.Description = Description;
7478
this.Mcp = Mcp;
79+
this.IsAutoentity = IsAutoentity;
7580
}
7681

7782
/// <summary>

src/Config/ObjectModel/RuntimeConfig.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,11 @@ public bool TryAddGeneratedAutoentityNameToDataSourceName(string entityName, str
269269
return false;
270270
}
271271

272+
public bool RemoveGeneratedAutoentityNameFromDataSourceName(string entityName)
273+
{
274+
return _entityNameToDataSourceName.Remove(entityName);
275+
}
276+
272277
/// <summary>
273278
/// Constructor for runtimeConfig.
274279
/// To be used when setting up from cli json scenario.

src/Core/Configurations/RuntimeConfigProvider.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,4 +426,32 @@ public void AddMergedEntitiesToConfig(Dictionary<string, Entity> newEntities)
426426
};
427427
_configLoader.EditRuntimeConfig(newRuntimeConfig);
428428
}
429+
430+
public void RemoveGeneratedAutoentitiesFromConfig()
431+
{
432+
Dictionary<string, Entity> entities = new(_configLoader.RuntimeConfig!.Entities);
433+
List<string> removingEntities = new();
434+
435+
// Add entities that will be removed to a list first to avoid modifying the collection while iterating over it.
436+
foreach ((string name, Entity entity) in entities)
437+
{
438+
if (entity.IsAutoentity)
439+
{
440+
removingEntities.Add(name);
441+
}
442+
}
443+
444+
// Remove all autoentities from the config.
445+
foreach (string name in removingEntities)
446+
{
447+
entities.Remove(name);
448+
_configLoader.RuntimeConfig!.RemoveGeneratedAutoentityNameFromDataSourceName(name);
449+
}
450+
451+
RuntimeConfig newRuntimeConfig = _configLoader.RuntimeConfig! with
452+
{
453+
Entities = new(entities)
454+
};
455+
_configLoader.EditRuntimeConfig(newRuntimeConfig);
456+
}
429457
}

src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,8 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
349349
Health: autoentity.Template.Health,
350350
Fields: null,
351351
Relationships: null,
352-
Mappings: new());
352+
Mappings: new(),
353+
IsAutoentity: true);
353354

354355
// Add the generated entity to the linking entities dictionary.
355356
// This allows the entity to be processed later during metadata population.

src/Core/Services/MetadataProviders/SqlMetadataProvider.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,12 @@ public async Task InitializeAsync()
353353

354354
GenerateRestPathToEntityMap();
355355
InitODataParser();
356+
357+
if (_isValidateOnly)
358+
{
359+
RemoveGeneratedAutoentities();
360+
}
361+
356362
timer.Stop();
357363
_logger.LogTrace($"Done inferring Sql database schema in {timer.ElapsedMilliseconds}ms.");
358364
}
@@ -714,6 +720,15 @@ protected virtual Task GenerateAutoentitiesIntoEntities(IReadOnlyDictionary<stri
714720
throw new NotSupportedException($"{GetType().Name} does not support Autoentities yet.");
715721
}
716722

723+
/// <summary>
724+
/// Removes the entities that were generated from the autoentities property.
725+
/// This should only be done when we only want to validate the entities.
726+
/// </summary>
727+
private void RemoveGeneratedAutoentities()
728+
{
729+
_runtimeConfigProvider.RemoveGeneratedAutoentitiesFromConfig();
730+
}
731+
717732
protected void PopulateDatabaseObjectForEntity(
718733
Entity entity,
719734
string entityName,

src/Service.Tests/Configuration/HotReload/ConfigurationHotReloadTests.cs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ private static void GenerateConfigFile(
6464
string entityBackingColumn = "title",
6565
string entityExposedName = "title",
6666
string mcpEnabled = "true",
67+
string autoentityName = "autoentity_{object}",
6768
string configFileName = CONFIG_FILE_NAME)
6869
{
6970
File.WriteAllText(configFileName, @"
@@ -180,6 +181,29 @@ private static void GenerateConfigFile(
180181
}
181182
]
182183
}
184+
},
185+
""autoentities"": {
186+
""BooksAutoentities"": {
187+
""patterns"": {
188+
""include"": [ ""%book%"" ],
189+
""name"": """ + autoentityName + @"""
190+
},
191+
""template"": {
192+
""rest"": {
193+
""enabled"": true
194+
}
195+
},
196+
""permissions"": [
197+
{
198+
""role"": ""anonymous"",
199+
""actions"": [
200+
{
201+
""action"": ""*""
202+
}
203+
]
204+
}
205+
]
206+
}
183207
}
184208
}");
185209
}
@@ -788,6 +812,41 @@ await WaitForConditionAsync(
788812
Assert.AreEqual(HttpStatusCode.OK, restResult.StatusCode);
789813
}
790814

815+
/// <summary>
816+
/// Hot reload the configuration file so that it changes the name of the autoentity properties.
817+
/// Then we assert that the hot reload is successful by sending a request to the newly created autoentity.
818+
/// </summary>
819+
[TestCategory(MSSQL_ENVIRONMENT)]
820+
[TestMethod]
821+
public async Task HotReloadAutoentities()
822+
{
823+
// Arrange
824+
_writer = new StringWriter();
825+
Console.SetOut(_writer);
826+
827+
// Act
828+
HttpResponseMessage restResult = await _testClient.GetAsync($"rest/autoentity_books");
829+
830+
GenerateConfigFile(
831+
connectionString: $"{ConfigurationTests.GetConnectionStringFromEnvironmentConfig(TestCategory.MSSQL).Replace("\\", "\\\\")}",
832+
autoentityName: "HotReload_{object}");
833+
await WaitForConditionAsync(
834+
() => WriterContains(HOT_RELOAD_SUCCESS_MESSAGE),
835+
TimeSpan.FromSeconds(HOT_RELOAD_TIMEOUT_SECONDS),
836+
TimeSpan.FromMilliseconds(500));
837+
838+
HttpResponseMessage failRestResult = await _testClient.GetAsync($"rest/autoentity_books");
839+
HttpResponseMessage hotReloadRestResult = await _testClient.GetAsync($"rest/HotReload_books");
840+
841+
// Assert
842+
Assert.AreEqual(HttpStatusCode.OK, restResult.StatusCode,
843+
$"REST request before hot-reload failed when it was expected to succeed. Response: {await restResult.Content.ReadAsStringAsync()}");
844+
Assert.AreEqual(HttpStatusCode.NotFound, failRestResult.StatusCode,
845+
$"REST request after hot-reload succeeded when it was expected to fail. Response: {await failRestResult.Content.ReadAsStringAsync()}");
846+
Assert.AreEqual(HttpStatusCode.OK, hotReloadRestResult.StatusCode,
847+
$"REST request after hot-reload failed when it was expected to succeed. Response: {await hotReloadRestResult.Content.ReadAsStringAsync()}");
848+
}
849+
791850
/// <summary>
792851
/// /// (Warning: This test only currently works in the pipeline due to constrains of not
793852
/// being able to change from one database type to another, under normal circumstances

0 commit comments

Comments
 (0)