Create Entities in-memory from Autoentities#3129
Create Entities in-memory from Autoentities#3129RubenCerna2079 wants to merge 15 commits intomainfrom
Conversation
1972a8f to
2f256e4
Compare
|
/azp run |
|
Azure Pipelines successfully started running 6 pipeline(s). |
There was a problem hiding this comment.
Pull request overview
This PR enables auto-generating entity definitions from autoentities at runtime (in-memory) so the generated entities become usable by the REST/GraphQL runtime without being serialized into the entities section of the config.
Changes:
- Implemented MSSQL autoentity generation to materialize matching tables into
RuntimeConfig.Entitiesduring metadata initialization. - Updated
RuntimeConfig/validation to allowentitiesto be omitted whenautoentitiesis present, and added APIs to mutate the in-memory runtime config. - Added an MSSQL test covering autoentities generation with/without pre-existing entities.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Service/Startup.cs | Adds TODOs related to validation placement with autoentity generation. |
| src/Service.Tests/Configuration/ConfigurationTests.cs | Adds test coverage for autoentities generating usable REST/GraphQL endpoints. |
| src/Core/Services/MetadataProviders/SqlMetadataProvider.cs | Exposes _entities to derived classes and triggers autoentity generation during MSSQL initialization. |
| src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs | Implements GenerateAutoentitiesIntoEntities() and query execution for autoentities. |
| src/Core/Configurations/RuntimeConfigValidator.cs | Allows entity validation loop to safely handle missing Entities. |
| src/Core/Configurations/RuntimeConfigProvider.cs | Adds method to replace runtime config entities in-memory after generation. |
| src/Config/RuntimeConfigLoader.cs | Adds a setter-style method to update the active RuntimeConfig instance. |
| src/Config/ObjectModel/RuntimeConfig.cs | Allows missing entities when autoentities exists; adds entity->datasource mapping helper. |
| public bool TryAddEntityNameToDataSourceName(string entityName) | ||
| { | ||
| return _entityNameToDataSourceName.TryAdd(entityName, this.DefaultDataSourceName); |
There was a problem hiding this comment.
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.
| 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); |
| RuntimeConfig newRuntimeConfig = _configLoader.RuntimeConfig! with | ||
| { | ||
| Entities = new(entities) | ||
| }; |
There was a problem hiding this comment.
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.
| 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 | |
| }; |
There was a problem hiding this comment.
Copilot is right on this one. This replaces the entire entity collection. Combined with entities being sourced from _entities (which is per-datasource), this drops entities from other datasources. Merge into the existing set instead. Something like this:
var merged = new Dictionary<string, Entity>(_configLoader.RuntimeConfig!.Entities.Entities);
foreach (var kvp in entities) merged[kvp.Key] = kvp.Value;| Dictionary<string, Entity> entities = (Dictionary<string, Entity>)_entities; | ||
| if (runtimeConfig.Autoentities is null) | ||
| { | ||
| return; | ||
| } | ||
|
|
There was a problem hiding this comment.
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.
| 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); |
| 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), |
There was a problem hiding this comment.
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.
| using (HttpClient client = server.CreateClient()) | ||
| { | ||
| // Act | ||
| HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/publishers"); |
There was a problem hiding this comment.
Disposable 'HttpRequestMessage' is created but not disposed.
| HttpRequestMessage graphqlRequest = new(HttpMethod.Post, "/graphql") | ||
| { | ||
| Content = JsonContent.Create(graphqlPayload) | ||
| }; |
There was a problem hiding this comment.
Disposable 'HttpRequestMessage' is created but not disposed.
|
/azp run |
|
Azure Pipelines successfully started running 6 pipeline(s). |
| { | ||
| 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", |
There was a problem hiding this comment.
| 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", |
| } | ||
| else | ||
| { | ||
| _runtimeConfigProvider.AddNewEntitiesToConfig(entities); |
There was a problem hiding this comment.
Dont we have access to the RuntimeConfigLoader here?
| return runtimeConfig; | ||
| } | ||
|
|
||
| public void AddNewEntitiesToConfig(Dictionary<string, Entity> entities) |
There was a problem hiding this comment.
Change the function name to "AddMergedEntitiesToConfig" since the entities passed in here, are all the entities - original + entities found from autoentities.
|
|
||
| string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" }; | ||
|
|
||
| using (TestServer server = new(Program.CreateWebHostBuilder(args))) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Per @Aniruddh25's feedback: after the server starts, resolve RuntimeConfigProvider from the service provider and assert the entity count. Expect 3 when useEntities=true (2 manual + 1 auto-generated) and 1 when useEntities=false. This proves the autoentity pipeline actually ran, rather than relying only on REST/GraphQL responses which could pass for the wrong reasons.
|
/azp run |
|
Azure Pipelines successfully started running 6 pipeline(s). |
| await Task.CompletedTask; | ||
| int addedEntities = 0; | ||
| RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); | ||
| Dictionary<string, Entity> entities = (Dictionary<string, Entity>)_entities; |
There was a problem hiding this comment.
This is not so much a technical problem as a code behavior problem. This line breaks the contract of IReadOnlyDictionary. For some reason, a developer typed this as read-only. Why? Presumably to protect it from mutation. But that developer made a mistake by assigning it via ToDictionary().
So, your cast works today because _entities is assigned via .ToDictionary() in the base class, but the declared type is IReadOnlyDictionary. If someone later CORRECTS it to ReadOnlyDictionary, this line will fail with InvalidCastException at runtime with no compile-time hint.
That's not all. _entities only has entities for this datasource (it is filtered in the SqlMetadataProvider constructor). So when you pass it to AddNewEntitiesToConfig that code will replace the full set with a subset, dropping entities from other datasources. 🫨
Suggestion: The easiest is to build a local dictionary from runtimeConfig.Entities.Entities (the full set), merge auto-generated entities into it, and pass that to AddNewEntitiesToConfig. That avoids both problems.
| continue; | ||
| } | ||
|
|
||
| foreach (JsonObject resultObject in resultArray!) |
There was a problem hiding this comment.
The ! is unnecessary here. You already guard for null on line 308 with continue, so the compiler knows resultArray is not null at this point. Remove the ! to keep things clean. I know these are easy to just keep everywhere, but it actually can introduce hard to debug problems later.
| string entityName = resultObject["entity_name"]!.ToString(); | ||
| string schemaName = resultObject["schema"]!.ToString(); | ||
| string objectName = resultObject["object"]!.ToString(); |
There was a problem hiding this comment.
If the SQL result has a row where one of these keys is missing, the ! will cause a NullReferenceException before reaching your IsNullOrWhiteSpace guard below. Use ?.ToString() and let the null check catch it:
string? entityName = resultObject["entity_name"]?.ToString();| } | ||
| } | ||
|
|
||
| public async Task<JsonArray?> QueryAutoentitiesAsync(Autoentity autoentity) |
There was a problem hiding this comment.
AutoentityPatterns.Exclude is string[]? in the constructor. If someone passes null, string.Join throws ArgumentNullException. Coalesce it: autoentity.Patterns.Exclude ?? Array.Empty().
| RuntimeConfig newRuntimeConfig = _configLoader.RuntimeConfig! with | ||
| { | ||
| Entities = new(entities) | ||
| }; |
There was a problem hiding this comment.
Copilot is right on this one. This replaces the entire entity collection. Combined with entities being sourced from _entities (which is per-datasource), this drops entities from other datasources. Merge into the existing set instead. Something like this:
var merged = new Dictionary<string, Entity>(_configLoader.RuntimeConfig!.Entities.Entities);
foreach (var kvp in entities) merged[kvp.Key] = kvp.Value;|
|
||
| _entityNameToDataSourceName = new Dictionary<string, string>(); | ||
| if (Entities is null) | ||
| if ((Entities is null || Entities.Entities.Count == 0) && Autoentities is null) |
There was a problem hiding this comment.
On line 276 you already set this.Entities = Entities ?? new RuntimeEntities(...), so Entities is null can never be true here. The null branch is dead code. SImplify to if (Entities.Entities.Count == 0 && Autoentities is null)
| 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 |
There was a problem hiding this comment.
What's the mystery? "If it is..." What?! I suggest you finish the sentence and also link to the task here. It's okay to have a TODO in a PR. Just always think that you might hand this off to another developer, so don't use too much shorthand.
|
|
||
| string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" }; | ||
|
|
||
| using (TestServer server = new(Program.CreateWebHostBuilder(args))) |
There was a problem hiding this comment.
Per @Aniruddh25's feedback: after the server starts, resolve RuntimeConfigProvider from the service provider and assert the entity count. Expect 3 when useEntities=true (2 manual + 1 auto-generated) and 1 when useEntities=false. This proves the autoentity pipeline actually ran, rather than relying only on REST/GraphQL responses which could pass for the wrong reasons.
|
|
||
| // Represents the entities exposed in the runtime config. | ||
| private IReadOnlyDictionary<string, Entity> _entities; | ||
| protected IReadOnlyDictionary<string, Entity> _entities; |
There was a problem hiding this comment.
This was changed from private to protected so the subclass can cast and mutate it. If you take the approach of building a local dictionary from runtimeConfig.Entities instead of mutating _entities directly, you can revert this back to private and preserve encapsulation.
Why make this change?
We need to generate all the entities from the autoentities properties. In order to do this we need to use the query that was previously created and, add the newly generated entities into the runtime so the user can use them.
What is this change?
MsSqlMetadataProvider.cs: Finish creating theGenerateAutoentitiesIntoEntitiesfunction so that it uses the query to receive all of the tables and turn them into entities inside the runtimeConfig.RuntimeConfig.cs: Create new function that adds the new entities to the runtimeConfig. And also change the runtimeConfig to allow for theentitiesproperty to be missing if the user decides to use theautoentitiesproperty.How was this tested?