Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
eab2698
Fix #3267: Warn when dab validate finds zero entities in the config
aaronburtle Mar 23, 2026
e2849f8
Add test for #3267: Verify warning when zero entities defined
aaronburtle Mar 23, 2026
0459fb3
Merge branch 'main' into dev/aaronburtle/warn-zero-entities-validate
aaronburtle Mar 23, 2026
447b0da
Fix zero-entities check: use .Entities.Count, .Autoentities.Count, an…
aaronburtle Mar 23, 2026
ff044ed
Consolidate TryGetConfig to a single call in IsConfigValid
aaronburtle Mar 23, 2026
c62ed09
Merge branch 'main' into dev/aaronburtle/warn-zero-entities-validate
aaronburtle Mar 26, 2026
346f079
Merge branch 'main' into dev/aaronburtle/warn-zero-entities-validate
aaronburtle Mar 27, 2026
94aa01d
Merge branch 'main' into dev/aaronburtle/warn-zero-entities-validate
anushakolan Mar 31, 2026
57c15ed
Merge branch 'main' into dev/aaronburtle/warn-zero-entities-validate
aaronburtle Apr 3, 2026
d402495
address comments
aaronburtle Apr 6, 2026
ef7df09
Merge branch 'dev/aaronburtle/warn-zero-entities-validate' of github.…
aaronburtle Apr 6, 2026
4f93225
new validation logic
aaronburtle Apr 6, 2026
535b209
Merge branch 'main' into dev/aaronburtle/warn-zero-entities-validate
aaronburtle Apr 7, 2026
d99a098
cleanup
aaronburtle Apr 7, 2026
26be2f9
Merge branch 'dev/aaronburtle/warn-zero-entities-validate' of github.…
aaronburtle Apr 7, 2026
f33b77b
use child configs instead of new metadata class
aaronburtle Apr 7, 2026
06c131e
better tests
aaronburtle Apr 7, 2026
8fee9c0
format
aaronburtle Apr 7, 2026
88f7eae
Merge branch 'main' into dev/aaronburtle/warn-zero-entities-validate
aaronburtle Apr 7, 2026
8e76209
fix tests
aaronburtle Apr 8, 2026
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
6 changes: 3 additions & 3 deletions src/Cli.Tests/ConfigureOptionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -811,7 +811,7 @@ public void TestDatabaseTypeUpdate(string dbType)
string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE);
Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config));
Assert.IsNotNull(config.Runtime);
Assert.AreEqual(config.DataSource.DatabaseType, Enum.Parse<DatabaseType>(dbType, ignoreCase: true));
Assert.AreEqual(config.DataSource!.DatabaseType, Enum.Parse<DatabaseType>(dbType, ignoreCase: true));
}

/// <summary>
Expand Down Expand Up @@ -841,7 +841,7 @@ public void TestDatabaseTypeUpdateCosmosDB_NoSQLToMSSQL()
string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE);
Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config));
Assert.IsNotNull(config.Runtime);
Assert.AreEqual(config.DataSource.DatabaseType, DatabaseType.MSSQL);
Assert.AreEqual(config.DataSource!.DatabaseType, DatabaseType.MSSQL);
Assert.AreEqual(config.DataSource.Options!.GetValueOrDefault("set-session-context", false), true);
Assert.IsFalse(config.DataSource.Options!.ContainsKey("database"));
Assert.IsFalse(config.DataSource.Options!.ContainsKey("container"));
Expand Down Expand Up @@ -877,7 +877,7 @@ public void TestDatabaseTypeUpdateMSSQLToCosmosDB_NoSQL()
string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE);
Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? config));
Assert.IsNotNull(config.Runtime);
Assert.AreEqual(config.DataSource.DatabaseType, DatabaseType.CosmosDB_NoSQL);
Assert.AreEqual(config.DataSource!.DatabaseType, DatabaseType.CosmosDB_NoSQL);
Assert.AreEqual(config.DataSource.Options!.GetValueOrDefault("database"), "testdb");
Assert.AreEqual(config.DataSource.Options!.GetValueOrDefault("container"), "testcontainer");
Assert.AreEqual(config.DataSource.Options!.GetValueOrDefault("schema"), "testschema.gql");
Expand Down
10 changes: 5 additions & 5 deletions src/Cli.Tests/EndToEndTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public Task TestInitForCosmosDBNoSql()

