Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona

RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig();
Dictionary<string, Entity> entities = new();
Dictionary<string, string> entityNameToRawEntity = new();
foreach ((string autoentityName, Autoentity autoentity) in autoentities)
{
int addedEntities = 0;
Expand Down Expand Up @@ -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);
}

Comment thread
RubenCerna2079 marked this conversation as resolved.
// 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 @@ -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)
Expand All @@ -384,6 +414,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 @@ -722,6 +722,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 RemoveWhitespaceAddCamelCase(string name)
Comment thread
RubenCerna2079 marked this conversation as resolved.
{
StringBuilder result = new(name.Length);
bool capitalizeNext = false;

foreach (char character in name)
{
if (char.IsWhiteSpace(character))
Comment thread
RubenCerna2079 marked this conversation as resolved.
{
capitalizeNext = true;
continue;
}

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

return result.ToString();
}

protected void PopulateDatabaseObjectForEntity(
Entity entity,
string entityName,
Expand Down
112 changes: 112 additions & 0 deletions src/Service.Tests/Configuration/ConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5882,6 +5882,118 @@ 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)]
[DataTestMethod]
[DataRow("dbo.Order Items", "{schema}_{object}", "dbo_orderItems", DisplayName = "Test Autoentities with schema and object name containing spaces")]
[DataRow("dbo.Order Items", "{object}", "orderItems", DisplayName = "Test Autoentities with object name containing spaces")]
[DataRow("dbo.Extra Order Items", "{schema}_{object}", "dbo_extraOrderItems", DisplayName = "Test Autoentities with schema and object name containing tabs")]
public async Task TestAutoentitiesGeneratedWithSpacesInObjectName(string tableName, string namePattern, string expectedEntityName)
{
// Arrange
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[] { tableName },
Exclude: null,
Name: namePattern
),
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 expectedEntityName and not names with spaces in between.
using HttpRequestMessage restRequest = new(HttpMethod.Get, $"/api/{expectedEntityName}");
using HttpResponseMessage restResponse = await client.SendAsync(restRequest);
Assert.AreEqual(
HttpStatusCode.OK,
restResponse.StatusCode,
$"REST path '/api/{expectedEntityName}' should exist; the entity name must be sanitized from 'dbo_Order Items' to '{expectedEntityName}'.");

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 = $@"
{{
{expectedEntityName} {{
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 '{expectedEntityName}' 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
56 changes: 35 additions & 21 deletions src/Service.Tests/DatabaseSchema-MsSql.sql
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ 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 TABLE IF EXISTS [extra order items];
DROP SCHEMA IF EXISTS [foo];
DROP SCHEMA IF EXISTS [bar];
COMMIT;
Expand Down Expand Up @@ -309,37 +311,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,
Expand All @@ -348,8 +347,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,
Expand Down Expand Up @@ -394,6 +392,16 @@ CREATE TABLE default_books(
title NVARCHAR(100)
);

CREATE TABLE [order items](
id INT PRIMARY KEY,
productname VARCHAR(100)
);

CREATE TABLE [extra order items](
id INT PRIMARY KEY,
productname VARCHAR(100)
);

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

INSERT INTO [extra order items](id, productname)
VALUES (1, 'Sample Product');
Loading