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');