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
24 changes: 16 additions & 8 deletions src/Config/ObjectModel/RuntimeConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,11 @@ public bool TryGetEntityNameFromPath(string entityPathName, [NotNullWhen(true)]
return _entityPathNameToEntityName.TryGetValue(entityPathName, out entityName);
}

public bool TryAddEntityNameToDataSourceName(string entityName)
{
return _entityNameToDataSourceName.TryAdd(entityName, this.DefaultDataSourceName);
Comment on lines +248 to +250
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

This method always maps newly added entities to DefaultDataSourceName. That will be incorrect when autoentities are generated while initializing a non-default datasource (multi-database scenario). Consider changing the API to accept a dataSourceName (and use _dataSourceName from the metadata provider) so the entity->datasource mapping stays correct.

Suggested change
public bool TryAddEntityNameToDataSourceName(string entityName)
{
return _entityNameToDataSourceName.TryAdd(entityName, this.DefaultDataSourceName);
public bool TryAddEntityNameToDataSourceName(string entityName, string? dataSourceName = null)
{
string effectiveDataSourceName = string.IsNullOrEmpty(dataSourceName) ? this.DefaultDataSourceName : dataSourceName;
return _entityNameToDataSourceName.TryAdd(entityName, effectiveDataSourceName);

Copilot uses AI. Check for mistakes.
}

/// <summary>
/// Constructor for runtimeConfig.
/// To be used when setting up from cli json scenario.
Expand All @@ -268,7 +273,7 @@ public RuntimeConfig(
this.DataSource = DataSource;
this.Runtime = Runtime;
this.AzureKeyVault = AzureKeyVault;
this.Entities = Entities;
this.Entities = Entities ?? new RuntimeEntities(new Dictionary<string, Entity>());
this.Autoentities = Autoentities;
this.DefaultDataSourceName = Guid.NewGuid().ToString();

Expand All @@ -287,25 +292,28 @@ public RuntimeConfig(
};

_entityNameToDataSourceName = new Dictionary<string, string>();
if (Entities is null)
if ((Entities is null || Entities.Entities.Count == 0) && Autoentities is null)
{
throw new DataApiBuilderException(
message: "entities is a mandatory property in DAB Config",
message: "Configuration file should contain either at least the Entities or Autoentities property",
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
message: "Configuration file should contain either at least the Entities or Autoentities property",
message: "Configuration file should contain either at least the entities or autoentities property",

statusCode: HttpStatusCode.UnprocessableEntity,
subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError);
}

foreach (KeyValuePair<string, Entity> entity in Entities)
if (Entities is not null)
{
_entityNameToDataSourceName.TryAdd(entity.Key, this.DefaultDataSourceName);
foreach (KeyValuePair<string, Entity> entity in Entities)
{
_entityNameToDataSourceName.TryAdd(entity.Key, this.DefaultDataSourceName);
}
}

// Process data source and entities information for each database in multiple database scenario.
this.DataSourceFiles = DataSourceFiles;

if (DataSourceFiles is not null && DataSourceFiles.SourceFiles is not null)
{
IEnumerable<KeyValuePair<string, Entity>> allEntities = Entities.AsEnumerable();
IEnumerable<KeyValuePair<string, Entity>>? allEntities = Entities?.AsEnumerable();
// Iterate through all the datasource files and load the config.
IFileSystem fileSystem = new FileSystem();
// This loader is not used as a part of hot reload and therefore does not need a handler.
Expand All @@ -322,7 +330,7 @@ public RuntimeConfig(
{
_dataSourceNameToDataSource = _dataSourceNameToDataSource.Concat(config._dataSourceNameToDataSource).ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
_entityNameToDataSourceName = _entityNameToDataSourceName.Concat(config._entityNameToDataSourceName).ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
allEntities = allEntities.Concat(config.Entities.AsEnumerable());
allEntities = allEntities?.Concat(config.Entities.AsEnumerable());
}
catch (Exception e)
{
Expand All @@ -336,7 +344,7 @@ public RuntimeConfig(
}
}

this.Entities = new RuntimeEntities(allEntities.ToDictionary(x => x.Key, x => x.Value));
this.Entities = new RuntimeEntities(allEntities != null ? allEntities.ToDictionary(x => x.Key, x => x.Value) : new Dictionary<string, Entity>());
}

SetupDataSourcesUsed();
Expand Down
5 changes: 5 additions & 0 deletions src/Config/RuntimeConfigLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -499,4 +499,9 @@ public void InsertWantedChangesInProductionMode()
RuntimeConfig = runtimeConfigCopy;
}
}

public void EditRuntimeConfig(RuntimeConfig newRuntimeConfig)
{
RuntimeConfig = newRuntimeConfig;
}
}
9 changes: 9 additions & 0 deletions src/Core/Configurations/RuntimeConfigProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -411,4 +411,13 @@ private static RuntimeConfig HandleCosmosNoSqlConfiguration(string? schema, Runt

return runtimeConfig;
}

public void AddNewEntitiesToConfig(Dictionary<string, Entity> entities)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Change the function name to "AddMergedEntitiesToConfig" since the entities passed in here, are all the entities - original + entities found from autoentities.

{
RuntimeConfig newRuntimeConfig = _configLoader.RuntimeConfig! with
{
Entities = new(entities)
};
Comment on lines +417 to +420
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

AddNewEntitiesToConfig replaces the entire RuntimeConfig.Entities collection with the provided dictionary. In multi-datasource scenarios (or any scenario where entities is a subset), this can unintentionally drop entities and desync internal lookup dictionaries. Prefer merging with the existing entity set (and updating the entity->datasource map consistently) rather than overwriting.

Suggested change
RuntimeConfig newRuntimeConfig = _configLoader.RuntimeConfig! with
{
Entities = new(entities)
};
// Merge incoming entities into the existing entity set instead of overwriting it.
RuntimeConfig currentRuntimeConfig = _configLoader.RuntimeConfig!;
Dictionary<string, Entity> mergedEntities = new(currentRuntimeConfig.Entities);
foreach ((string entityName, Entity entityDefinition) in entities)
{
// Add new entities or update existing ones with the provided definitions.
mergedEntities[entityName] = entityDefinition;
}
RuntimeConfig newRuntimeConfig = currentRuntimeConfig with
{
Entities = mergedEntities
};

Copilot uses AI. Check for mistakes.
_configLoader.EditRuntimeConfig(newRuntimeConfig);
}
}
81 changes: 80 additions & 1 deletion src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,86 @@ private bool TryResolveDbType(string sqlDbTypeName, out DbType dbType)
/// <inheritdoc/>
protected override async Task GenerateAutoentitiesIntoEntities()
{
await Task.CompletedTask;
int addedEntities = 0;
RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig();
Dictionary<string, Entity> entities = (Dictionary<string, Entity>)_entities;
if (runtimeConfig.Autoentities is null)
{
return;
}

Comment on lines +299 to +304
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

entities is taken from _entities, which is a per-datasource filtered dictionary (see SqlMetadataProvider ctor). Passing that dictionary into AddNewEntitiesToConfig risks replacing RuntimeConfig.Entities with only the entities for this datasource, dropping entities for other datasources and leaving _entityNameToDataSourceName inconsistent. Use the full runtimeConfig.Entities.Entities as the base and merge in generated entities, or make AddNewEntitiesToConfig merge rather than replace.

Suggested change
Dictionary<string, Entity> entities = (Dictionary<string, Entity>)_entities;
if (runtimeConfig.Autoentities is null)
{
return;
}
if (runtimeConfig.Autoentities is null)
{
return;
}
// Start from the full set of entities defined in the runtime configuration,
// not the per-datasource filtered _entities dictionary, so that when we
// persist new auto-generated entities we don't drop entities for other datasources.
Dictionary<string, Entity> entities = new(runtimeConfig.Entities.Entities);

Copilot uses AI. Check for mistakes.
foreach ((string autoentityName, Autoentity autoentity) in runtimeConfig.Autoentities.AutoEntities)
{
JsonArray? resultArray = await QueryAutoentitiesAsync(autoentity);
if (resultArray is null)
{
continue;
}

foreach (JsonObject resultObject in resultArray!)
{
// Extract the entity name, schema, and database object name from the query result.
// The SQL query returns these values with placeholders already replaced.
string entityName = resultObject["entity_name"]!.ToString();
string schemaName = resultObject["schema"]!.ToString();
string objectName = resultObject["object"]!.ToString();

if (string.IsNullOrWhiteSpace(entityName) || string.IsNullOrWhiteSpace(objectName))
{
_logger.LogError("Skipping autoentity generation: entity_name or object is null or empty for autoentity pattern '{AutoentityName}'.", autoentityName);
continue;
}

// 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(
Source: new EntitySource(
Object: objectName,
Type: EntitySourceType.Table,
Parameters: null,
KeyFields: null),
Comment on lines +317 to +334
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

schemaName is read from the autoentities query but not used. If a matched table is in a non-default schema, setting EntitySource.Object to just objectName will make DAB resolve it against the default schema (dbo) and point to the wrong table. Consider using a schema-qualified source (e.g., schemaName.objectName) when schemaName is not empty/default.

Copilot uses AI. Check for mistakes.
GraphQL: autoentity.Template.GraphQL,
Rest: autoentity.Template.Rest,
Mcp: autoentity.Template.Mcp,
Permissions: autoentity.Permissions,
Cache: autoentity.Template.Cache,
Health: autoentity.Template.Health,
Fields: null,
Relationships: null,
Mappings: new());

// Add the generated entity to the linking entities dictionary.
// This allows the entity to be processed later during metadata population.
if (!entities.TryAdd(entityName, generatedEntity) || !runtimeConfig.TryAddEntityNameToDataSourceName(entityName))
{
// TODO: need to make a better message that includes if the conflict is with a user-defined entity or another auto-generated entity.
throw new DataApiBuilderException(
message: $"Entity with name '{entityName}' already exists. Cannot create new entity from autoentity pattern with definition-name '{autoentityName}'.",
statusCode: HttpStatusCode.BadRequest,
subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization);
}

if (runtimeConfig.IsRestEnabled)
{
_logger.LogInformation("[{entity}] REST path: {globalRestPath}/{entityRestPath}", entityName, runtimeConfig.RestPath, entityName);
}
else
{
_logger.LogInformation(message: "REST calls are disabled for the entity: {entity}", entityName);
}

addedEntities++;
}
}

