Skip to content

Commit a4505f8

Browse files
CopilotRubenCerna2079souvikghosh04anushakolan
authored
Remove whitespaces from autoentity-generated names to prevent invalid REST paths and GraphQL singular/plural tables for SQL objects with spaces (#3609)
## Why make this change? Tables with whitespace in names (for example, `Order Items`) were being auto-generated into entity names like `dbo_Order Items`, which then produced invalid REST paths as well as invalid GraphQL names for singular/plural tables which failed config validation. This change ensures autoentity name generation does not pass whitespace through to REST path and GraphQL singular/plural tables defaults. - Autoentity naming currently interpolates `{schema}` / `{object}` verbatim; whitespace in object names breaks `dab validate`. ## What is this change? - **Autoentity name normalization (MsSql metadata path)** - Added `RemoveWhitespaceAddCamelCase` in `SqlMetadataProvider` and applied it to generated `entity_name` before entity creation. The method name is intentionally specific to clarify it only removes whitespace (not other invalid characters). - Normalization removes whitespace and uppercases the character immediately following each removed whitespace (camelCase-join), e.g. `dbo_Order Items` → `dbo_OrderItems`. - Added a guard after normalization: if the result is empty (e.g. the raw entity name was entirely whitespace), an error is logged with the original name and schema for context, and the entry is skipped. - Collision detection is improved: when `TryAdd` fails because two database objects normalize to the same entity name (e.g. `"Order Item"` and `"OrderItem"` both yield `"OrderItem"`), the exception message includes the pre-normalization name and schema to make the collision diagnosable. ```csharp 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(); } ``` - **Focused test coverage** - Added a dedicated `TestAutoentitiesGeneratedWithSpacesInObjectName` test method that explicitly asserts the sanitized entity name `dbo_OrderItems` (via `const string EXPECTED_ENTITY_NAME`) and validates end-to-end REST and GraphQL access with that name. - Whitespace-handling validation is now separated from the non-default schema cases in `TestAutoentitiesGeneratedWithUnusualElements` for better readability and maintainability. ## How was this tested? - [ ] Integration Tests - [x] Unit Tests ## Sample Request(s) - Example CLI usage to reproduce/validate behavior: - `dab auto-config add mydef --patterns.include "dbo.Order Items" --patterns.name "{schema}_{object}"` - `dab validate` - Expected generated entity naming behavior: - SQL object: `dbo.[Order Items]` - Generated entity/REST path segment: `dbo_OrderItems` (no whitespace) --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: RubenCerna2079 <32799214+RubenCerna2079@users.noreply.github.com> Co-authored-by: Ruben Cerna <rcernaserna@microsoft.com> Co-authored-by: Souvik Ghosh <souvikofficial04@gmail.com> Co-authored-by: Anusha Kolan <anushakolan10@gmail.com>
1 parent fba075a commit a4505f8

4 files changed

Lines changed: 212 additions & 23 deletions

File tree

src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
308308

309309
RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig();
310310
Dictionary<string, Entity> entities = new();
311+
Dictionary<string, string> entityNameToRawEntity = new();
311312
foreach ((string autoentityName, Autoentity autoentity) in autoentities)
312313
{
313314
int addedEntities = 0;
@@ -339,6 +340,26 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
339340
continue;
340341
}
341342

343+
// Remove whitespace from the entity name and camelCase-join words so the result is
344+
// a valid identifier for REST paths and GraphQL singular/plural names.
345+
string rawEntityName = entityName;
346+
entityName = RemoveWhitespaceAddCamelCase(entityName);
347+
348+
if (string.IsNullOrEmpty(entityName))
349+
{
350+
_logger.LogError(
351+
"Skipping autoentity generation: entity name '{rawEntityName}' for schema '{schemaName}' resolves to an empty string after whitespace removal for autoentities definition '{autoentityName}'.",
352+
rawEntityName, schemaName, autoentityName);
353+
continue;
354+
}
355+
356+
if (rawEntityName != entityName)
357+
{
358+
_logger.LogDebug(
359+
"Entity name '{rawEntityName}' was normalized to '{entityName}' by removing whitespace.",
360+
rawEntityName, entityName);
361+
}
362+
342363
// Create the entity using the template settings and permissions from the autoentity configuration.
343364
// Currently the source type is always Table for auto-generated entities from database objects.
344365
Entity generatedEntity = new(
@@ -360,15 +381,24 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
360381

361382
// Add the generated entity to the linking entities dictionary.
362383
// This allows the entity to be processed later during metadata population.
384+
// A collision can occur when two database objects produce the same entity name after
385+
// whitespace removal (e.g. "Order Item" and "OrderItem" both yield "OrderItem").
363386
if (!entities.TryAdd(entityName, generatedEntity) || !runtimeConfig.TryAddGeneratedAutoentityNameToDataSourceName(entityName, autoentityName))
364387
{
388+
string checkEntityName = entityNameToRawEntity.ContainsKey(entityName) && !rawEntityName.Contains(" ")
389+
? entityNameToRawEntity[entityName]
390+
: rawEntityName;
391+
string collisionMessage = checkEntityName.Contains(" ")
392+
? $"Entity '{entityName}' normalized from '{checkEntityName}' from '{schemaName}' schema conflicts in autoentity pattern '{autoentityName}'. Use --patterns.exclude to skip it."
393+
: $"Entity '{entityName}' conflicts in autoentity pattern '{autoentityName}'. Use --patterns.exclude to skip it.";
365394
throw new DataApiBuilderException(
366-
message: $"Entity '{entityName}' conflicts with autoentity pattern '{autoentityName}'. Use --patterns.exclude to skip it.",
395+
message: collisionMessage,
367396
statusCode: HttpStatusCode.BadRequest,
368397
subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization);
369398
}
370399

371400
addedEntities++;
401+
entityNameToRawEntity.Add(entityName, rawEntityName);
372402
}
373403

374404
if (addedEntities == 0)
@@ -384,6 +414,12 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
384414
_runtimeConfigProvider.AddMergedEntitiesToConfig(entities);
385415
}
386416