Assert.IsNotNull(runtimeConfig);
Assert.IsTrue(runtimeConfig.AllowIntrospection);
Assert.AreEqual(DatabaseType.CosmosDB_NoSQL, runtimeConfig.DataSource.DatabaseType);
Assert.AreEqual(DatabaseType.CosmosDB_NoSQL, runtimeConfig.DataSource!.DatabaseType);
CosmosDbNoSQLDataSourceOptions? cosmosDataSourceOptions = runtimeConfig.DataSource.GetTypedOptions<CosmosDbNoSQLDataSourceOptions>();
Assert.IsNotNull(cosmosDataSourceOptions);
Assert.AreEqual("graphqldb", cosmosDataSourceOptions.Database);
Expand Down Expand Up @@ -92,7 +92,7 @@ public void TestInitForCosmosDBPostgreSql()
Assert.IsTrue(_runtimeConfigLoader!.TryLoadConfig(TEST_RUNTIME_CONFIG_FILE, out RuntimeConfig? runtimeConfig));

Assert.IsNotNull(runtimeConfig);
Assert.AreEqual(DatabaseType.CosmosDB_PostgreSQL, runtimeConfig.DataSource.DatabaseType);
Assert.AreEqual(DatabaseType.CosmosDB_PostgreSQL, runtimeConfig.DataSource!.DatabaseType);
Assert.IsNotNull(runtimeConfig.Runtime);
Assert.IsNotNull(runtimeConfig.Runtime.Rest);
Assert.AreEqual("/rest-api", runtimeConfig.Runtime.Rest.Path);
Expand Down Expand Up @@ -123,7 +123,7 @@ public void TestInitializingRestAndGraphQLGlobalSettings()
out RuntimeConfig? runtimeConfig,
replacementSettings: replacementSettings));

SqlConnectionStringBuilder builder = new(runtimeConfig.DataSource.ConnectionString);
SqlConnectionStringBuilder builder = new(runtimeConfig.DataSource!.ConnectionString);
Assert.AreEqual(ProductInfo.GetDataApiBuilderUserAgent(), builder.ApplicationName);

Assert.IsNotNull(runtimeConfig);
Expand Down Expand Up @@ -204,7 +204,7 @@ public void TestEnablingMultipleCreateOperation(CliBool isMultipleCreateEnabled,
replacementSettings: replacementSettings));

Assert.IsNotNull(runtimeConfig);
Assert.AreEqual(expectedDbType, runtimeConfig.DataSource.DatabaseType);
Assert.AreEqual(expectedDbType, runtimeConfig.DataSource!.DatabaseType);
Assert.IsNotNull(runtimeConfig.Runtime);
Assert.IsNotNull(runtimeConfig.Runtime.GraphQL);
if (runtimeConfig.DataSource.DatabaseType is DatabaseType.MSSQL && isMultipleCreateEnabled is not CliBool.None)
Expand Down Expand Up @@ -243,7 +243,7 @@ public void TestAddEntity()

