From ab89cb7d26c035256415f35c5d4c395168aba32c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 19 May 2026 23:01:00 +0000
Subject: [PATCH 01/10] Initial plan
From 4aff5f40b81599d6e18dd28e8c08d8ca9773661f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 19 May 2026 23:13:30 +0000
Subject: [PATCH 02/10] fix autoentity name generation for whitespace in SQL
metadata provider
Agent-Logs-Url: https://github.com/Azure/data-api-builder/sessions/2051de6a-3432-4bd6-901c-3a3d6a8cc114
Co-authored-by: RubenCerna2079 <32799214+RubenCerna2079@users.noreply.github.com>
---
.../MsSqlMetadataProvider.cs | 23 +++++++++++++++++++
.../UnitTests/SqlMetadataProviderUnitTests.cs | 13 +++++++++++
2 files changed, 36 insertions(+)
diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
index cdb54a2ac2..2495951f55 100644
--- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
+++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
@@ -4,6 +4,7 @@
using System.Data;
using System.Data.Common;
using System.Net;
+using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Azure.DataApiBuilder.Config.DatabasePrimitives;
@@ -333,6 +334,8 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
continue;
}
+ entityName = SanitizeGeneratedEntityName(entityName);
+
// Create the entity using the template settings and permissions from the autoentity configuration.
// Currently the source type is always Table for auto-generated entities from database objects.
Entity generatedEntity = new(
@@ -412,5 +415,25 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
return resultArray;
}
+
+ internal static string SanitizeGeneratedEntityName(string name)
+ {
+ StringBuilder sanitizedName = new(name.Length);
+ bool capitalizeNext = false;
+
+ foreach (char character in name)
+ {
+ if (char.IsWhiteSpace(character))
+ {
+ capitalizeNext = true;
+ continue;
+ }
+
+ sanitizedName.Append(capitalizeNext ? char.ToUpperInvariant(character) : character);
+ capitalizeNext = false;
+ }
+
+ return sanitizedName.ToString();
+ }
}
}
diff --git a/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs b/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs
index dd6ad7d27e..45c1ccecfc 100644
--- a/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs
+++ b/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs
@@ -610,6 +610,19 @@ private static async Task SetupTestFixtureAndInferMetadata()
await _sqlMetadataProvider.InitializeAsync();
}
+ ///
+ /// Ensures autoentity-generated entity names are sanitized for whitespace.
+ ///
+ [DataTestMethod]
+ [DataRow("dbo_Order Items", "dbo_OrderItems")]
+ [DataRow("Order Items", "OrderItems")]
+ [DataRow("NoWhitespace", "NoWhitespace")]
+ public void SanitizeGeneratedEntityName_SanitizesWhitespace(string input, string expected)
+ {
+ string actual = MsSqlMetadataProvider.SanitizeGeneratedEntityName(input);
+ Assert.AreEqual(expected, actual);
+ }
+
///
/// Ensures that the query that returns the tables that will be generated
/// into entities from the autoentities configuration returns the expected result.
From 4dbae6ed3ad0b4ff21c6d15a9a6f2fc1b42385ba Mon Sep 17 00:00:00 2001
From: Ruben Cerna
Date: Tue, 19 May 2026 17:23:50 -0700
Subject: [PATCH 03/10] Add tests
---
.../MsSqlMetadataProvider.cs | 27 ++++----------
.../MetadataProviders/SqlMetadataProvider.cs | 25 +++++++++++++
src/Service.Tests/DatabaseSchema-MsSql.sql | 37 ++++++++++++-------
.../UnitTests/SqlMetadataProviderUnitTests.cs | 14 +------
4 files changed, 57 insertions(+), 46 deletions(-)
diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
index 2495951f55..3ca6e96de1 100644
--- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
+++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
@@ -334,6 +334,7 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
continue;
}
+ // Sanitize the entity name by ensuring all whitespace characters are removed.
entityName = SanitizeGeneratedEntityName(entityName);
// Create the entity using the template settings and permissions from the autoentity configuration.
@@ -389,6 +390,12 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
_runtimeConfigProvider.AddMergedEntitiesToConfig(entities);
}
+ ///
+ /// Queries the database for autoentities based on the provided autoentity definition.
+ ///
+ /// The name of the autoentity definition.
+ /// The autoentity definition containing patterns for inclusion, exclusion, and name.
+ /// A JsonArray containing the queried autoentities, or null if none are found.
public async Task QueryAutoentitiesAsync(string autoentityName, Autoentity autoentity)
{
string include = string.Join(",", autoentity.Patterns.Include);
@@ -415,25 +422,5 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
return resultArray;
}
-
- internal static string SanitizeGeneratedEntityName(string name)
- {
- StringBuilder sanitizedName = new(name.Length);
- bool capitalizeNext = false;
-
- foreach (char character in name)
- {
- if (char.IsWhiteSpace(character))
- {
- capitalizeNext = true;
- continue;
- }
-
- sanitizedName.Append(capitalizeNext ? char.ToUpperInvariant(character) : character);
- capitalizeNext = false;
- }
-
- return sanitizedName.ToString();
- }
}
}
diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs
index 3a85ba823e..f879e9560d 100644
--- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs
+++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs
@@ -733,6 +733,31 @@ private void RemoveGeneratedAutoentities()
_runtimeConfigProvider.RemoveGeneratedAutoentitiesFromConfig();
}
+ ///
+ /// Sanitizes the generated entity name by removing whitespace and capitalizing the next character after whitespace.
+ ///
+ /// The entity name to be sanitized.
+ /// The sanitized entity name.
+ protected static string SanitizeGeneratedEntityName(string name)
+ {
+ StringBuilder sanitizedName = new(name.Length);
+ bool capitalizeNext = false;
+
+ foreach (char character in name)
+ {
+ if (char.IsWhiteSpace(character))
+ {
+ capitalizeNext = true;
+ continue;
+ }
+
+ sanitizedName.Append(capitalizeNext ? char.ToUpperInvariant(character) : character);
+ capitalizeNext = false;
+ }
+
+ return sanitizedName.ToString();
+ }
+
protected void PopulateDatabaseObjectForEntity(
Entity entity,
string entityName,
diff --git a/src/Service.Tests/DatabaseSchema-MsSql.sql b/src/Service.Tests/DatabaseSchema-MsSql.sql
index 4e87394aee..cd95cab950 100644
--- a/src/Service.Tests/DatabaseSchema-MsSql.sql
+++ b/src/Service.Tests/DatabaseSchema-MsSql.sql
@@ -63,6 +63,7 @@ DROP TABLE IF EXISTS date_only_table;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS user_profiles;
DROP TABLE IF EXISTS default_books;
+DROP TABLE IF EXISTS [Order Items];
DROP SCHEMA IF EXISTS [foo];
DROP SCHEMA IF EXISTS [bar];
COMMIT;
@@ -321,21 +322,23 @@ CREATE TABLE mappedbookmarks
bkname nvarchar(50) NOT NULL
)
-create Table fte_data(
-id int IDENTITY(5001,1),
-u_id int DEFAULT 2,
-name varchar(50),
-position varchar(20),
-salary int default 20,
-PRIMARY KEY(id, u_id)
+create Table fte_data
+(
+ id int IDENTITY(5001,1),
+ u_id int DEFAULT 2,
+ name varchar(50),
+ position varchar(20),
+ salary int default 20,
+ PRIMARY KEY(id, u_id)
);
-create Table intern_data(
-id int,
-months int default 2 NOT NULL,
-name varchar(50),
-salary int default 15,
-PRIMARY KEY(id, months)
+create Table intern_data
+(
+ id int,
+ months int default 2 NOT NULL,
+ name varchar(50),
+ salary int default 15,
+ PRIMARY KEY(id, months)
);
create table books_sold
@@ -394,6 +397,11 @@ CREATE TABLE default_books(
title NVARCHAR(100)
);
+CREATE TABLE [Order Items](
+ id INT PRIMARY KEY,
+ productname VARCHAR(100),
+);
+
ALTER TABLE books
ADD CONSTRAINT book_publisher_fk
FOREIGN KEY (publisher_id)
@@ -826,3 +834,6 @@ INSERT INTO date_only_table( event_date, event_time, event_timestamp)
VALUES ('2023-01-01', '08:30:00', '2023-01-01 08:30:00'),
('2023-02-15', '12:45:00', '2023-02-15 12:45:00'),
('2023-03-30', '17:15:00', '2023-03-30 17:15:00');
+
+INSERT INTO [Order Items](id, productname)
+VALUES (1, 'Sample Product');
diff --git a/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs b/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs
index 45c1ccecfc..88d53adcda 100644
--- a/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs
+++ b/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs
@@ -610,19 +610,6 @@ private static async Task SetupTestFixtureAndInferMetadata()
await _sqlMetadataProvider.InitializeAsync();
}
- ///
- /// Ensures autoentity-generated entity names are sanitized for whitespace.
- ///
- [DataTestMethod]
- [DataRow("dbo_Order Items", "dbo_OrderItems")]
- [DataRow("Order Items", "OrderItems")]
- [DataRow("NoWhitespace", "NoWhitespace")]
- public void SanitizeGeneratedEntityName_SanitizesWhitespace(string input, string expected)
- {
- string actual = MsSqlMetadataProvider.SanitizeGeneratedEntityName(input);
- Assert.AreEqual(expected, actual);
- }
-
///
/// Ensures that the query that returns the tables that will be generated
/// into entities from the autoentities configuration returns the expected result.
@@ -633,6 +620,7 @@ public void SanitizeGeneratedEntityName_SanitizesWhitespace(string input, string
[DataRow(new string[] { "dbo.%book%" }, new string[] { "dbo.%books%" }, "{schema}_{object}_exclude_books", new string[] { "book" }, "books")]
[DataRow(new string[] { "dbo.%book%", "dbo.%publish%" }, new string[] { }, "{object}", new string[] { "book", "publish" }, "")]
[DataRow(new string[] { }, new string[] { "dbo.%book%" }, "{object}s", new string[] { "" }, "book")]
+ [DataRow(new string[] { "dbo.Order Items" }, new string[] { }, "{schema}_{object}", new string[] { "OrderItems" }, "")]
public async Task CheckAutoentitiesQuery(string[] include, string[] exclude, string name, string[] includeObject, string excludeObject)
{
// Arrange
From f322f1f11e58ee74ddf08977316bf6eff33bc4b3 Mon Sep 17 00:00:00 2001
From: Ruben Cerna
Date: Fri, 22 May 2026 15:23:28 -0700
Subject: [PATCH 04/10] Fix tests
---
.../Configuration/ConfigurationTests.cs | 37 +++++++++++++++----
.../UnitTests/SqlMetadataProviderUnitTests.cs | 1 -
2 files changed, 29 insertions(+), 9 deletions(-)
diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs
index 203de98aef..69e3016ccc 100644
--- a/src/Service.Tests/Configuration/ConfigurationTests.cs
+++ b/src/Service.Tests/Configuration/ConfigurationTests.cs
@@ -5759,16 +5759,17 @@ public async Task TestAutoentitiesWithSameObjectDifferentSchemas()
}
///
- /// Ensures that autoentities are properly generated into in-memory entities when entities have non-default schemas.
+ /// Ensures that autoentities are properly generated into in-memory entities when entities have unusual elements such as non-default schemas or spaces in their names.
///
/// The pattern to include for autoentities
/// Boolean that indicates if the pattern is for the foo schema
///
[TestCategory(TestCategory.MSSQL)]
[DataTestMethod]
- [DataRow("foo.%", true, DisplayName = "Test Autoentities with foo schema")]
- [DataRow("bar.%", false, DisplayName = "Test Autoentities with bar schema")]
- public async Task TestAutoentitiesGeneratedWithDifferentSchemas(string includePattern, bool isPatternFoo)
+ [DataRow("foo.%", 0, DisplayName = "Test Autoentities with foo schema")]
+ [DataRow("bar.%", 1, DisplayName = "Test Autoentities with bar schema")]
+ [DataRow("dbo.Order Items", 2, DisplayName = "Test Autoentities with object with spaces")]
+ public async Task TestAutoentitiesGeneratedWithUnusualElements(string includePattern, int patternType)
{
// Arrange
Dictionary autoentityMap = new()
@@ -5826,11 +5827,33 @@ public async Task TestAutoentitiesGeneratedWithDifferentSchemas(string includePa
using (HttpClient client = server.CreateClient())
{
// Act
- string path = isPatternFoo ? "foo_magazines" : "bar_magazines";
+ string path;
+ string item;
+ string expectedResponseFragment;
+ switch (patternType)
+ {
+ case 0:
+ path = "foo_magazines";
+ item = "title";
+ expectedResponseFragment = @"""title"":""Vogue""";
+ break;
+ case 1:
+ path = "bar_magazines";
+ item = "comic_name";
+ expectedResponseFragment = @"""comic_name"":""NotVogue""";
+ break;
+ case 2:
+ path = "dbo_OrderItems";
+ item = "productname";
+ expectedResponseFragment = @"""productname"":""Sample Product""";
+ break;
+ default:
+ throw new ArgumentException("Invalid pattern type");
+ }
+
using HttpRequestMessage restRequest = new(HttpMethod.Get, $"/api/{path}");
using HttpResponseMessage restResponse = await client.SendAsync(restRequest);
- string item = isPatternFoo ? "title" : "comic_name";
string graphqlQuery = $@"
{{
{path} {{
@@ -5848,8 +5871,6 @@ public async Task TestAutoentitiesGeneratedWithDifferentSchemas(string includePa
HttpResponseMessage graphqlResponse = await client.SendAsync(graphqlRequest);
// Assert
- string expectedResponseFragment = isPatternFoo ? @"""title"":""Vogue""" : @"""comic_name"":""NotVogue""";
-
// Verify REST response
Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode, "REST request to auto-generated entity should succeed");
diff --git a/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs b/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs
index 88d53adcda..dd6ad7d27e 100644
--- a/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs
+++ b/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs
@@ -620,7 +620,6 @@ private static async Task SetupTestFixtureAndInferMetadata()
[DataRow(new string[] { "dbo.%book%" }, new string[] { "dbo.%books%" }, "{schema}_{object}_exclude_books", new string[] { "book" }, "books")]
[DataRow(new string[] { "dbo.%book%", "dbo.%publish%" }, new string[] { }, "{object}", new string[] { "book", "publish" }, "")]
[DataRow(new string[] { }, new string[] { "dbo.%book%" }, "{object}s", new string[] { "" }, "book")]
- [DataRow(new string[] { "dbo.Order Items" }, new string[] { }, "{schema}_{object}", new string[] { "OrderItems" }, "")]
public async Task CheckAutoentitiesQuery(string[] include, string[] exclude, string name, string[] includeObject, string excludeObject)
{
// Arrange
From 9d24e8ff976a96d67ce124f1542f5a2885096534 Mon Sep 17 00:00:00 2001
From: Ruben Cerna
Date: Fri, 22 May 2026 15:26:01 -0700
Subject: [PATCH 05/10] Fix syntax error
---
src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
index 3ca6e96de1..fe4c4612db 100644
--- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
+++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
@@ -4,7 +4,6 @@
using System.Data;
using System.Data.Common;
using System.Net;
-using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Azure.DataApiBuilder.Config.DatabasePrimitives;
From 4701eabb3ae4c92a4797a9eb388059703ceb01a2 Mon Sep 17 00:00:00 2001
From: Ruben Cerna
Date: Fri, 22 May 2026 15:54:47 -0700
Subject: [PATCH 06/10] Changes based on copilot comments
---
src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs | 2 +-
src/Service.Tests/Configuration/ConfigurationTests.cs | 2 +-
src/Service.Tests/DatabaseSchema-MsSql.sql | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
index fe4c4612db..6438a7a663 100644
--- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
+++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
@@ -394,7 +394,7 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
///
/// The name of the autoentity definition.
/// The autoentity definition containing patterns for inclusion, exclusion, and name.
- /// A JsonArray containing the queried autoentities, or null if none are found.
+ /// A JsonArray containing the queried autoentities, or an empty array if none are found.
public async Task QueryAutoentitiesAsync(string autoentityName, Autoentity autoentity)
{
string include = string.Join(",", autoentity.Patterns.Include);
diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs
index 69e3016ccc..15342308fd 100644
--- a/src/Service.Tests/Configuration/ConfigurationTests.cs
+++ b/src/Service.Tests/Configuration/ConfigurationTests.cs
@@ -5762,7 +5762,7 @@ public async Task TestAutoentitiesWithSameObjectDifferentSchemas()
/// Ensures that autoentities are properly generated into in-memory entities when entities have unusual elements such as non-default schemas or spaces in their names.
///
/// The pattern to include for autoentities
- /// Boolean that indicates if the pattern is for the foo schema
+ /// Integer that indicates which input pattern is being used
///
[TestCategory(TestCategory.MSSQL)]
[DataTestMethod]
diff --git a/src/Service.Tests/DatabaseSchema-MsSql.sql b/src/Service.Tests/DatabaseSchema-MsSql.sql
index cd95cab950..472cfac133 100644
--- a/src/Service.Tests/DatabaseSchema-MsSql.sql
+++ b/src/Service.Tests/DatabaseSchema-MsSql.sql
@@ -399,7 +399,7 @@ CREATE TABLE default_books(
CREATE TABLE [Order Items](
id INT PRIMARY KEY,
- productname VARCHAR(100),
+ productname VARCHAR(100)
);
ALTER TABLE books
From 615e98283a97b9fe721fad266d066958b653ee40 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 9 Jun 2026 18:57:20 +0000
Subject: [PATCH 07/10] Address review feedback: rename sanitizer, guard empty
names, improve collision message, split whitespace test
---
.../MsSqlMetadataProvider.cs | 28 ++++-
.../MetadataProviders/SqlMetadataProvider.cs | 16 +--
.../Configuration/ConfigurationTests.cs | 118 ++++++++++++++++--
3 files changed, 145 insertions(+), 17 deletions(-)
diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
index 6438a7a663..f5bdee7357 100644
--- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
+++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
@@ -333,8 +333,25 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
continue;
}
- // Sanitize the entity name by ensuring all whitespace characters are removed.
- entityName = SanitizeGeneratedEntityName(entityName);
+ // Remove whitespace from the entity name and camelCase-join words so the result is
+ // a valid identifier for REST paths and GraphQL singular/plural names.
+ string rawEntityName = entityName;
+ entityName = RemoveWhitespaceAndCamelCase(entityName);
+
+ if (string.IsNullOrEmpty(entityName))
+ {
+ _logger.LogError(
+ "Skipping autoentity generation: entity name '{rawEntityName}' for schema '{schemaName}' resolves to an empty string after whitespace removal for autoentities definition '{autoentityName}'.",
+ rawEntityName, schemaName, autoentityName);
+ continue;
+ }
+
+ if (rawEntityName != entityName)
+ {
+ _logger.LogDebug(
+ "Entity name '{rawEntityName}' was normalized to '{entityName}' by removing whitespace.",
+ rawEntityName, entityName);
+ }
// Create the entity using the template settings and permissions from the autoentity configuration.
// Currently the source type is always Table for auto-generated entities from database objects.
@@ -357,10 +374,15 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
// Add the generated entity to the linking entities dictionary.
// This allows the entity to be processed later during metadata population.
+ // A collision can occur when two database objects produce the same entity name after
+ // whitespace removal (e.g. "Order Item" and "OrderItem" both yield "OrderItem").
if (!entities.TryAdd(entityName, generatedEntity) || !runtimeConfig.TryAddGeneratedAutoentityNameToDataSourceName(entityName, autoentityName))
{
+ string collisionMessage = rawEntityName != entityName
+ ? $"Entity '{entityName}' (normalized from '{rawEntityName}' in schema '{schemaName}') conflicts with autoentity pattern '{autoentityName}'. Use --patterns.exclude to skip it."
+ : $"Entity '{entityName}' conflicts with autoentity pattern '{autoentityName}'. Use --patterns.exclude to skip it.";
throw new DataApiBuilderException(
- message: $"Entity '{entityName}' conflicts with autoentity pattern '{autoentityName}'. Use --patterns.exclude to skip it.",
+ message: collisionMessage,
statusCode: HttpStatusCode.BadRequest,
subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization);
}
diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs
index 9db0b48827..572354a549 100644
--- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs
+++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs
@@ -734,13 +734,15 @@ private void RemoveGeneratedAutoentities()
}
///
- /// Sanitizes the generated entity name by removing whitespace and capitalizing the next character after whitespace.
+ /// Removes whitespace from the generated entity name and capitalizes the character
+ /// immediately following each removed whitespace (camelCase join).
+ /// For example, "Order Items" becomes "OrderItems" and "dbo_Order Items" becomes "dbo_OrderItems".
///
- /// The entity name to be sanitized.
- /// The sanitized entity name.
- protected static string SanitizeGeneratedEntityName(string name)
+ /// The entity name to process.
+ /// The entity name with whitespace removed and following characters capitalized.
+ protected static string RemoveWhitespaceAndCamelCase(string name)
{
- StringBuilder sanitizedName = new(name.Length);
+ StringBuilder result = new(name.Length);
bool capitalizeNext = false;
foreach (char character in name)
@@ -751,11 +753,11 @@ protected static string SanitizeGeneratedEntityName(string name)
continue;
}
- sanitizedName.Append(capitalizeNext ? char.ToUpperInvariant(character) : character);
+ result.Append(capitalizeNext ? char.ToUpperInvariant(character) : character);
capitalizeNext = false;
}
- return sanitizedName.ToString();
+ return result.ToString();
}
protected void PopulateDatabaseObjectForEntity(
diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs
index 15342308fd..fe4402f926 100644
--- a/src/Service.Tests/Configuration/ConfigurationTests.cs
+++ b/src/Service.Tests/Configuration/ConfigurationTests.cs
@@ -5759,7 +5759,7 @@ public async Task TestAutoentitiesWithSameObjectDifferentSchemas()
}
///
- /// Ensures that autoentities are properly generated into in-memory entities when entities have unusual elements such as non-default schemas or spaces in their names.
+ /// Ensures that autoentities are properly generated into in-memory entities when entities have unusual elements such as non-default schemas.
///
/// The pattern to include for autoentities
/// Integer that indicates which input pattern is being used
@@ -5768,7 +5768,6 @@ public async Task TestAutoentitiesWithSameObjectDifferentSchemas()
[DataTestMethod]
[DataRow("foo.%", 0, DisplayName = "Test Autoentities with foo schema")]
[DataRow("bar.%", 1, DisplayName = "Test Autoentities with bar schema")]
- [DataRow("dbo.Order Items", 2, DisplayName = "Test Autoentities with object with spaces")]
public async Task TestAutoentitiesGeneratedWithUnusualElements(string includePattern, int patternType)
{
// Arrange
@@ -5842,11 +5841,6 @@ public async Task TestAutoentitiesGeneratedWithUnusualElements(string includePat
item = "comic_name";
expectedResponseFragment = @"""comic_name"":""NotVogue""";
break;
- case 2:
- path = "dbo_OrderItems";
- item = "productname";
- expectedResponseFragment = @"""productname"":""Sample Product""";
- break;
default:
throw new ArgumentException("Invalid pattern type");
}
@@ -5888,6 +5882,116 @@ public async Task TestAutoentitiesGeneratedWithUnusualElements(string includePat
}
}
+ ///
+ /// Ensures that autoentities are generated with valid names when the SQL object name contains spaces.
+ /// Whitespace is removed and the following character is capitalized (camelCase join), so that the
+ /// resulting entity name is a valid REST path segment and GraphQL type name.
+ /// For example, "dbo.[Order Items]" with the default pattern "{schema}_{object}" produces the
+ /// entity name "dbo_OrderItems" — not "dbo_Order Items".
+ ///
+ [TestCategory(TestCategory.MSSQL)]
+ [TestMethod]
+ public async Task TestAutoentitiesGeneratedWithSpacesInObjectName()
+ {
+ // Arrange
+ const string EXPECTED_ENTITY_NAME = "dbo_OrderItems";
+ const string EXPECTED_ITEM_FIELD = "productname";
+ const string EXPECTED_RESPONSE_FRAGMENT = @"""productname"":""Sample Product""";
+
+ Dictionary autoentityMap = new()
+ {
+ {
+ "SpacedObjectAutoEntity", new Autoentity(
+ Patterns: new AutoentityPatterns(
+ Include: new[] { "dbo.Order Items" },
+ Exclude: null,
+ Name: null
+ ),
+ Template: new AutoentityTemplate(
+ Rest: new EntityRestOptions(Enabled: true),
+ GraphQL: new EntityGraphQLOptions(
+ Singular: string.Empty,
+ Plural: string.Empty,
+ Enabled: true
+ ),
+ Health: null,
+ Cache: null
+ ),
+ Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }
+ )
+ }
+ };
+
+ DataSource dataSource = new(DatabaseType.MSSQL,
+ GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null);
+
+ RuntimeConfig configuration = new(
+ Schema: "TestAutoentitiesSpacesSchema",
+ DataSource: dataSource,
+ Runtime: new(
+ Rest: new(Enabled: true),
+ GraphQL: new(Enabled: true),
+ Mcp: new(Enabled: false),
+ Host: new(
+ Cors: null,
+ Authentication: new Config.ObjectModel.AuthenticationOptions(
+ Provider: nameof(EasyAuthType.StaticWebApps),
+ Jwt: null
+ )
+ )
+ ),
+ Entities: new(new Dictionary()),
+ Autoentities: new RuntimeAutoentities(autoentityMap)
+ );
+
+ File.WriteAllText(CUSTOM_CONFIG_FILENAME, configuration.ToJson());
+
+ string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" };
+ using (TestServer server = new(Program.CreateWebHostBuilder(args)))
+ using (HttpClient client = server.CreateClient())
+ {
+ // Assert that the sanitized entity name "dbo_OrderItems" is reachable via REST,
+ // explicitly confirming the generated name is EXPECTED_ENTITY_NAME and not "dbo_Order Items".
+ using HttpRequestMessage restRequest = new(HttpMethod.Get, $"/api/{EXPECTED_ENTITY_NAME}");
+ using HttpResponseMessage restResponse = await client.SendAsync(restRequest);
+ Assert.AreEqual(
+ HttpStatusCode.OK,
+ restResponse.StatusCode,
+ $"REST path '/api/{EXPECTED_ENTITY_NAME}' should exist; the entity name must be sanitized from 'dbo_Order Items' to '{EXPECTED_ENTITY_NAME}'.");
+
+ string restResponseBody = await restResponse.Content.ReadAsStringAsync();
+ Assert.IsTrue(!string.IsNullOrEmpty(restResponseBody), "REST response should contain data");
+ Assert.IsTrue(restResponseBody.Contains(EXPECTED_RESPONSE_FRAGMENT));
+
+ // Also verify via GraphQL using the sanitized name as the query root field.
+ string graphqlQuery = $@"
+ {{
+ {EXPECTED_ENTITY_NAME} {{
+ items {{
+ {EXPECTED_ITEM_FIELD}
+ }}
+ }}
+ }}";
+
+ object graphqlPayload = new { query = graphqlQuery };
+ HttpRequestMessage graphqlRequest = new(HttpMethod.Post, "/graphql")
+ {
+ Content = JsonContent.Create(graphqlPayload)
+ };
+ HttpResponseMessage graphqlResponse = await client.SendAsync(graphqlRequest);
+
+ Assert.AreEqual(
+ HttpStatusCode.OK,
+ graphqlResponse.StatusCode,
+ $"GraphQL query for '{EXPECTED_ENTITY_NAME}' should succeed with the sanitized entity name.");
+
+ string graphqlResponseBody = await graphqlResponse.Content.ReadAsStringAsync();
+ Assert.IsTrue(!string.IsNullOrEmpty(graphqlResponseBody), "GraphQL response should contain data");
+ Assert.IsFalse(graphqlResponseBody.Contains("errors"), "GraphQL response should not contain errors");
+ Assert.IsTrue(graphqlResponseBody.Contains(EXPECTED_RESPONSE_FRAGMENT));
+ }
+ }
+
///
/// Tests that DAB fails if the entities generated from autoentities property
/// do not contain unique parameters such as rest path, graphql singular/plural names,
From 9343c7ee26cb975f3a5cac61115db4a2aecf43b8 Mon Sep 17 00:00:00 2001
From: Ruben Cerna
Date: Mon, 29 Jun 2026 15:23:48 -0700
Subject: [PATCH 08/10] Changes based on comments
---
.../MsSqlMetadataProvider.cs | 11 +-
.../Configuration/ConfigurationTests.cs | 106 ++----------------
src/Service.Tests/DatabaseSchema-MsSql.sql | 18 +--
3 files changed, 23 insertions(+), 112 deletions(-)
diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
index f5bdee7357..22cad71baa 100644
--- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
+++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
@@ -302,6 +302,7 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig();
Dictionary entities = new();
+ Dictionary entityNameToRawEntity = new();
foreach ((string autoentityName, Autoentity autoentity) in autoentities)
{
int addedEntities = 0;
@@ -378,9 +379,12 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
// whitespace removal (e.g. "Order Item" and "OrderItem" both yield "OrderItem").
if (!entities.TryAdd(entityName, generatedEntity) || !runtimeConfig.TryAddGeneratedAutoentityNameToDataSourceName(entityName, autoentityName))
{
- string collisionMessage = rawEntityName != entityName
- ? $"Entity '{entityName}' (normalized from '{rawEntityName}' in schema '{schemaName}') conflicts with autoentity pattern '{autoentityName}'. Use --patterns.exclude to skip it."
- : $"Entity '{entityName}' conflicts with autoentity pattern '{autoentityName}'. Use --patterns.exclude to skip it.";
+ string checkEntityName = entityNameToRawEntity.ContainsKey(entityName) && !rawEntityName.Contains(" ")
+ ? entityNameToRawEntity[entityName]
+ : rawEntityName;
+ string collisionMessage = checkEntityName.Contains(" ")
+ ? $"Entity '{entityName}' normalized from '{rawEntityName}' conflicts in autoentity pattern '{autoentityName}'. Use --patterns.exclude to skip it."
+ : $"Entity '{entityName}' conflicts in autoentity pattern '{autoentityName}'. Use --patterns.exclude to skip it.";
throw new DataApiBuilderException(
message: collisionMessage,
statusCode: HttpStatusCode.BadRequest,
@@ -397,6 +401,7 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
}
addedEntities++;
+ entityNameToRawEntity.Add(entityName, rawEntityName);
}
if (addedEntities == 0)
diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs
index fe4402f926..3e30958c7d 100644
--- a/src/Service.Tests/Configuration/ConfigurationTests.cs
+++ b/src/Service.Tests/Configuration/ConfigurationTests.cs
@@ -5759,126 +5759,38 @@ public async Task TestAutoentitiesWithSameObjectDifferentSchemas()
}
///
- /// Ensures that autoentities are properly generated into in-memory entities when entities have unusual elements such as non-default schemas.
+ /// Ensures that autoentities are properly generated into in-memory entities when entities have non-default schemas.
///
/// The pattern to include for autoentities
- /// Integer that indicates which input pattern is being used
+ /// Boolean that indicates if the pattern is for the foo schema
///
[TestCategory(TestCategory.MSSQL)]
[DataTestMethod]
- [DataRow("foo.%", 0, DisplayName = "Test Autoentities with foo schema")]
- [DataRow("bar.%", 1, DisplayName = "Test Autoentities with bar schema")]
- public async Task TestAutoentitiesGeneratedWithUnusualElements(string includePattern, int patternType)
+ [DataRow("foo.%", true, DisplayName = "Test Autoentities with foo schema")]
+ [DataRow("bar.%", false, DisplayName = "Test Autoentities with bar schema")]
+ public async Task TestAutoentitiesGeneratedWithDifferentSchemas(string includePattern, bool isPatternFoo)
{
// Arrange
Dictionary autoentityMap = new()
- {
- {
- "PublisherAutoEntity", new Autoentity(
- Patterns: new AutoentityPatterns(
- Include: new[] { includePattern },
- Exclude: null,
- Name: null
- ),
- Template: new AutoentityTemplate(
- Rest: new EntityRestOptions(Enabled: true),
- GraphQL: new EntityGraphQLOptions(
- Singular: string.Empty,
- Plural: string.Empty,
- Enabled: true
- ),
- Health: null,
- Cache: null
- ),
- Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }
- )
- }
- };
-
- // Create DataSource for MSSQL connection
- DataSource dataSource = new(DatabaseType.MSSQL,
- GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null);
-
- // Build complete runtime configuration with autoentities
- RuntimeConfig configuration = new(
- Schema: "TestAutoentitiesSchema",
- DataSource: dataSource,
- Runtime: new(
- Rest: new(Enabled: true),
- GraphQL: new(Enabled: true),
- Mcp: new(Enabled: false),
- Host: new(
- Cors: null,
- Authentication: new Config.ObjectModel.AuthenticationOptions(
- Provider: nameof(EasyAuthType.StaticWebApps),
- Jwt: null
- )
- )
- ),
- Entities: new(new Dictionary()),
- Autoentities: new RuntimeAutoentities(autoentityMap)
- );
-
- File.WriteAllText(CUSTOM_CONFIG_FILENAME, configuration.ToJson());
-
- string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" };
- using (TestServer server = new(Program.CreateWebHostBuilder(args)))
using (HttpClient client = server.CreateClient())
{
// Act
- string path;
- string item;
- string expectedResponseFragment;
- switch (patternType)
- {
- case 0:
- path = "foo_magazines";
- item = "title";
- expectedResponseFragment = @"""title"":""Vogue""";
- break;
- case 1:
- path = "bar_magazines";
- item = "comic_name";
- expectedResponseFragment = @"""comic_name"":""NotVogue""";
- break;
- default:
- throw new ArgumentException("Invalid pattern type");
- }
-
+ string path = isPatternFoo ? "foo_magazines" : "bar_magazines";
using HttpRequestMessage restRequest = new(HttpMethod.Get, $"/api/{path}");
using HttpResponseMessage restResponse = await client.SendAsync(restRequest);
+ string item = isPatternFoo ? "title" : "comic_name";
string graphqlQuery = $@"
{{
{path} {{
- items {{
- {item}
- }}
- }}
- }}";
-
- object graphqlPayload = new { query = graphqlQuery };
- HttpRequestMessage graphqlRequest = new(HttpMethod.Post, "/graphql")
- {
- Content = JsonContent.Create(graphqlPayload)
- };
HttpResponseMessage graphqlResponse = await client.SendAsync(graphqlRequest);
// Assert
+ string expectedResponseFragment = isPatternFoo ? @"""title"":""Vogue""" : @"""comic_name"":""NotVogue""";
+
// Verify REST response
Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode, "REST request to auto-generated entity should succeed");
- string restResponseBody = await restResponse.Content.ReadAsStringAsync();
- Assert.IsTrue(!string.IsNullOrEmpty(restResponseBody), "REST response should contain data");
- Assert.IsTrue(restResponseBody.Contains(expectedResponseFragment));
-
- // Verify GraphQL response
- Assert.AreEqual(HttpStatusCode.OK, graphqlResponse.StatusCode, "GraphQL request to auto-generated entity should succeed");
-
- string graphqlResponseBody = await graphqlResponse.Content.ReadAsStringAsync();
- Assert.IsTrue(!string.IsNullOrEmpty(graphqlResponseBody), "GraphQL response should contain data");
- Assert.IsFalse(graphqlResponseBody.Contains("errors"), "GraphQL response should not contain errors");
- Assert.IsTrue(graphqlResponseBody.Contains(expectedResponseFragment));
}
}
diff --git a/src/Service.Tests/DatabaseSchema-MsSql.sql b/src/Service.Tests/DatabaseSchema-MsSql.sql
index 472cfac133..379f3f1321 100644
--- a/src/Service.Tests/DatabaseSchema-MsSql.sql
+++ b/src/Service.Tests/DatabaseSchema-MsSql.sql
@@ -310,20 +310,17 @@ CREATE TABLE GQLmappings (
column3 varchar(max)
)
-CREATE TABLE bookmarks
-(
+CREATE TABLE bookmarks (
id int IDENTITY(1,1) PRIMARY KEY,
bkname nvarchar(1000) NOT NULL
)
-CREATE TABLE mappedbookmarks
-(
+CREATE TABLE mappedbookmarks (
id int IDENTITY(1,1) PRIMARY KEY,
bkname nvarchar(50) NOT NULL
)
-create Table fte_data
-(
+create Table fte_data (
id int IDENTITY(5001,1),
u_id int DEFAULT 2,
name varchar(50),
@@ -332,8 +329,7 @@ create Table fte_data
PRIMARY KEY(id, u_id)
);
-create Table intern_data
-(
+create Table intern_data (
id int,
months int default 2 NOT NULL,
name varchar(50),
@@ -341,8 +337,7 @@ create Table intern_data
PRIMARY KEY(id, months)
);
-create table books_sold
-(
+create table books_sold (
id int PRIMARY KEY not null,
book_name varchar(50),
row_version rowversion,
@@ -351,8 +346,7 @@ create table books_sold
last_sold_on_date as last_sold_on,
)
-CREATE TABLE default_with_function_table
-(
+CREATE TABLE default_with_function_table (
id INT PRIMARY KEY IDENTITY(5001,1),
user_value INT,
[current_date] DATETIME DEFAULT GETDATE() NOT NULL,
From c1fbdf294a6b0c63800604a90a82c9085fb16087 Mon Sep 17 00:00:00 2001
From: Ruben Cerna
Date: Mon, 29 Jun 2026 15:27:06 -0700
Subject: [PATCH 09/10] Address comments
---
src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs | 2 +-
src/Core/Services/MetadataProviders/SqlMetadataProvider.cs | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
index 22cad71baa..de10934d80 100644
--- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
+++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
@@ -337,7 +337,7 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
// Remove whitespace from the entity name and camelCase-join words so the result is
// a valid identifier for REST paths and GraphQL singular/plural names.
string rawEntityName = entityName;
- entityName = RemoveWhitespaceAndCamelCase(entityName);
+ entityName = RemoveWhitespaceAddCamelCase(entityName);
if (string.IsNullOrEmpty(entityName))
{
diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs
index 2d7ca5ae92..f8cecc3e9e 100644
--- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs
+++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs
@@ -740,7 +740,7 @@ private void RemoveGeneratedAutoentities()
///
/// The entity name to process.
/// The entity name with whitespace removed and following characters capitalized.
- protected static string RemoveWhitespaceAndCamelCase(string name)
+ protected static string RemoveWhitespaceAddCamelCase(string name)
{
StringBuilder result = new(name.Length);
bool capitalizeNext = false;
From 3e6dc94afcae6d9616ad173e25677863a1ea1783 Mon Sep 17 00:00:00 2001
From: Ruben Cerna
Date: Mon, 29 Jun 2026 15:30:37 -0700
Subject: [PATCH 10/10] fix log
---
src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
index de10934d80..25cb8b63d3 100644
--- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
+++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
@@ -383,7 +383,7 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
? entityNameToRawEntity[entityName]
: rawEntityName;
string collisionMessage = checkEntityName.Contains(" ")
- ? $"Entity '{entityName}' normalized from '{rawEntityName}' conflicts in autoentity pattern '{autoentityName}'. Use --patterns.exclude to skip it."
+ ? $"Entity '{entityName}' normalized from '{checkEntityName}' from '{schemaName}' schema conflicts in autoentity pattern '{autoentityName}'. Use --patterns.exclude to skip it."
: $"Entity '{entityName}' conflicts in autoentity pattern '{autoentityName}'. Use --patterns.exclude to skip it.";
throw new DataApiBuilderException(
message: collisionMessage,