Skip to content
Draft
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
127 changes: 127 additions & 0 deletions src/Cli.Tests/AutoSimulateTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Cli.Tests;

/// <summary>
/// Tests for the auto-config-simulate CLI command.
/// </summary>
[TestClass]
public class AutoSimulateTests
{
private IFileSystem? _fileSystem;
private FileSystemRuntimeConfigLoader? _runtimeConfigLoader;

[TestInitialize]
public void TestInitialize()
{
_fileSystem = FileSystemUtils.ProvisionMockFileSystem();
_runtimeConfigLoader = new FileSystemRuntimeConfigLoader(_fileSystem);

ILoggerFactory loggerFactory = TestLoggerSupport.ProvisionLoggerFactory();
ConfigGenerator.SetLoggerForCliConfigGenerator(loggerFactory.CreateLogger<ConfigGenerator>());
SetCliUtilsLogger(loggerFactory.CreateLogger<Utils>());
}

[TestCleanup]
public void TestCleanup()
{
_fileSystem = null;
_runtimeConfigLoader = null;
}

/// <summary>
/// Tests that the simulate command fails when no config file is present.
/// </summary>
[TestMethod]
public void TestSimulateAutoentities_NoConfigFile()
{
// Arrange
AutoConfigSimulateOptions options = new();

// Act
bool success = TrySimulateAutoentities(options, _runtimeConfigLoader!, _fileSystem!);

// Assert
Assert.IsFalse(success);
}

/// <summary>
/// Tests that the simulate command fails when the database type is not MSSQL.
/// </summary>
[TestMethod]
public void TestSimulateAutoentities_NonMssqlDatabase()
{
// Arrange: create a PostgreSQL config
InitOptions initOptions = new(
databaseType: DatabaseType.PostgreSQL,
connectionString: "testconnectionstring",
cosmosNoSqlDatabase: null,
cosmosNoSqlContainer: null,
graphQLSchemaPath: null,
setSessionContext: false,
hostMode: HostMode.Development,
corsOrigin: new List<string>(),
authenticationProvider: EasyAuthType.AppService.ToString(),
restRequestBodyStrict: CliBool.True,
config: TEST_RUNTIME_CONFIG_FILE);
Assert.IsTrue(TryGenerateConfig(initOptions, _runtimeConfigLoader!, _fileSystem!));

AutoConfigSimulateOptions options = new(config: TEST_RUNTIME_CONFIG_FILE);

// Act
bool success = TrySimulateAutoentities(options, _runtimeConfigLoader!, _fileSystem!);

// Assert
Assert.IsFalse(success);
}

/// <summary>
/// Tests that the simulate command fails when no autoentities are defined in the config.
/// </summary>
[TestMethod]
public void TestSimulateAutoentities_NoAutoentitiesDefined()
{
// Arrange: create an MSSQL config without autoentities
InitOptions initOptions = CreateBasicInitOptionsForMsSqlWithConfig(config: TEST_RUNTIME_CONFIG_FILE);
Assert.IsTrue(TryGenerateConfig(initOptions, _runtimeConfigLoader!, _fileSystem!));

AutoConfigSimulateOptions options = new(config: TEST_RUNTIME_CONFIG_FILE);

// Act
bool success = TrySimulateAutoentities(options, _runtimeConfigLoader!, _fileSystem!);

// Assert
Assert.IsFalse(success);
}

/// <summary>
/// Tests that the simulate command options parse the output path correctly.
/// </summary>
[TestMethod]
public void TestSimulateAutoentitiesOptions_OutputPathParsed()
{
// Arrange
string outputPath = "simulation-output.csv";

// Act
AutoConfigSimulateOptions options = new(output: outputPath, config: TEST_RUNTIME_CONFIG_FILE);

// Assert
Assert.AreEqual(outputPath, options.Output);
Assert.AreEqual(TEST_RUNTIME_CONFIG_FILE, options.Config);
}

/// <summary>
/// Tests that the simulate command options default output to null (console output).
/// </summary>
[TestMethod]
public void TestSimulateAutoentitiesOptions_DefaultOutputIsNull()
{
// Arrange & Act
AutoConfigSimulateOptions options = new();

// Assert
Assert.IsNull(options.Output);
}
}
49 changes: 49 additions & 0 deletions src/Cli/Commands/AutoConfigSimulateOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.IO.Abstractions;
using Azure.DataApiBuilder.Config;
using Azure.DataApiBuilder.Product;
using Cli.Constants;
using CommandLine;
using Microsoft.Extensions.Logging;
using static Cli.Utils;
using ILogger = Microsoft.Extensions.Logging.ILogger;