Assert.IsTrue(_runtimeConfigLoader!.TryLoadConfig(TEST_RUNTIME_CONFIG_FILE, out RuntimeConfig? addRuntimeConfig));
Assert.IsNotNull(addRuntimeConfig);
Assert.AreEqual(TEST_ENV_CONN_STRING, addRuntimeConfig.DataSource.ConnectionString);
Assert.AreEqual(TEST_ENV_CONN_STRING, addRuntimeConfig.DataSource!.ConnectionString);
Assert.AreEqual(1, addRuntimeConfig.Entities.Count()); // 1 new entity added
Assert.IsTrue(addRuntimeConfig.Entities.ContainsKey("todo"));
Entity entity = addRuntimeConfig.Entities["todo"];
Expand Down
8 changes: 8 additions & 0 deletions src/Cli.Tests/ModuleInitializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,14 @@ public static void Init()
VerifierSettings.IgnoreMember<DataSource>(dataSource => dataSource.DatabaseTypeNotSupportedMessage);
// Ignore DefaultDataSourceName as that's not serialized in our config file.
VerifierSettings.IgnoreMember<RuntimeConfig>(config => config.DefaultDataSourceName);
// Ignore IsRootConfig as that's a computed property for validation, not serialized.
VerifierSettings.IgnoreMember<RuntimeConfig>(config => config.IsRootConfig);
// Ignore IsChildConfig as that's a runtime flag for validation, not serialized.
VerifierSettings.IgnoreMember<RuntimeConfig>(config => config.IsChildConfig);
// Ignore AutoentityResolutionCounts as that's populated at runtime during metadata initialization.
VerifierSettings.IgnoreMember<RuntimeConfig>(config => config.AutoentityResolutionCounts);
// Ignore ChildConfigs as that's populated at runtime during child config loading.
VerifierSettings.IgnoreMember<RuntimeConfig>(config => config.ChildConfigs);
// Ignore MaxResponseSizeMB as as that's unimportant from a test standpoint.
VerifierSettings.IgnoreMember<HostOptions>(options => options.MaxResponseSizeMB);
// Ignore UserProvidedMaxResponseSizeMB as that's not serialized in our config file.
Expand Down
4 changes: 2 additions & 2 deletions src/Cli.Tests/UserDelegatedAuthRuntimeParsingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public void TestRuntimeCanParseUserDelegatedAuthConfig()
// Assert
Assert.IsTrue(success);
Assert.IsNotNull(config);
Assert.IsNotNull(config.DataSource.UserDelegatedAuth);
Assert.IsNotNull(config.DataSource!.UserDelegatedAuth);
Assert.IsTrue(config.DataSource.UserDelegatedAuth.Enabled);
Assert.AreEqual("https://database.windows.net", config.DataSource.UserDelegatedAuth.DatabaseAudience);
}
Expand Down Expand Up @@ -95,7 +95,7 @@ public void TestRuntimeCanParseConfigWithoutUserDelegatedAuth()
// Assert
Assert.IsTrue(success);
Assert.IsNotNull(config);
Assert.IsNull(config.DataSource.UserDelegatedAuth);
Assert.IsNull(config.DataSource!.UserDelegatedAuth);
}
}
}
250 changes: 250 additions & 0 deletions src/Cli.Tests/ValidateConfigTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -359,4 +359,254 @@ private async Task ValidatePropertyOptionsFails(ConfigureOptions options)
JsonSchemaValidationResult result = await validator.ValidateConfigSchema(config, TEST_RUNTIME_CONFIG_FILE, mockLoggerFactory.Object);
Assert.IsFalse(result.IsValid);
}

/// <summary>
/// Validates that a non-root config (has data-source but no data-source-files) with zero entities
/// and an invalid connection string gets a connection string validation error.
/// Entity validation is gated on successful DB connectivity, so no entity error fires.
/// The validation still returns false due to the connection string error.
/// Regression test for https://github.com/Azure/data-api-builder/issues/3267
/// </summary>
[TestMethod]
public void TestValidateNonRootZeroEntitiesWithInvalidConnectionString()
{
((MockFileSystem)_fileSystem!).AddFile(TEST_RUNTIME_CONFIG_FILE, INVALID_INTIAL_CONFIG);
ValidateOptions validateOptions = new(TEST_RUNTIME_CONFIG_FILE);

Mock<ILogger<ConfigGenerator>> mockLogger = new();
SetLoggerForCliConfigGenerator(mockLogger.Object);

bool isValid = ConfigGenerator.IsConfigValid(validateOptions, _runtimeConfigLoader!, _fileSystem!);

// Validation should fail due to the empty connection string.
Assert.IsFalse(isValid);
}