if (addedEntities == 0)
{
_logger.LogWarning("No new entities were generated from the autoentity patterns defined in the configuration.");
}
else
{
_runtimeConfigProvider.AddNewEntitiesToConfig(entities);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Dont we have access to the RuntimeConfigLoader here?

}
}

public async Task<JsonArray?> QueryAutoentitiesAsync(Autoentity autoentity)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public abstract class SqlMetadataProvider<ConnectionT, DataAdapterT, CommandT> :
private readonly DatabaseType _databaseType;

// Represents the entities exposed in the runtime config.
private IReadOnlyDictionary<string, Entity> _entities;
protected IReadOnlyDictionary<string, Entity> _entities;

// Represents the linking entities created by DAB to support multiple mutations for entities having an M:N relationship between them.
protected Dictionary<string, Entity> _linkingEntities = new();
Expand Down Expand Up @@ -307,6 +307,7 @@ public string GetEntityName(string graphQLType)
public async Task InitializeAsync()
{
System.Diagnostics.Stopwatch timer = System.Diagnostics.Stopwatch.StartNew();

if (GetDatabaseType() == DatabaseType.MSSQL)
{
await GenerateAutoentitiesIntoEntities();
Expand Down
142 changes: 142 additions & 0 deletions src/Service.Tests/Configuration/ConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5231,6 +5231,148 @@ public async Task TestGraphQLIntrospectionQueriesAreNotImpactedByDepthLimit()
}
}

