diff --git a/src/Cli.Tests/AutoSimulateTests.cs b/src/Cli.Tests/AutoSimulateTests.cs new file mode 100644 index 0000000000..14f4412f7c --- /dev/null +++ b/src/Cli.Tests/AutoSimulateTests.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Cli.Tests; + +/// +/// Tests for the auto-config-simulate CLI command. +/// +[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()); + SetCliUtilsLogger(loggerFactory.CreateLogger()); + } + + [TestCleanup] + public void TestCleanup() + { + _fileSystem = null; + _runtimeConfigLoader = null; + } + + /// + /// Tests that the simulate command fails when no autoentities are defined in the config. + /// + [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); + } + + /// + /// Tests that the simulate command options parse the output path correctly. + /// + [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); + } + + /// + /// Tests that the simulate command options default output to null (console output). + /// + [TestMethod] + public void TestSimulateAutoentitiesOptions_DefaultOutputIsNull() + { + // Arrange & Act + AutoConfigSimulateOptions options = new(); + + // Assert + Assert.IsNull(options.Output); + } +} diff --git a/src/Cli/Commands/AutoConfigSimulateOptions.cs b/src/Cli/Commands/AutoConfigSimulateOptions.cs new file mode 100644 index 0000000000..205c8d7d9e --- /dev/null +++ b/src/Cli/Commands/AutoConfigSimulateOptions.cs @@ -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 +{ + /// + /// 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. + /// + [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; + } + } + } +} diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 6d1fcf946d..31c7e91e9f 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -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; @@ -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"; + + /// + /// 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. + /// + /// The simulate options provided by the user. + /// The config loader to read the existing config. + /// The filesystem used for reading the config file and writing output. + /// True if the simulation completed successfully; otherwise, false. + public static bool TrySimulateAutoentities(AutoConfigSimulateOptions 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 in config file."); + return false; + } + + MsSqlQueryBuilder queryBuilder = new(); + string query = queryBuilder.BuildGetAutoentitiesQuery(); + + Dictionary> 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; + } + } + + /// + /// 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. + /// + /// The simulation results keyed by filter (definition) name. + private static void WriteSimulationResultsToConsole(Dictionary> 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(); + } + } + + /// + /// 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. + /// + /// The file path to write the CSV output to. + /// The simulation results keyed by filter (definition) name. + /// The filesystem abstraction used for writing the file. + /// True if the file was written successfully; otherwise, false. + private static bool WriteSimulationResultsToCsvFile( + string outputPath, + Dictionary> 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; + } + } + + /// + /// 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. + /// + /// The value to quote. + /// A properly escaped CSV field value. + private static string QuoteCsvValue(string value) + { + if (value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r')) + { + return $"\"{value.Replace("\"", "\"\"")}\""; + } + + return value; + } + /// /// 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. diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs index e4732da095..de16ed27f5 100644 --- a/src/Cli/Program.cs +++ b/src/Cli/Program.cs @@ -58,7 +58,7 @@ public static int Execute(string[] args, ILogger cliLogger, IFileSystem fileSyst }); // Parsing user arguments and executing required methods. - int result = parser.ParseArguments(args) + int result = parser.ParseArguments(args) .MapResult( (InitOptions options) => options.Handler(cliLogger, loader, fileSystem), (AddOptions options) => options.Handler(cliLogger, loader, fileSystem), @@ -68,6 +68,7 @@ public static int Execute(string[] args, ILogger cliLogger, IFileSystem fileSyst (AddTelemetryOptions options) => options.Handler(cliLogger, loader, fileSystem), (ConfigureOptions options) => options.Handler(cliLogger, loader, fileSystem), (AutoConfigOptions options) => options.Handler(cliLogger, loader, fileSystem), + (AutoConfigSimulateOptions options) => options.Handler(cliLogger, loader, fileSystem), (ExportOptions options) => options.Handler(cliLogger, loader, fileSystem), errors => DabCliParserErrorHandler.ProcessErrorsAndReturnExitCode(errors));