diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
index cdb54a2ac2..f5bdee7357 100644
--- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
+++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
@@ -333,6 +333,26 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
continue;
}
+ // 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.
Entity generatedEntity = new(
@@ -354,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);
}
@@ -386,6 +411,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 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/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs
index d93b4edbcf..572354a549 100644
--- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs
+++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs
@@ -733,6 +733,33 @@ private void RemoveGeneratedAutoentities()
_runtimeConfigProvider.RemoveGeneratedAutoentitiesFromConfig();
}
+ ///
+ /// 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 process.
+ /// The entity name with whitespace removed and following characters capitalized.
+ protected static string RemoveWhitespaceAndCamelCase(string name)
+ {
+ StringBuilder result = new(name.Length);
+ bool capitalizeNext = false;
+
+ foreach (char character in name)
+ {
+ if (char.IsWhiteSpace(character))
+ {
+ capitalizeNext = true;
+ continue;
+ }
+
+ result.Append(capitalizeNext ? char.ToUpperInvariant(character) : character);
+ capitalizeNext = false;
+ }
+
+ return result.ToString();
+ }
+
protected void PopulateDatabaseObjectForEntity(
Entity entity,
string entityName,
diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs
index 203de98aef..fe4402f926 100644
--- a/src/Service.Tests/Configuration/ConfigurationTests.cs
+++ b/src/Service.Tests/Configuration/ConfigurationTests.cs
@@ -5759,16 +5759,16 @@ 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.
///
/// 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]
- [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")]
+ public async Task TestAutoentitiesGeneratedWithUnusualElements(string includePattern, int patternType)
{
// Arrange
Dictionary autoentityMap = new()
@@ -5826,11 +5826,28 @@ 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;
+ 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 +5865,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");
@@ -5867,6 +5882,116 @@ public async Task TestAutoentitiesGeneratedWithDifferentSchemas(string includePa
}
}
+ ///
+ /// 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,
diff --git a/src/Service.Tests/DatabaseSchema-MsSql.sql b/src/Service.Tests/DatabaseSchema-MsSql.sql
index 4e87394aee..472cfac133 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');