diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index 166bcd1b35..67b9da0d5c 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -308,6 +308,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; @@ -339,6 +340,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 = RemoveWhitespaceAddCamelCase(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( @@ -360,15 +381,24 @@ 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 checkEntityName = entityNameToRawEntity.ContainsKey(entityName) && !rawEntityName.Contains(" ") + ? entityNameToRawEntity[entityName] + : rawEntityName; + string collisionMessage = checkEntityName.Contains(" ") + ? $"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: $"Entity '{entityName}' conflicts with autoentity pattern '{autoentityName}'. Use --patterns.exclude to skip it.", + message: collisionMessage, statusCode: HttpStatusCode.BadRequest, subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); } addedEntities++; + entityNameToRawEntity.Add(entityName, rawEntityName); } if (addedEntities == 0) @@ -384,6 +414,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 d9a71a1b66..fda9bdba43 100644 --- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs @@ -722,6 +722,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 RemoveWhitespaceAddCamelCase(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 ceeee4732c..d0054f6dcd 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -5786,12 +5786,51 @@ public async Task TestAutoentitiesWithSameObjectDifferentSchemas() public async Task TestAutoentitiesGeneratedWithDifferentSchemas(string includePattern, bool isPatternFoo) { // Arrange + Dictionary autoentityMap = new() + using (HttpClient client = server.CreateClient()) + { + // Act + 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} {{ + 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"); + + } + } + + /// + /// 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() { { - "PublisherAutoEntity", new Autoentity( + "SpacedObjectAutoEntity", new Autoentity( Patterns: new AutoentityPatterns( - Include: new[] { includePattern }, + Include: new[] { "dbo.Order Items" }, Exclude: null, Name: null ), @@ -5810,13 +5849,11 @@ public async Task TestAutoentitiesGeneratedWithDifferentSchemas(string includePa } }; - // 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", + Schema: "TestAutoentitiesSpacesSchema", DataSource: dataSource, Runtime: new( Rest: new(Enabled: true), @@ -5840,17 +5877,25 @@ public async Task TestAutoentitiesGeneratedWithDifferentSchemas(string includePa using (TestServer server = new(Program.CreateWebHostBuilder(args))) using (HttpClient client = server.CreateClient()) { - // Act - string path = isPatternFoo ? "foo_magazines" : "bar_magazines"; - using HttpRequestMessage restRequest = new(HttpMethod.Get, $"/api/{path}"); + // 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 item = isPatternFoo ? "title" : "comic_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 = $@" {{ - {path} {{ + {EXPECTED_ENTITY_NAME} {{ items {{ - {item} + {EXPECTED_ITEM_FIELD} }} }} }}"; @@ -5862,23 +5907,15 @@ 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"); - - 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"); + 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(expectedResponseFragment)); + Assert.IsTrue(graphqlResponseBody.Contains(EXPECTED_RESPONSE_FRAGMENT)); } } diff --git a/src/Service.Tests/DatabaseSchema-MsSql.sql b/src/Service.Tests/DatabaseSchema-MsSql.sql index 4e87394aee..379f3f1321 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; @@ -309,37 +310,34 @@ 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( -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 -( +create table books_sold ( id int PRIMARY KEY not null, book_name varchar(50), row_version rowversion, @@ -348,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, @@ -394,6 +391,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 +828,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');