[TestCategory(TestCategory.MSSQL)]
[DataTestMethod]
[DataRow(true, DisplayName = "Test Autoentities with additional entities")]
[DataRow(false, DisplayName = "Test Autoentities without additional entities")]
public async Task TestAutoentitiesAreGeneratedIntoEntities(bool useEntities)
{
// Arrange
EntityRelationship bookRelationship = new(Cardinality: Cardinality.One,
TargetEntity: "BookPublisher",
SourceFields: new string[] { },
TargetFields: new string[] { },
LinkingObject: null,
LinkingSourceFields: null,
LinkingTargetFields: null);

Entity bookEntity = new(Source: new("books", EntitySourceType.Table, null, null),
Fields: null,
Rest: null,
GraphQL: new(Singular: "book", Plural: "books"),
Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) },
Relationships: new Dictionary<string, EntityRelationship>() { { "publishers", bookRelationship } },
Mappings: null);

EntityRelationship publisherRelationship = new(Cardinality: Cardinality.Many,
TargetEntity: "Book",
SourceFields: new string[] { },
TargetFields: new string[] { },
LinkingObject: null,
LinkingSourceFields: null,
LinkingTargetFields: null);

Entity publisherEntity = new(
Source: new("publishers", EntitySourceType.Table, null, null),
Fields: null,
Rest: null,
GraphQL: new(Singular: "bookpublisher", Plural: "bookpublishers"),
Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) },
Relationships: new Dictionary<string, EntityRelationship>() { { "books", publisherRelationship } },
Mappings: null);

