using System;
using System.Collections.Generic;
using System.Data;
using System.Data.Common;
using System.Data.SqlClient;
using System.Data.SqlTypes;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using D365MetadataBridge.Models;
namespace D365MetadataBridge.Services
{
///
/// Lazy, fresh, read-only SQL debug operations. No connection on construction and no retry of any operation.
///
/// Two independent guards stand between a request and a write, because on a D365FO dev VM the
/// account running MCP is normally sysadmin, so SQL permissions restrict nothing:
/// 1. admits exactly one SELECT, checked on the ScriptDom syntax tree;
/// 2. every operation runs inside a transaction that is ALWAYS rolled back. A statement the policy
/// should have refused, or a parse the engine reads differently from ScriptDom, can still not
/// leave a change behind: there is no path in this class that commits.
/// Catalog access is limited to an allow-list of database-scoped sys views (),
/// so server-level secrets such as sys.sql_logins stay out of reach.
///
public sealed class AxDbSqlService
{
public const int ResponseByteLimit = 256 * 1024;
///
/// The sys catalog views a query may read: what debugging a table needs (its columns, keys,
/// indexes, constraints, triggers, row counts) and nothing server-scoped. Checked against the
/// object the name actually RESOLVES to, so an unqualified compatibility view such as
/// `syslogins` (which resolves into sys) is refused too.
///
internal static readonly HashSet CatalogViews = new HashSet(StringComparer.OrdinalIgnoreCase)
{
"tables", "columns", "types", "schemas", "objects", "indexes", "index_columns",
"foreign_keys", "foreign_key_columns", "key_constraints", "default_constraints",
"check_constraints", "computed_columns", "identity_columns", "views", "triggers", "partitions",
};
private const string SchemaSql = @"SELECT c.column_id AS ordinal, c.name AS name, t.name AS type,
c.max_length AS maxLength, c.precision AS precision, c.scale AS scale, c.is_nullable AS isNullable,
c.is_identity AS isIdentity, c.is_computed AS isComputed, dc.definition AS defaultDefinition,
CONVERT(bit, CASE WHEN pk.column_id IS NULL THEN 0 ELSE 1 END) AS isPrimaryKey,
pk.key_ordinal AS primaryKeyOrdinal
FROM sys.columns c JOIN sys.types t ON c.user_type_id=t.user_type_id
JOIN sys.objects o ON c.object_id=o.object_id JOIN sys.schemas s ON o.schema_id=s.schema_id
LEFT JOIN sys.default_constraints dc ON c.default_object_id=dc.object_id
LEFT JOIN (
SELECT ic.object_id, ic.column_id, ic.key_ordinal
FROM sys.index_columns ic JOIN sys.indexes i ON ic.object_id=i.object_id AND ic.index_id=i.index_id
WHERE i.is_primary_key=1 AND ic.key_ordinal>0
) pk ON c.object_id=pk.object_id AND c.column_id=pk.column_id
WHERE s.name=@schema AND o.name=@table ORDER BY c.column_id";
private static readonly JsonSerializerOptions Json = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
private readonly AxDbSqlOptions options;
public bool Enabled => options.Enabled;
public AxDbSqlService(AxDbSqlOptions options) { this.options = options; }
public async Task> Handle(string method, JsonElement? input)
{
var watch = Stopwatch.StartNew();
var result = new Dictionary
{
["operationId"] = Guid.NewGuid().ToString("D"), ["server"] = options.Server,
["database"] = options.Database, ["durationMs"] = 0L, ["success"] = false
};
SqlConnection? connection = null;
using (var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(45)))
{
try
{
if (!Enabled) throw new AxDbFailure("AXDB_UNAVAILABLE", "AxDB SQL is disabled. Configure D365FO_SQL_ENABLED and D365FO_SQL_SERVER, then restart the server with an updated bridge.");
var request = input.HasValue ? JsonSerializer.Deserialize(input.Value.GetRawText(), Json) : new AxDbSqlRequest();
if (request == null) throw new ArgumentException("SQL request must be an object.");
var normalized = method.ToLowerInvariant();
if (normalized != "axdbstatus" && normalized != "axdbschema" && normalized != "axdbquery")
throw new ArgumentException("Unknown AxDB SQL operation.");
PlannedStatement? planned = normalized == "axdbquery" ? Plan(request) : null;
if (normalized == "axdbschema")
{
if (string.IsNullOrWhiteSpace(request.Table) || request.Table.Length > 128 || string.IsNullOrWhiteSpace(request.Schema) || request.Schema.Length > 128)
throw new ArgumentException("schema and table must be non-empty identifiers of at most 128 characters.");
}
var maxRows = request.MaxRows ?? options.MaxRows;
if (maxRows < 1 || maxRows > 1000) throw new ArgumentException("maxRows must be 1..1000.");
connection = new SqlConnection(options.ConnectionString());
await connection.OpenAsync(deadline.Token).ConfigureAwait(false);
// Second guard: everything below runs in a transaction that is only ever rolled back.
using (var init = Command(connection, "SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SET XACT_ABORT ON; SET NOCOUNT ON; SET LOCK_TIMEOUT 30000; BEGIN TRANSACTION;", watch))
await init.ExecuteNonQueryAsync(deadline.Token).ConfigureAwait(false);
if (normalized == "axdbstatus")
{
using (var command = Command(connection, "SELECT DB_NAME() AS databaseName, SUSER_SNAME() AS loginName, CONVERT(nvarchar(128), SERVERPROPERTY('ServerName')) AS serverName", watch))
await Read(command, result, 1, deadline.Token).ConfigureAwait(false);
result["available"] = true; result["readOnly"] = true;
result["timeoutSeconds"] = options.TimeoutSeconds; result["maxRows"] = options.MaxRows;
}
else if (normalized == "axdbschema")
{
await CheckTables(connection, new[] { new AxDbTableReference { Schema = request.Schema, Table = request.Table } }, watch, deadline.Token).ConfigureAwait(false);
using (var command = Command(connection, SchemaSql, watch))
{
command.Parameters.Add("@schema", SqlDbType.NVarChar, 128).Value = request.Schema;
command.Parameters.Add("@table", SqlDbType.NVarChar, 128).Value = request.Table;
await Read(command, result, 1000, deadline.Token).ConfigureAwait(false);
}
}
else
{
await CheckTables(connection, planned!.Tables, watch, deadline.Token).ConfigureAwait(false);
using (var command = Command(connection, request.Sql, watch))
{
command.Parameters.AddRange(planned.Parameters);
await Read(command, result, maxRows, deadline.Token).ConfigureAwait(false);
}
}
result["success"] = true;
}
catch (Exception exception)
{
var code = FailureCode(exception, deadline.IsCancellationRequested);
var message = exception.Message;
if (message.Length > 2048) message = message.Substring(0, 2048);
result["error"] = new { code, message };
// A failed read returns no partial dataset, including oversized column metadata.
result.Remove("columns"); result.Remove("rows"); result.Remove("truncated");
if (normalizedStatus(method)) result["available"] = false;
}
finally
{
// Roll back whatever the transaction holds, then close. Disposing the connection
// without a commit rolls back too; the explicit rollback just does not wait for it.
if (connection != null && connection.State == ConnectionState.Open)
{
try
{
using (var rollback = new SqlCommand("IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;", connection) { CommandTimeout = 5 })
await rollback.ExecuteNonQueryAsync().ConfigureAwait(false);
}
catch { }
}
// No transparent retries, reconnects or operation replay.
try { connection?.Dispose(); } catch { }
result["durationMs"] = watch.ElapsedMilliseconds;
}
}
return result;
}
private static bool normalizedStatus(string method) => string.Equals(method, "axdbStatus", StringComparison.OrdinalIgnoreCase);
internal static string FailureCode(Exception exception, bool deadlineCancelled)
{
if (deadlineCancelled) return "AXDB_TIMEOUT";
// SqlSequentialStream/TextReader wrap provider failures in IOException.
while (exception is IOException && exception.InnerException != null) exception = exception.InnerException;
return exception is AxDbFailure failure ? failure.Code :
exception is OperationCanceledException || (exception is SqlException sql && sql.Number == -2) ? "AXDB_TIMEOUT" :
exception is SqlException ? "AXDB_SQL_ERROR" : "AXDB_INVALID_REQUEST";
}
private PlannedStatement Plan(AxDbSqlRequest request)
{
var tables = AxDbSqlPolicy.Validate(request.Sql);
var parameters = (request.Parameters ?? new List()).Select(p => p.ToSqlParameter()).ToArray();
if (parameters.Length > 200 || parameters.Select(p => p.ParameterName).Distinct(StringComparer.OrdinalIgnoreCase).Count() != parameters.Length)
throw new ArgumentException("At most 200 parameters with distinct names are supported.");
return new PlannedStatement { Request = request, Tables = tables, Parameters = parameters };
}
private SqlCommand Command(SqlConnection connection, string sql, Stopwatch watch)
{
var remaining = 45 - watch.Elapsed.TotalSeconds;
if (remaining <= 0) throw new OperationCanceledException("AxDB SQL operation exceeded its 45-second budget.");
return new SqlCommand(sql, connection) { CommandTimeout = Math.Max(1, Math.Min(options.TimeoutSeconds, (int)Math.Ceiling(remaining))) };
}
private async Task CheckTables(SqlConnection connection, IEnumerable tables, Stopwatch watch, CancellationToken token)
{
foreach (var table in tables.GroupBy(t => t.Schema + "\0" + t.Table, StringComparer.Ordinal).Select(g => g.First()))
{
using (var command = Command(connection, @"SELECT o.type, o.is_ms_shipped,
CASE WHEN EXISTS (SELECT 1 FROM sys.external_tables e WHERE e.object_id=o.object_id) THEN 1 ELSE 0 END AS externalTable, s.name, o.name
FROM sys.all_objects o JOIN sys.schemas s ON o.schema_id=s.schema_id
WHERE (@schema='' AND o.object_id=OBJECT_ID(QUOTENAME(@table))) OR (s.name=@schema AND o.name=@table)", watch))
{
command.Parameters.Add("@schema", SqlDbType.NVarChar, 128).Value = table.Schema;
command.Parameters.Add("@table", SqlDbType.NVarChar, 128).Value = table.Table;
using (var reader = await command.ExecuteReaderAsync(token).ConfigureAwait(false))
{
if (!await reader.ReadAsync(token).ConfigureAwait(false)) throw new ArgumentException("Local table not found or not visible: " + table.Schema + "." + table.Table);
var type = reader.GetString(0).Trim();
var catalog = reader.GetString(3).Equals("sys", StringComparison.OrdinalIgnoreCase) && reader.GetBoolean(1) && type == "V";
if (catalog && !CatalogViews.Contains(reader.GetString(4)))
throw new ArgumentException("sys." + reader.GetString(4) + " is not readable here. Catalog views allowed for debugging: sys." + string.Join(", sys.", CatalogViews.OrderBy(v => v, StringComparer.Ordinal)) + ".");
if (reader.GetInt32(2) != 0 || !(type == "U" || catalog))
throw new ArgumentException("Only local base tables and the allowed sys catalog views are supported; views, synonyms and external tables are rejected: " + table.Schema + "." + table.Table);
}
}
}
}
private static async Task Read(SqlCommand command, Dictionary result, int maxRows, CancellationToken token)
{
var rows = new List>();
var columns = new List