namespace Cli.Commands
{
/// <summary>
/// Command options for the auto-config-simulate verb.
/// Simulates autoentities generation by querying the database and displaying
/// which entities would be created for each filter definition.
/// </summary>
[Verb("auto-config-simulate", isDefault: false, HelpText = "Simulate autoentities generation by querying the database and displaying the results.", Hidden = false)]
public class AutoConfigSimulateOptions : Options
{
public AutoConfigSimulateOptions(
string? output = null,
string? config = null)
: base(config)
{
Output = output;
}

[Option('o', "output", Required = false, HelpText = "Path to output CSV file. If not specified, results are printed to the console.")]
public string? Output { get; }

public int Handler(ILogger logger, FileSystemRuntimeConfigLoader loader, IFileSystem fileSystem)
{
logger.LogInformation("{productName} {version}", PRODUCT_NAME, ProductInfo.GetProductVersion());
bool isSuccess = ConfigGenerator.TrySimulateAutoentities(this, loader, fileSystem);
if (isSuccess)
{
return CliReturnCode.SUCCESS;
}
else
{
logger.LogError("Failed to simulate autoentities.");
return CliReturnCode.GENERAL_ERROR;
}
}
}
}
195 changes: 195 additions & 0 deletions src/Cli/ConfigGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using System.Text;
using Azure.DataApiBuilder.Config;
using Azure.DataApiBuilder.Config.Converters;
using Azure.DataApiBuilder.Config.NamingPolicies;
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Core;
using Azure.DataApiBuilder.Core.Configurations;
using Azure.DataApiBuilder.Core.Resolvers;
using Azure.DataApiBuilder.Service;
using Cli.Commands;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using Serilog;
using static Cli.Utils;
Expand Down Expand Up @@ -3130,6 +3133,198 @@ private static AutoentityPatterns BuildAutoentityPatterns(AutoConfigOptions opti
return parsedPermissions;
}

// Column names returned by the autoentities SQL query.
private const string AUTOENTITIES_COLUMN_ENTITY_NAME = "entity_name";
private const string AUTOENTITIES_COLUMN_OBJECT = "object";
private const string AUTOENTITIES_COLUMN_SCHEMA = "schema";

/// <summary>
/// Simulates the autoentities generation by querying the database and displaying
/// which entities would be created for each autoentities filter definition.
/// When an output file path is provided, results are written as CSV; otherwise they are printed to the console.
/// </summary>
/// <param name="options">The simulate options provided by the user.</param>
/// <param name="loader">The config loader to read the existing config.</param>
/// <param name="fileSystem">The filesystem used for reading the config file and writing output.</param>
/// <returns>True if the simulation completed successfully; otherwise, false.</returns>
public static bool TrySimulateAutoentities(AutoSimulateOptions options, FileSystemRuntimeConfigLoader loader, IFileSystem fileSystem)
{
if (!TryGetConfigFileBasedOnCliPrecedence(loader, options.Config, out string runtimeConfigFile))
{
return false;
}

// Load config with env var replacement so the connection string is fully resolved.
DeserializationVariableReplacementSettings replacementSettings = new(doReplaceEnvVar: true);
if (!loader.TryLoadConfig(runtimeConfigFile, out RuntimeConfig? runtimeConfig, replacementSettings: replacementSettings))
{
_logger.LogError("Failed to read the config file: {runtimeConfigFile}.", runtimeConfigFile);
return false;
}

if (runtimeConfig.DataSource.DatabaseType != DatabaseType.MSSQL)
{
_logger.LogError("Autoentities simulation is only supported for MSSQL databases. Current database type: {DatabaseType}.", runtimeConfig.DataSource.DatabaseType);
return false;
}

if (runtimeConfig.Autoentities?.Autoentities is null || runtimeConfig.Autoentities.Autoentities.Count == 0)
{
_logger.LogError("No autoentities definitions found in the config file.");
return false;
}

string connectionString = runtimeConfig.DataSource.ConnectionString;
if (string.IsNullOrWhiteSpace(connectionString))
{
_logger.LogError("Connection string is missing or empty.");
return false;
}

MsSqlQueryBuilder queryBuilder = new();
string query = queryBuilder.BuildGetAutoentitiesQuery();

Dictionary<string, List<(string EntityName, string SchemaName, string ObjectName)>> results = new();

try
{
using SqlConnection connection = new(connectionString);
connection.Open();

foreach ((string filterName, Autoentity autoentity) in runtimeConfig.Autoentities.Autoentities)
{
string include = string.Join(",", autoentity.Patterns.Include);
string exclude = string.Join(",", autoentity.Patterns.Exclude);
string namePattern = autoentity.Patterns.Name;

List<(string, string, string)> filterResults = new();

using SqlCommand command = new(query, connection);
command.Parameters.AddWithValue("@include_pattern", include);
command.Parameters.AddWithValue("@exclude_pattern", exclude);
command.Parameters.AddWithValue("@name_pattern", namePattern);

using SqlDataReader reader = command.ExecuteReader();
while (reader.Read())
{
string entityName = reader[AUTOENTITIES_COLUMN_ENTITY_NAME]?.ToString() ?? string.Empty;
string objectName = reader[AUTOENTITIES_COLUMN_OBJECT]?.ToString() ?? string.Empty;
string schemaName = reader[AUTOENTITIES_COLUMN_SCHEMA]?.ToString() ?? string.Empty;

if (!string.IsNullOrWhiteSpace(entityName) && !string.IsNullOrWhiteSpace(objectName))
{
filterResults.Add((entityName, schemaName, objectName));
}
}

results[filterName] = filterResults;
}
}
catch (Exception ex)
{
_logger.LogError("Failed to query the database: {Message}", ex.Message);
return false;
}

if (!string.IsNullOrWhiteSpace(options.Output))
{
return WriteSimulationResultsToCsvFile(options.Output, results, fileSystem);
}
else
{
WriteSimulationResultsToConsole(results);
return true;
}
}