Dictionary<string, Entity> entityMap = new()
{
{ "Book", bookEntity },
{ "BookPublisher", publisherEntity }
};

Dictionary<string, Autoentity> autoentityMap = new()
{
{
"PublisherAutoEntity", new Autoentity(
Patterns: new AutoentityPatterns(
Include: new[] { "%publishers%" },
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) }
)
}
};

// 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",
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(useEntities ? entityMap : 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)))
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you also retrieve the serviceProvider -> RuntimeConfigProvider and the RuntimeConfig to check the entities member has 3 entities with useentity and 1 when not using previous entities?

using (HttpClient client = server.CreateClient())
{
// Act
HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/publishers");
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

Disposable 'HttpRequestMessage' is created but not disposed.

Copilot uses AI. Check for mistakes.
HttpResponseMessage restResponse = await client.SendAsync(restRequest);

string graphqlQuery = @"
{
publishers {
items {
id
name
}
}
}";

object graphqlPayload = new { query = graphqlQuery };
HttpRequestMessage graphqlRequest = new(HttpMethod.Post, "/graphql")
{
Content = JsonContent.Create(graphqlPayload)
};
Comment on lines +5350 to +5353
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

Disposable 'HttpRequestMessage' is created but not disposed.

Copilot uses AI. Check for mistakes.
HttpResponseMessage graphqlResponse = await client.SendAsync(graphqlRequest);

// Assert
string expectedResponseFragment = @"{""id"":1156,""name"":""The First Publisher""}";

// 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");

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));
}
}

/// <summary>
/// Tests the behavior of GraphQL queries in non-hosted mode when the depth limit is explicitly set to -1 or null.
/// Setting the depth limit to -1 is intended to disable the depth limit check, allowing queries of any depth.
Expand Down
2 changes: 2 additions & 0 deletions src/Service/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1140,11 +1140,13 @@ private async Task<bool> PerformOnConfigChangeAsync(IApplicationBuilder app)
// Now that the configuration has been set, perform validation of the runtime config
// itself.

// TODO: Add this check at the end of generating the new entities and skip this one only if it is
runtimeConfigValidator.ValidateConfigProperties();

if (runtimeConfig.IsDevelopmentMode())
{
// Running only in developer mode to ensure fast and smooth startup in production.
// TODO: Add this check at the end of generating the new entities and skip this one only if it is
runtimeConfigValidator.ValidatePermissionsInConfig(runtimeConfig);
}

Expand Down
Loading