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)
{
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
85 changes: 61 additions & 24 deletions src/Service.Tests/Configuration/ConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5786,12 +5786,51 @@ public async Task TestAutoentitiesWithSameObjectDifferentSchemas()
public async Task TestAutoentitiesGeneratedWithDifferentSchemas(string includePattern, bool isPatternFoo)
{
// Arrange
Dictionary<string, Autoentity> 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");

}
}

/// <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()
{
{
"PublisherAutoEntity", new Autoentity(
"SpacedObjectAutoEntity", new Autoentity(
Patterns: new AutoentityPatterns(
Include: new[] { includePattern },
Include: new[] { "dbo.Order Items" },
Exclude: null,
Name: null
),
Expand All @@ -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),
Expand All @@ -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}
}}
}}
}}";
Expand All @@ -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));
}
}

Expand Down
47 changes: 26 additions & 21 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 @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -394,6 +391,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 +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');