417+
/// <summary>
418+
/// Queries the database for autoentities based on the provided autoentity definition.
419+
/// </summary>
420+
/// <param name="autoentityName">The name of the autoentity definition.</param>
421+
/// <param name="autoentity">The autoentity definition containing patterns for inclusion, exclusion, and name.</param>
422+
/// <returns>A JsonArray containing the queried autoentities, or an empty array if none are found.</returns>
387423
public async Task<JsonArray?> QueryAutoentitiesAsync(string autoentityName, Autoentity autoentity)
388424
{
389425
string include = string.Join(",", autoentity.Patterns.Include);

src/Core/Services/MetadataProviders/SqlMetadataProvider.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,33 @@ private void RemoveGeneratedAutoentities()
722722
_runtimeConfigProvider.RemoveGeneratedAutoentitiesFromConfig();
723723
}
724724

725+
/// <summary>
726+
/// Removes whitespace from the generated entity name and capitalizes the character
727+
/// immediately following each removed whitespace (camelCase join).
728+
/// For example, "Order Items" becomes "OrderItems" and "dbo_Order Items" becomes "dbo_OrderItems".
729+
/// </summary>
730+
/// <param name="name">The entity name to process.</param>
731+
/// <returns>The entity name with whitespace removed and following characters capitalized.</returns>
732+
protected static string RemoveWhitespaceAddCamelCase(string name)
733+
{
734+
StringBuilder result = new(name.Length);
735+
bool capitalizeNext = false;
736+
737+
foreach (char character in name)
738+
{
739+
if (char.IsWhiteSpace(character))
740+
{
741+
capitalizeNext = true;
742+
continue;
743+
}
744+
745+
result.Append(capitalizeNext ? char.ToUpperInvariant(character) : character);
746+
capitalizeNext = false;
747+
}
748+
749+
return result.ToString();
750+
}
751+
725752
protected void PopulateDatabaseObjectForEntity(
726753
Entity entity,
727754
string entityName,

src/Service.Tests/Configuration/ConfigurationTests.cs

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5882,6 +5882,118 @@ public async Task TestAutoentitiesGeneratedWithDifferentSchemas(string includePa
58825882
}
58835883
}
58845884

