Skip to content
Open
33 changes: 32 additions & 1 deletion src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sanitization via whitespace removal can introduce collisions. For example:

  • "Order Item" → "OrderItem"
  • "OrderItem" → "OrderItem"

Both would map to the same entity name after sanitization.

Do we currently detect or handle such collisions? It might be useful to either log or explicitly guard against these cases to avoid silent overrides or ambiguous schema behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Collisions are now surfaced explicitly. The original (pre-sanitization) name is stored before RemoveWhitespaceAndCamelCase is called. When entities.TryAdd fails (covering the "Order Item" / "OrderItem""OrderItem" collision case), the exception message includes both the sanitized name and the original name along with the schema, e.g.:

Entity 'OrderItem' (normalized from 'Order Item' in schema 'dbo') conflicts with autoentity pattern '...'. Use --patterns.exclude to skip it.

This makes it clear to the user that the collision may be a result of whitespace removal rather than an exact name match.

// 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(
Expand All @@ -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);
}
Expand Down Expand Up @@ -386,6 +411,12 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
_runtimeConfigProvider.AddMergedEntitiesToConfig(entities);
}

/// <summary>
/// Queries the database for autoentities based on the provided autoentity definition.
/// </summary>
/// <param name="autoentityName">The name of the autoentity definition.</param>
/// <param name="autoentity">The autoentity definition containing patterns for inclusion, exclusion, and name.</param>
/// <returns>A JsonArray containing the queried autoentities, or an empty array if none are found.</returns>
public async Task<JsonArray?> QueryAutoentitiesAsync(string autoentityName, Autoentity autoentity)
{
string include = string.Join(",", autoentity.Patterns.Include);
Expand Down
27 changes: 27 additions & 0 deletions src/Core/Services/MetadataProviders/SqlMetadataProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,33 @@ private void RemoveGeneratedAutoentities()
_runtimeConfigProvider.RemoveGeneratedAutoentitiesFromConfig();
}

/// <summary>
/// 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".
/// </summary>
/// <param name="name">The entity name to process.</param>
/// <returns>The entity name with whitespace removed and following characters capitalized.</returns>
protected static string RemoveWhitespaceAndCamelCase(string name)
{
StringBuilder result = new(name.Length);
bool capitalizeNext = false;

foreach (char character in name)
{
if (char.IsWhiteSpace(character))
Comment on lines +736 to +750

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we sanitize other valid characters like []? for e.g.- [Customer-Orders] still produces dbo_Customer-Orders, which can be invalid for REST/GraphQL. if this is about only removing whitespaces, may be the function can be renamed with that.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a more specific name (e.g., RemoveWhitespaceAndCamelCase) could improve clarity and avoid confusion for future contributors.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to RemoveWhitespaceAndCamelCase in 29f0e79 → addressed in the latest commit. The updated XML doc also describes the camelCase-join behavior explicitly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — SanitizeGeneratedEntityName has been renamed to RemoveWhitespaceAndCamelCase with an updated doc comment that describes the camelCase-join behavior.

{
capitalizeNext = true;
continue;
}

result.Append(capitalizeNext ? char.ToUpperInvariant(character) : character);
capitalizeNext = false;
}

return result.ToString();
}

protected void PopulateDatabaseObjectForEntity(
Entity entity,
string entityName,
Expand Down
143 changes: 134 additions & 9 deletions src/Service.Tests/Configuration/ConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5759,16 +5759,16 @@ public async Task TestAutoentitiesWithSameObjectDifferentSchemas()
}

/// <summary>
/// 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.
/// </summary>
/// <param name="includePattern">The pattern to include for autoentities</param>
/// <param name="isPatternFoo">Boolean that indicates if the pattern is for the foo schema</param>
/// <param name="patternType">Integer that indicates which input pattern is being used</param>
/// <returns></returns>
Comment thread
RubenCerna2079 marked this conversation as resolved.
[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)
Comment thread
RubenCerna2079 marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests validate that REST and GraphQL access work end-to-end, which is great.

One improvement would be to explicitly assert the generated/sanitized entity name (e.g., "dbo_OrderItems") in the test. That would make the behavior more explicit and protect against future regressions in naming logic.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new TestAutoentitiesGeneratedWithSpacesInObjectName test declares const string EXPECTED_ENTITY_NAME = "dbo_OrderItems" and uses it directly as the REST path and GraphQL field name. The Assert.AreEqual failure message explicitly states that the entity name must be sanitized from "dbo_Order Items" to "dbo_OrderItems", making the expected generated name unambiguous.

{
// Arrange
Dictionary<string, Autoentity> autoentityMap = new()
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current test combines multiple scenarios (schemas + whitespace handling) using a patternType switch. This makes the test a bit harder to follow and extend.

It may be cleaner to split whitespace-specific validation into a separate test method for better readability and maintainability.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extracted into a dedicated TestAutoentitiesGeneratedWithSpacesInObjectName test method. TestAutoentitiesGeneratedWithUnusualElements now only covers the two non-default schema cases (foo/bar), while the whitespace normalization scenario has its own test with an explicit EXPECTED_ENTITY_NAME constant and targeted assertions.

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} {{
Expand All @@ -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");

Expand All @@ -5867,6 +5882,116 @@ public async Task TestAutoentitiesGeneratedWithDifferentSchemas(string includePa
}
}

/// <summary>
/// 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".
/// </summary>
[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<string, Autoentity> 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<string, Entity>()),
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));
}
}

/// <summary>
/// Tests that DAB fails if the entities generated from autoentities property
/// do not contain unique parameters such as rest path, graphql singular/plural names,
Expand Down
37 changes: 24 additions & 13 deletions src/Service.Tests/DatabaseSchema-MsSql.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -394,6 +397,11 @@ CREATE TABLE default_books(
title NVARCHAR(100)
);

CREATE TABLE [Order Items](
id INT PRIMARY KEY,
productname VARCHAR(100)
);
Comment thread
RubenCerna2079 marked this conversation as resolved.

ALTER TABLE books
ADD CONSTRAINT book_publisher_fk
FOREIGN KEY (publisher_id)
Expand Down Expand Up @@ -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');