/// <summary>
/// Writes the autoentities simulation results to the console in a human-readable format.
/// Results are grouped by filter name with entity-to-database-object mappings.
/// </summary>
/// <param name="results">The simulation results keyed by filter (definition) name.</param>
private static void WriteSimulationResultsToConsole(Dictionary<string, List<(string EntityName, string SchemaName, string ObjectName)>> results)
{
Console.WriteLine("AutoEntities Simulation Results");
Console.WriteLine();

foreach ((string filterName, List<(string EntityName, string SchemaName, string ObjectName)> matches) in results)
{
Console.WriteLine($"Filter: {filterName}");
Console.WriteLine($"Matches: {matches.Count}");
Console.WriteLine();

if (matches.Count == 0)
{
Console.WriteLine("(no matches)");
}
else
{
int maxEntityNameLength = matches.Max(m => m.EntityName.Length);
foreach ((string entityName, string schemaName, string objectName) in matches)
{
Console.WriteLine($"{entityName.PadRight(maxEntityNameLength)} -> {schemaName}.{objectName}");
}
}

Console.WriteLine();
}
}

/// <summary>
/// Writes the autoentities simulation results to a CSV file.
/// The file includes a header row followed by one row per matched entity.
/// If the file already exists it is overwritten.
/// </summary>
/// <param name="outputPath">The file path to write the CSV output to.</param>
/// <param name="results">The simulation results keyed by filter (definition) name.</param>
/// <param name="fileSystem">The filesystem abstraction used for writing the file.</param>
/// <returns>True if the file was written successfully; otherwise, false.</returns>
private static bool WriteSimulationResultsToCsvFile(
string outputPath,
Dictionary<string, List<(string EntityName, string SchemaName, string ObjectName)>> results,
IFileSystem fileSystem)
{
try
{
StringBuilder sb = new();
sb.AppendLine("filter_name,entity_name,database_object");

foreach ((string filterName, List<(string EntityName, string SchemaName, string ObjectName)> matches) in results)
{
foreach ((string entityName, string schemaName, string objectName) in matches)
{
sb.AppendLine($"{QuoteCsvValue(filterName)},{QuoteCsvValue(entityName)},{QuoteCsvValue($"{schemaName}.{objectName}")}");
}
}

fileSystem.File.WriteAllText(outputPath, sb.ToString());
_logger.LogInformation("Simulation results written to {outputPath}.", outputPath);
return true;
}
catch (Exception ex)
{
_logger.LogError("Failed to write output file: {Message}", ex.Message);
return false;
}
}

/// <summary>
/// Quotes a value for inclusion in a CSV field.
/// If the value contains a comma, double-quote, or newline, it is wrapped in double-quotes
/// and any embedded double-quotes are escaped by doubling them.
/// </summary>
/// <param name="value">The value to quote.</param>
/// <returns>A properly escaped CSV field value.</returns>
private static string QuoteCsvValue(string value)
{
if (value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r'))
{
return $"\"{value.Replace("\"", "\"\"")}\"";
}

return value;
}

/// <summary>
/// Attempts to update the Azure Key Vault configuration options based on the provided values.
/// Validates that any user-provided parameter value is valid and updates the runtime configuration accordingly.
Expand Down
Loading