5885+
/// <summary>
5886+
/// Ensures that autoentities are generated with valid names when the SQL object name contains spaces.
5887+
/// Whitespace is removed and the following character is capitalized (camelCase join), so that the
5888+
/// resulting entity name is a valid REST path segment and GraphQL type name.
5889+
/// For example, "dbo.[Order Items]" with the default pattern "{schema}_{object}" produces the
5890+
/// entity name "dbo_OrderItems" — not "dbo_Order Items".
5891+
/// </summary>
5892+
[TestCategory(TestCategory.MSSQL)]
5893+
[DataTestMethod]
5894+
[DataRow("dbo.Order Items", "{schema}_{object}", "dbo_orderItems", DisplayName = "Test Autoentities with schema and object name containing spaces")]
5895+
[DataRow("dbo.Order Items", "{object}", "orderItems", DisplayName = "Test Autoentities with object name containing spaces")]
5896+
[DataRow("dbo.Extra Order Items", "{schema}_{object}", "dbo_extraOrderItems", DisplayName = "Test Autoentities with schema and object name containing tabs")]
5897+
public async Task TestAutoentitiesGeneratedWithSpacesInObjectName(string tableName, string namePattern, string expectedEntityName)
5898+
{
5899+
// Arrange
5900+
const string EXPECTED_ITEM_FIELD = "productname";
5901+
const string EXPECTED_RESPONSE_FRAGMENT = @"""productname"":""Sample Product""";
5902+
5903+
Dictionary<string, Autoentity> autoentityMap = new()
5904+
{
5905+
{
5906+
"SpacedObjectAutoEntity", new Autoentity(
5907+
Patterns: new AutoentityPatterns(
5908+
Include: new[] { tableName },
5909+
Exclude: null,
5910+
Name: namePattern
5911+
),
5912+
Template: new AutoentityTemplate(
5913+
Rest: new EntityRestOptions(Enabled: true),
5914+
GraphQL: new EntityGraphQLOptions(
5915+
Singular: string.Empty,
5916+
Plural: string.Empty,
5917+
Enabled: true
5918+
),
5919+
Health: null,
5920+
Cache: null
5921+
),
5922+
Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }
5923+
)
5924+
}
5925+
};
5926+
5927+
DataSource dataSource = new(DatabaseType.MSSQL,
5928+
GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null);
5929+
5930+
RuntimeConfig configuration = new(
5931+
Schema: "TestAutoentitiesSpacesSchema",
5932+
DataSource: dataSource,
5933+
Runtime: new(
5934+
Rest: new(Enabled: true),
5935+
GraphQL: new(Enabled: true),
5936+
Mcp: new(Enabled: false),
5937+
Host: new(
5938+
Cors: null,
5939+
Authentication: new Config.ObjectModel.AuthenticationOptions(
5940+
Provider: nameof(EasyAuthType.StaticWebApps),
5941+
Jwt: null
5942+
)
5943+
)
5944+
),
5945+
Entities: new(new Dictionary<string, Entity>()),
5946+
Autoentities: new RuntimeAutoentities(autoentityMap)
5947+
);
5948+
5949+
File.WriteAllText(CUSTOM_CONFIG_FILENAME, configuration.ToJson());
5950+
5951+
string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" };
5952+
using (TestServer server = new(Program.CreateWebHostBuilder(args)))
5953+
using (HttpClient client = server.CreateClient())
5954+
{
5955+
// Assert that the sanitized entity name "dbo_OrderItems" is reachable via REST,
5956+
// explicitly confirming the generated name is expectedEntityName and not names with spaces in between.
5957+
using HttpRequestMessage restRequest = new(HttpMethod.Get, $"/api/{expectedEntityName}");
5958+
using HttpResponseMessage restResponse = await client.SendAsync(restRequest);
5959+
Assert.AreEqual(
5960+
HttpStatusCode.OK,
5961+
restResponse.StatusCode,
5962+
$"REST path '/api/{expectedEntityName}' should exist; the entity name must be sanitized from 'dbo_Order Items' to '{expectedEntityName}'.");
5963+
5964+
string restResponseBody = await restResponse.Content.ReadAsStringAsync();
5965+
Assert.IsTrue(!string.IsNullOrEmpty(restResponseBody), "REST response should contain data");
5966+
Assert.IsTrue(restResponseBody.Contains(EXPECTED_RESPONSE_FRAGMENT));
5967+
5968+
// Also verify via GraphQL using the sanitized name as the query root field.
5969+
string graphqlQuery = $@"
5970+
{{
5971+
{expectedEntityName} {{
5972+
items {{
5973+
{EXPECTED_ITEM_FIELD}
5974+
}}
5975+
}}
5976+
}}";
5977+
5978+
object graphqlPayload = new { query = graphqlQuery };
5979+
HttpRequestMessage graphqlRequest = new(HttpMethod.Post, "/graphql")
5980+
{
5981+
Content = JsonContent.Create(graphqlPayload)
5982+
};
5983+
HttpResponseMessage graphqlResponse = await client.SendAsync(graphqlRequest);
5984+
5985+
Assert.AreEqual(
5986+
HttpStatusCode.OK,
5987+
graphqlResponse.StatusCode,
5988+
$"GraphQL query for '{expectedEntityName}' should succeed with the sanitized entity name.");
5989+
5990+
string graphqlResponseBody = await graphqlResponse.Content.ReadAsStringAsync();
5991+
Assert.IsTrue(!string.IsNullOrEmpty(graphqlResponseBody), "GraphQL response should contain data");
5992+
Assert.IsFalse(graphqlResponseBody.Contains("errors"), "GraphQL response should not contain errors");
5993+
Assert.IsTrue(graphqlResponseBody.Contains(EXPECTED_RESPONSE_FRAGMENT));
5994+
}
5995+
}
5996+
58855997
/// <summary>
58865998
/// Tests that DAB fails if the entities generated from autoentities property
58875999
/// do not contain unique parameters such as rest path, graphql singular/plural names,
@@ -5895,7 +6007,7 @@ public async Task TestAutoentitiesGeneratedWithDifferentSchemas(string includePa
58956007
/// <returns></returns>
58966008
[TestCategory(TestCategory.MSSQL)]
58976009
[DataTestMethod]
5898-
[DataRow("dbo_publishers", "uniqueSingularPublisher", "uniquePluralPublishers", "/unique/publisher", "Entity 'dbo_publishers' conflicts with autoentity pattern 'PublisherAutoEntity'. Use --patterns.exclude to skip it.", DisplayName = "Autoentities fail due to entity name")]
6010+
[DataRow("dbo_publishers", "uniqueSingularPublisher", "uniquePluralPublishers", "/unique/publisher", "Entity 'dbo_publishers' conflicts in autoentity pattern 'PublisherAutoEntity'. Use --patterns.exclude to skip it.", DisplayName = "Autoentities fail due to entity name")]
58996011
[DataRow("UniquePublisher", "dbo_publishers", "uniquePluralPublishers", "/unique/publisher", "Entity dbo_publishers generates queries/mutation that already exist", DisplayName = "Autoentities fail due to graphql singular type")]
59006012
[DataRow("UniquePublisher", "uniqueSingularPublisher", "dbo_publishers", "/unique/publisher", "Entity dbo_publishers generates queries/mutation that already exist", DisplayName = "Autoentities fail due to graphql plural type")]
59016013
[DataRow("UniquePublisher", "uniqueSingularPublisher", "uniquePluralPublishers", "/dbo_publishers", "The rest path: dbo_publishers specified for entity: dbo_publishers is already used by another entity.", DisplayName = "Autoentities fail due to rest path")]

src/Service.Tests/DatabaseSchema-MsSql.sql

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ DROP TABLE IF EXISTS date_only_table;
6363
DROP TABLE IF EXISTS users;
6464
DROP TABLE IF EXISTS user_profiles;
6565
DROP TABLE IF EXISTS default_books;
66+
DROP TABLE IF EXISTS [order items];
67+
DROP TABLE IF EXISTS [extra order items];
6668
DROP SCHEMA IF EXISTS [foo];
6769
DROP SCHEMA IF EXISTS [bar];
6870
COMMIT;
@@ -309,37 +311,34 @@ CREATE TABLE GQLmappings (
309311
column3 varchar(max)
310312
)
311313

312-
CREATE TABLE bookmarks
313-
(
314+
CREATE TABLE bookmarks (
314315
id int IDENTITY(1,1) PRIMARY KEY,
315316
bkname nvarchar(1000) NOT NULL
316317
)
317318

318-
CREATE TABLE mappedbookmarks
319-
(
319+
CREATE TABLE mappedbookmarks (
320320
id int IDENTITY(1,1) PRIMARY KEY,
321321
bkname nvarchar(50) NOT NULL
322322
)
323323

324-
create Table fte_data(
325-
id int IDENTITY(5001,1),
326-
u_id int DEFAULT 2,
327-
name varchar(50),
328-
position varchar(20),
329-
salary int default 20,
330-
PRIMARY KEY(id, u_id)
324+
create Table fte_data (
325+
id int IDENTITY(5001,1),
326+
u_id int DEFAULT 2,
327+
name varchar(50),
328+
position varchar(20),
329+
salary int default 20,
330+
PRIMARY KEY(id, u_id)
331331
);
332332

333-
create Table intern_data(
334-
id int,
335-
months int default 2 NOT NULL,
336-
name varchar(50),
337-
salary int default 15,
338-
PRIMARY KEY(id, months)
333+
create Table intern_data (
334+
id int,
335+
months int default 2 NOT NULL,
336+
name varchar(50),
337+
salary int default 15,
338+
PRIMARY KEY(id, months)
339339
);
340340

341-
create table books_sold
342-
(
341+
create table books_sold (
343342
id int PRIMARY KEY not null,
344343
book_name varchar(50),
345344
row_version rowversion,
@@ -348,8 +347,7 @@ create table books_sold
348347
last_sold_on_date as last_sold_on,
349348
)
350349

351-
CREATE TABLE default_with_function_table
352-
(
350+
CREATE TABLE default_with_function_table (
353351
id INT PRIMARY KEY IDENTITY(5001,1),
354352
user_value INT,
355353
[current_date] DATETIME DEFAULT GETDATE() NOT NULL,
@@ -394,6 +392,16 @@ CREATE TABLE default_books(
394392
title NVARCHAR(100)
395393
);
396394

395+
CREATE TABLE [order items](
396+
id INT PRIMARY KEY,
397+
productname VARCHAR(100)
398+
);
399+
400+
CREATE TABLE [extra order items](
401+
id INT PRIMARY KEY,
402+
productname VARCHAR(100)
403+
);
404+
397405
ALTER TABLE books
398406
ADD CONSTRAINT book_publisher_fk
399407
FOREIGN KEY (publisher_id)
@@ -826,3 +834,9 @@ INSERT INTO date_only_table( event_date, event_time, event_timestamp)
826834
VALUES ('2023-01-01', '08:30:00', '2023-01-01 08:30:00'),
827835
('2023-02-15', '12:45:00', '2023-02-15 12:45:00'),
828836
('2023-03-30', '17:15:00', '2023-03-30 17:15:00');
837+
838+
INSERT INTO [order items](id, productname)
839+
VALUES (1, 'Sample Product');
840+
841+
INSERT INTO [extra order items](id, productname)
842+
VALUES (1, 'Sample Product');

0 commit comments

Comments
 (0)