/// <summary>
/// Validates that a root config (with data-source-files pointing to children)
/// that has no data-source and no entities is considered structurally valid
/// for parsing. The root config delegates entity requirements to children.
/// </summary>
[TestMethod]
public void TestRootConfigWithNoDataSourceAndNoEntitiesParses()
{
string rootConfig = @"
{
""$schema"": """ + DAB_DRAFT_SCHEMA_TEST_PATH + @""",
""runtime"": {
""rest"": { ""enabled"": true },
""graphql"": { ""enabled"": true },
""host"": { ""mode"": ""development"" }
},
""data-source-files"": [""child1.json""],
""entities"": {}
}";

// The root config should parse without error (no data-source required for root).
Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(rootConfig, out RuntimeConfig? config));
Assert.IsNotNull(config);
Assert.IsTrue(config.IsRootConfig);
}

/// <summary>
/// Validates that a non-root config with a data-source and no entities parses
/// successfully. Validation of entity presence happens during dab validate,
/// not during parsing.
/// </summary>
[TestMethod]
public void TestNonRootConfigWithDataSourceAndNoEntitiesParses()
{
Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(INITIAL_CONFIG, out RuntimeConfig? config));
Assert.IsNotNull(config);
Assert.IsFalse(config.IsRootConfig);
}

/// <summary>
/// Validates that a non-root config with a data source but no entities
/// produces a validation error from ValidateDataSourceAndEntityPresence.
/// </summary>
[TestMethod]
public void TestNonRootWithDataSourceAndNoEntitiesProducesError()
{
RuntimeConfig config = BuildTestConfig(
hasDataSource: true,
entities: new Dictionary<string, Entity>());

RuntimeConfigValidator validator = BuildValidator(config);
validator.ValidateDataSourceAndEntityPresence(config);

Assert.AreEqual(1, validator.ConfigValidationExceptions.Count);
Assert.IsTrue(validator.ConfigValidationExceptions[0].Message.Contains("no entities found"));
}

/// <summary>
/// Validates that a non-root config with no data source
/// produces a validation error requiring a data source.
/// </summary>
[TestMethod]
public void TestNonRootWithNoDataSourceProducesError()
{
RuntimeConfig config = BuildTestConfig(
hasDataSource: false,
entities: new Dictionary<string, Entity>());

RuntimeConfigValidator validator = BuildValidator(config);
validator.ValidateDataSourceAndEntityPresence(config);

Assert.AreEqual(1, validator.ConfigValidationExceptions.Count);
Assert.IsTrue(validator.ConfigValidationExceptions[0].Message.Contains("data source is required"));
}

/// <summary>
/// Validates that a non-root config with a data source and entities passes validation.
/// </summary>
[TestMethod]
public void TestNonRootWithDataSourceAndEntitiesIsValid()
{
Dictionary<string, Entity> entities = new()
{
{ "Book", BuildSimpleEntity("dbo.books") }
};

RuntimeConfig config = BuildTestConfig(hasDataSource: true, entities: entities);

RuntimeConfigValidator validator = BuildValidator(config);
validator.ValidateDataSourceAndEntityPresence(config);

Assert.AreEqual(0, validator.ConfigValidationExceptions.Count);
}

/// <summary>
/// Validates that a root config with no data source and no entities is valid
/// (children carry the load).
/// </summary>
[TestMethod]
public void TestRootWithNoDataSourceAndNoEntitiesIsValid()
{
// Build a child config with a data source and entity.
RuntimeConfig childConfig = BuildTestConfig(
hasDataSource: true,
entities: new Dictionary<string, Entity>
{
{ "Book", BuildSimpleEntity("dbo.books") }
});
childConfig.IsChildConfig = true;

// Build a root config with no data source, pointing to the child.
RuntimeConfig rootConfig = BuildTestConfig(
hasDataSource: false,
entities: new Dictionary<string, Entity>(),
dataSourceFiles: new DataSourceFiles(new[] { "child.json" }));
rootConfig.ChildConfigs.Add(("child.json", childConfig));

RuntimeConfigValidator validator = BuildValidator(rootConfig);
validator.ValidateDataSourceAndEntityPresence(rootConfig);

Assert.AreEqual(0, validator.ConfigValidationExceptions.Count);
}

