diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index a8b71d10c9..77d6e7348c 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -258,6 +258,8 @@ Runtime.GraphQL.FeatureFlags is not null && private Dictionary _entityPathNameToEntityName = new(); + private Dictionary _dataSourceNameToConfigLoader = new(); + /// /// List of all datasources. /// @@ -305,6 +307,11 @@ public bool RemoveGeneratedAutoentityNameFromDataSourceName(string entityName) return _entityNameToDataSourceName.Remove(entityName); } + public bool TryGetConfigLoaderFromDataSourceName(string dataSourceName, [NotNullWhen(true)] out FileSystemRuntimeConfigLoader? configLoader) + { + return _dataSourceNameToConfigLoader.TryGetValue(dataSourceName, out configLoader); + } + /// /// Constructor for runtimeConfig. /// To be used when setting up from cli json scenario. @@ -372,8 +379,6 @@ public RuntimeConfig( IEnumerable>? allAutoentities = Autoentities?.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. - FileSystemRuntimeConfigLoader loader = new(fileSystem, handler: null); // Pass the parent's AKV options so @akv() references in child configs can // be resolved using the parent's Key Vault configuration. @@ -390,7 +395,8 @@ public RuntimeConfig( foreach (string dataSourceFile in DataSourceFiles.SourceFiles) { - + // This loader is not used as a part of hot reload and therefore does not need a handler. + FileSystemRuntimeConfigLoader loader = new(fileSystem, handler: null, baseConfigFilePath: dataSourceFile); if (loader.TryLoadConfig(dataSourceFile, out RuntimeConfig? config, replacementSettings: replacementSettings)) { try @@ -401,6 +407,7 @@ public RuntimeConfig( // Store the child config reference for per-child validation. ChildConfigs.Add((dataSourceFile, config)); + _dataSourceNameToConfigLoader.TryAdd(config.DefaultDataSourceName, loader); _dataSourceNameToDataSource = _dataSourceNameToDataSource.Concat(config._dataSourceNameToDataSource).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); _entityNameToDataSourceName = _entityNameToDataSourceName.Concat(config._entityNameToDataSourceName).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); _autoentityNameToDataSourceName = _autoentityNameToDataSourceName.Concat(config._autoentityNameToDataSourceName).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); diff --git a/src/Core/Services/MetadataProviders/MetadataProviderFactory.cs b/src/Core/Services/MetadataProviders/MetadataProviderFactory.cs index 635928ccc8..41f173c13b 100644 --- a/src/Core/Services/MetadataProviders/MetadataProviderFactory.cs +++ b/src/Core/Services/MetadataProviders/MetadataProviderFactory.cs @@ -49,13 +49,19 @@ private void ConfigureMetadataProviders() { foreach ((string dataSourceName, DataSource dataSource) in _runtimeConfigProvider.GetConfig().GetDataSourceNamesToDataSourcesIterator()) { + RuntimeConfigProvider runtimeConfigProvider = _runtimeConfigProvider; + if (_runtimeConfigProvider.GetConfig().TryGetConfigLoaderFromDataSourceName(dataSourceName, out FileSystemRuntimeConfigLoader? configLoader)) + { + runtimeConfigProvider = new RuntimeConfigProvider(configLoader); + } + ISqlMetadataProvider metadataProvider = dataSource.DatabaseType switch { - DatabaseType.CosmosDB_NoSQL => new CosmosSqlMetadataProvider(_runtimeConfigProvider, _runtimeConfigValidator, _fileSystem), - DatabaseType.MSSQL => new MsSqlMetadataProvider(_runtimeConfigProvider, _runtimeConfigValidator, _queryManagerFactory, _logger, dataSourceName, _isValidateOnly), - DatabaseType.DWSQL => new MsSqlMetadataProvider(_runtimeConfigProvider, _runtimeConfigValidator, _queryManagerFactory, _logger, dataSourceName, _isValidateOnly), - DatabaseType.PostgreSQL => new PostgreSqlMetadataProvider(_runtimeConfigProvider, _runtimeConfigValidator, _queryManagerFactory, _logger, dataSourceName, _isValidateOnly), - DatabaseType.MySQL => new MySqlMetadataProvider(_runtimeConfigProvider, _runtimeConfigValidator, _queryManagerFactory, _logger, dataSourceName, _isValidateOnly), + DatabaseType.CosmosDB_NoSQL => new CosmosSqlMetadataProvider(runtimeConfigProvider, _runtimeConfigValidator, _fileSystem), + DatabaseType.MSSQL => new MsSqlMetadataProvider(runtimeConfigProvider, _runtimeConfigValidator, _queryManagerFactory, _logger, dataSourceName, _isValidateOnly), + DatabaseType.DWSQL => new MsSqlMetadataProvider(runtimeConfigProvider, _runtimeConfigValidator, _queryManagerFactory, _logger, dataSourceName, _isValidateOnly), + DatabaseType.PostgreSQL => new PostgreSqlMetadataProvider(runtimeConfigProvider, _runtimeConfigValidator, _queryManagerFactory, _logger, dataSourceName, _isValidateOnly), + DatabaseType.MySQL => new MySqlMetadataProvider(runtimeConfigProvider, _runtimeConfigValidator, _queryManagerFactory, _logger, dataSourceName, _isValidateOnly), _ => throw new NotSupportedException(dataSource.DatabaseTypeNotSupportedMessage), }; diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 207a374a74..0619622a9d 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -6655,6 +6655,118 @@ public async Task ValidateAutoentitiesConfiguration() "Unexpected autoentity-related validation error."); } + /// + /// End-to-end regression test that creates actual root and child JSON files, loads them through + /// , runs metadata initialization and validation via + /// , and asserts the PER-FILE result. + /// + /// The root references the child through data-source-files. The root's own autoentity + /// resolves ZERO entities (its pattern matches no table) while the child's autoentities for MSSQL + /// resolve real tables (dbo.books). After the child's autoentities are merged into the root, the + /// merged config has resolvable entities, so validation succeeds and no "No entities found" + /// presence error is produced. + /// Requires a running MSSQL instance reachable via the standard MSSQL test connection string. + /// + [TestMethod] + [TestCategory(TestCategory.MSSQL)] + public async Task TestValidate_MultiConfigRootResolvingZero_ProducesRootScopedErrorAndValidChild() + { + // Root autoentity matches nothing (resolves 0); child matches dbo.books (resolves). + (string rootConfigPath, FileSystemRuntimeConfigLoader loader, IFileSystem fileSystem) = + ArrangeMultiConfigForMsSql(); + + // Point the loader at the root config and load through the provider so the child is merged. + loader.UpdateConfigFilePath(rootConfigPath); + RuntimeConfigProvider provider = new(loader); + + ILoggerFactory loggerFactory = new LoggerFactory(); + RuntimeConfigValidator validator = new( + provider, + fileSystem, + loggerFactory.CreateLogger(), + isValidateOnly: true); + + // Runs metadata initialization (real autoentity resolution against MSSQL) + presence validation. + bool isValid = await validator.TryValidateConfig(rootConfigPath, loggerFactory); + Assert.IsTrue(isValid, "Validation should succeed"); + + List presenceErrors = validator.ConfigValidationExceptions + .Where(e => e.Message.Contains("No entities found")) + .ToList(); + Assert.AreEqual(0, presenceErrors.Count, + "Expected no errors to be found"); + } + + /// + /// Helper: builds a real multi-config on disk for MSSQL directly through the + /// object model (the pattern used throughout these Service.Tests), + /// rather than the CLI generators which are not referenced by this test project: + /// - a CHILD config with its own MSSQL data source and autoentities matching dbo.books, + /// - a ROOT config with its own MSSQL data source, autoentities matching + /// , and data-source-files pointing at the child. + /// Returns the root config path plus a fresh loader and file system to drive validation. + /// + private static (string RootConfigPath, FileSystemRuntimeConfigLoader ValidateLoader, IFileSystem FileSystem) + ArrangeMultiConfigForMsSql() + { + string connectionString = GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL); + + // Use a real file system + temp directory so the RuntimeConfig constructor (which loads + // data-source-files through the real file system) can find and merge the child config. + IFileSystem fileSystem = new FileSystem(); + + // Root: own MSSQL data source + autoentities (pattern controls whether it resolves) + + // data-source-files pointing at the child. + RuntimeConfig rootConfig = new( + Schema: "root-schema", + DataSource: new(DatabaseType.MSSQL, connectionString, Options: null), + Entities: new(new Dictionary()), + Autoentities: new(BuildAutoentityMap(definitionName: "root-filter", patternInclude: "dbo.books", entiityNames: "root_{object}")), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new(), + Host: new(null, null, HostMode.Development)), + DataSourceFiles: new DataSourceFiles(new[] { "dab-child.json" })); + File.WriteAllText("dab-root.json", rootConfig.ToJson()); + + // Child: own MSSQL data source + autoentities matching a real table (dbo.books). + RuntimeConfig childConfig = new( + Schema: "child-schema", + DataSource: new(DatabaseType.MSSQL, connectionString, Options: null), + Entities: new(new Dictionary()), + Autoentities: new(BuildAutoentityMap(definitionName: "child-filter", patternInclude: "dbo.books", entiityNames: "child_{object}")), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new(), + Host: new(null, null, HostMode.Development))); + File.WriteAllText("dab-child.json", childConfig.ToJson()); + + return ("dab-root.json", new FileSystemRuntimeConfigLoader(fileSystem), fileSystem); + } + + /// + /// Helper: builds an autoentity map containing a single definition whose include pattern + /// controls which tables it resolves against the target database. + /// + private static Dictionary BuildAutoentityMap(string definitionName, string patternInclude, string entiityNames) + { + EntityAction entityAction = new(EntityActionOperation.Read, null, null); + + Autoentity autoentity = new( + Patterns: new AutoentityPatterns( + Include: new[] { patternInclude }, + Exclude: Array.Empty(), + Name: entiityNames), + Template: new AutoentityTemplate( + Rest: new(Enabled: true), + GraphQL: new(Enabled: true, Singular: string.Empty, Plural: string.Empty)), + Permissions: new EntityPermission[] { new("anonymous", new EntityAction[] { entityAction }) }); + + return new Dictionary { { definitionName, autoentity } }; + } + /// /// 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.