/// <summary>
/// Validates that a child config with a data source but no entities produces
/// an error that names the child file.
/// </summary>
[TestMethod]
public void TestChildWithDataSourceAndNoEntitiesProducesNamedError()
{
RuntimeConfig childConfig = BuildTestConfig(
hasDataSource: true,
entities: new Dictionary<string, Entity>());
childConfig.IsChildConfig = true;

RuntimeConfig rootConfig = BuildTestConfig(
hasDataSource: false,
entities: new Dictionary<string, Entity>(),
dataSourceFiles: new DataSourceFiles(new[] { "child-db.json" }));
rootConfig.ChildConfigs.Add(("child-db.json", childConfig));

RuntimeConfigValidator validator = BuildValidator(rootConfig);
validator.ValidateDataSourceAndEntityPresence(rootConfig);

Assert.AreEqual(1, validator.ConfigValidationExceptions.Count);
Assert.IsTrue(validator.ConfigValidationExceptions[0].Message.Contains("child-db.json"));
Assert.IsTrue(validator.ConfigValidationExceptions[0].Message.Contains("no entities found"));
}

/// <summary>
/// Validates that a child config with no data source produces
/// an error that names the child file.
/// </summary>
[TestMethod]
public void TestChildWithNoDataSourceProducesNamedError()
{
RuntimeConfig childConfig = BuildTestConfig(
hasDataSource: false,
entities: new Dictionary<string, Entity>());
childConfig.IsChildConfig = true;

RuntimeConfig rootConfig = BuildTestConfig(
hasDataSource: false,
entities: new Dictionary<string, Entity>(),
dataSourceFiles: new DataSourceFiles(new[] { "child-db.json" }));
rootConfig.ChildConfigs.Add(("child-db.json", childConfig));

RuntimeConfigValidator validator = BuildValidator(rootConfig);
validator.ValidateDataSourceAndEntityPresence(rootConfig);

Assert.AreEqual(1, validator.ConfigValidationExceptions.Count);
Assert.IsTrue(validator.ConfigValidationExceptions[0].Message.Contains("child-db.json"));
Assert.IsTrue(validator.ConfigValidationExceptions[0].Message.Contains("data source is required"));
}

/// <summary>
/// Helper: builds a RuntimeConfigValidator in validate-only mode over the given config.
/// </summary>
private static RuntimeConfigValidator BuildValidator(RuntimeConfig config)
{
MockFileSystem fs = new();
FileSystemRuntimeConfigLoader loader = new(fs)
{
RuntimeConfig = config
};
RuntimeConfigProvider provider = new(loader);
return new RuntimeConfigValidator(provider, fs, new Mock<ILogger<RuntimeConfigValidator>>().Object, isValidateOnly: true);
}

/// <summary>
/// Helper: builds a minimal RuntimeConfig for testing.
/// </summary>
private static RuntimeConfig BuildTestConfig(
bool hasDataSource,
Dictionary<string, Entity> entities,
DataSourceFiles? dataSourceFiles = null)
{
DataSource? ds = hasDataSource
? new DataSource(DatabaseType.MSSQL, "Server=localhost;Database=test;", Options: null)
: null;

return new RuntimeConfig(
Schema: null,
DataSource: ds,
Runtime: new(
Rest: new(),
GraphQL: new(),
Mcp: new(),
Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)),
Entities: new RuntimeEntities(entities),
DataSourceFiles: dataSourceFiles);
}

/// <summary>
/// Helper: builds a simple entity for testing.
/// </summary>
private static Entity BuildSimpleEntity(string source)
{
return new Entity(
Source: new EntitySource(Object: source, Type: EntitySourceType.Table, Parameters: null, KeyFields: null),
GraphQL: new(Singular: null, Plural: null),
Fields: null,
Rest: new(EntityRestOptions.DEFAULT_SUPPORTED_VERBS),
Permissions: new[] { new EntityPermission("anonymous", new[] { new EntityAction(EntityActionOperation.Read, null, null) }) },
Relationships: null,
Mappings: null);
}
}
Loading
Loading