using System.Text;
using System.Text.Json;
using SQLInsight.Collectors.Concurrency;
using SQLInsight.Collectors.DynamicsAx;
using SQLInsight.Core.DynamicsAx;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Tests;
///
/// "SQL Scripts/<regel-id>.sql": a reviewable script per rule. The header is written by hand; the SQL below the
/// marker is exactly what SQLInsight sends, generated from the code here, so it can never drift. Run the tests with
/// SQLINSIGHT_UPDATE_SQL_SCRIPTS=1 to rewrite the generated part after changing a query.
///
public sealed class SqlScriptTests
{
public const string Begin = "-- >>> SQL, som SQLInsight kører – genereret fra koden (tests/SQLInsight.Collectors.Tests/SqlScriptTests.cs). Ret ikke herunder i hånden.";
public const string End = "-- <<< slut på genereret SQL";
private static string DynamicsAxDirectory => Path.Combine(AppContext.BaseDirectory, "DynamicsAx");
public static TheoryData Files() => [.. Generated().Keys];
[Theory]
[MemberData(nameof(Files))]
public void Script_holds_exactly_the_sql_sqlinsight_runs(string file)
{
var path = Path.Combine(RepositoryRoot(), "SQL Scripts", file);
Assert.True(File.Exists(path), $"SQL Scripts/{file} mangler.");
var text = File.ReadAllText(path);
var begin = text.IndexOf(Begin, StringComparison.Ordinal);
var end = text.IndexOf(End, StringComparison.Ordinal);
Assert.True(begin > 0 && end > begin, $"SQL Scripts/{file}: markeringerne for den genererede SQL mangler.");
var expected = Generated()[file];
var actual = text[(begin + Begin.Length)..end].Replace("\r\n", "\n", StringComparison.Ordinal).Trim('\n');
if (actual != expected.Replace("\r\n", "\n", StringComparison.Ordinal).Trim('\n') && Environment.GetEnvironmentVariable("SQLINSIGHT_UPDATE_SQL_SCRIPTS") == "1")
{
var updated = text[..(begin + Begin.Length)] + "\r\n" + expected.Replace("\r\n", "\n", StringComparison.Ordinal).Trim('\n').Replace("\n", "\r\n", StringComparison.Ordinal)
+ "\r\n" + text[end..];
File.WriteAllText(path, updated, new UTF8Encoding(false));
return;
}
Assert.Equal(expected.Replace("\r\n", "\n", StringComparison.Ordinal).Trim('\n'), actual);
}
[Theory]
[MemberData(nameof(Files))]
public void Header_says_what_the_reader_needs(string file)
{
var header = File.ReadAllText(Path.Combine(RepositoryRoot(), "SQL Scripts", file)).Split(Begin)[0];
if (!file.StartsWith('_'))
{
Assert.Contains("Regel:", header, StringComparison.Ordinal);
Assert.Contains("Alvorlighed:", header, StringComparison.Ordinal);
}
Assert.Contains("Rettigheder:", header, StringComparison.Ordinal);
Assert.Contains("Versioner:", header, StringComparison.Ordinal);
Assert.Contains("Collector:", header, StringComparison.Ordinal);
Assert.Contains("ikke verificeret mod kundedata", header, StringComparison.Ordinal);
}
/// File name → the generated SQL.
internal static Dictionary Generated()
{
var ax30 = Database("schema-ax30");
var ax40 = AxDetection.Recognise("AXDB", AxSchemaFile.Find(DynamicsAxDirectory, "schema-ax30")!.DetectionRows()
.Select(r => r with { Schema = "dbo", Precision = r.Column == "RECID" ? 19 : r.Precision, Type = r.Column == "RECID" ? "bigint" : r.Type }).ToList(), ["AXDB"])!;
var ax2009 = Database("schema-ax2009");
var ax2012 = Database("schema-ax2012");
Assert.Equal((AxVersion.Axapta30, AxVersion.Ax40, AxVersion.Ax2009, AxVersion.Ax2012R2), (ax30.Version, ax40.Version, ax2009.Version, ax2012.Version));
var versions = new (string Title, AxDatabase Database)[]
{
("Axapta 3.0 (skema bmssa)", ax30),
("AX 4.0 (skema dbo) – ikke verificeret mod kundedata", ax40),
("AX 2009 – baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata", ax2009),
("AX 2012 RTM/R2/R3 og F&O – baseret på Microsofts dokumenterede skema, ikke verificeret mod kundedata", ax2012),
};
string Batch() => Join(versions.SelectMany(v =>
{
var adapter = AxSchemaAdapter.For(v.Database.Version)!;
var list = new List { Ax($"{v.Title}: batchjob (AxSchemaAdapter.BatchJobs)", v.Database, adapter.BatchJobs(v.Database)) };
if (adapter.BatchHistory(v.Database, null) is { } first)
{
list.Add(Ax($"{v.Title}: kørselshistorik, første læsning (AxSchemaAdapter.BatchHistory)", v.Database, first));
list.Add(Ax($"{v.Title}: kørselshistorik, de næste læsninger fra seneste RECID minus {AxSchemaAdapter.HistoryOverlap}", v.Database,
adapter.BatchHistory(v.Database, Watermark + AxSchemaAdapter.HistoryOverlap)!, watermark: true));
}
return list;
}));
string Numbers() => Join(versions.Select(v => Ax($"{v.Title}: nummerserier (AxSchemaAdapter.NumberSequences)", v.Database,
AxSchemaAdapter.For(v.Database.Version)!.NumberSequences(v.Database))));
string RecIds() => Join(versions.Select(v => Ax($"{v.Title}: RecId pr. tabel (AxSchemaAdapter.RecIds)", v.Database,
AxSchemaAdapter.For(v.Database.Version)!.RecIds(v.Database))));
var detection = Join(
[
Plain("Alle versioner: online brugerdatabaser og deres snapshot-indstillinger (AxDatabaseDetector.DatabasesSql)", AxDatabaseDetector.DatabasesSql),
Plain("Alle versioner: agentens login, til GRANT-teksten", "SELECT SUSER_SNAME()"),
Detection(),
Plain("SQL Server 2000 (kun opdagelse under Servere og databaser, AxDetection.MetadataSql2000)", AxDetection.MetadataSql2000("AXDB")),
.. versions.Select(v => Ax($"{v.Title}: firmaer (AxSchemaAdapter.Companies)", v.Database, AxSchemaAdapter.Companies(v.Database))),
]);
var blocking = Join(
[
Plain("SQL Server 2012 og nyere (BlockingCollector.BuildSql2005)", BlockingCollector.BuildSql2005(SqlServerVersion.Parse("11.0.7001.0"))),
Plain("SQL Server 2008 og 2008 R2", BlockingCollector.BuildSql2005(SqlServerVersion.Parse("10.50.6000.34"))),
Plain("SQL Server 2005", BlockingCollector.BuildSql2005(SqlServerVersion.Parse("9.0.5000.0"))),
Plain("SQL Server 2000 (BlockingCollector.Sql2000)", BlockingCollector.Sql2000),
]);
var files = new Dictionary(StringComparer.Ordinal)
{
["_collectors/AxDatabaseDetector.sql"] = detection,
["_collectors/AxBatchCollector.sql"] = Batch(),
["_collectors/AxSequenceCollector.sql"] = Join([Numbers(), RecIds()]),
["SQLI-AX-001.sql"] = Batch(),
["SQLI-AX-002.sql"] = Batch(),
["SQLI-AX-003.sql"] = Batch(),
["SQLI-AX-004.sql"] = Batch(),
["SQLI-AX-005.sql"] = Numbers(),
["SQLI-AX-006.sql"] = Numbers(),
["SQLI-AX-007.sql"] = Numbers(),
["SQLI-AX-008.sql"] = Numbers(),
["SQLI-AX-009.sql"] = RecIds(),
["SQLI-AX-010.sql"] = Join([detection, blocking]),
};
foreach (var (id, minimum, query) in CommunityAxRules())
{
files[id + ".sql"] = Join(
[
Detection(),
$"-- Variant: SQL Server {Release(minimum)} og nyere – i hver overvåget database, som AX-genkendelsen herover har genkendt (CustomQueryCollector)\r\n"
+ "USE [AXDB]; -- <== ret til AX-databasen\r\n" + query + "\r\nGO",
]);
}
return files;
}
private const long Watermark = 999_999_999;
private static string Release(string minimumVersion) => minimumVersion switch { "9.0" => "2005", "10.0" => "2008", "11.0" => "2012", "12.0" => "2014", "13.0" => "2016", _ => minimumVersion };
private static AxDatabase Database(string schemaFile) =>
AxDetection.Recognise("AXDB", AxSchemaFile.Find(DynamicsAxDirectory, schemaFile)!.DetectionRows(), ["AXDB", "AXDB_model"])!;
private static string Detection() => Plain("SQL Server 2005 og nyere: AX-genkendelse i hver overvåget database (AxDetection.MetadataSql via AxSql.InDatabase)",
"DECLARE @Database sysname;\r\nSET @Database = N'AXDB'; -- <== ret til databasen\r\nDECLARE @Sql nvarchar(max);\r\nSET @Sql = "
+ AxSql.Literal(AxSql.Batch(AxDetection.MetadataSql)) + ";\r\nDECLARE @Procedure nvarchar(300);\r\nSET @Procedure = QUOTENAME(@Database) + N'.sys.sp_executesql';\r\n"
+ "EXEC @Procedure @Sql;\r\n" + AxSql.Reset);
private static string Plain(string title, string sql) => $"-- Variant: {title}\r\n{sql.Trim()}\r\nGO";
///
/// An AX read as the agent sends it: the batch below runs inside the AX database through [db].sys.sp_executesql
/// (read committed, 2 s lock timeout; snapshot instead when the database allows it), so the session never changes database.
///
private static string Ax(string title, AxDatabase database, AxQuery? query, bool watermark = false)
{
Assert.NotNull(query);
Assert.True(query.IsMeasurable, query.NotMeasurable);
var sql = new StringBuilder()
.Append("-- Variant: ").Append(title).Append("\r\n")
.Append("DECLARE @Database sysname;\r\nSET @Database = N'AXDB'; -- <== ret til AX-databasen\r\n")
.Append("DECLARE @Schema sysname;\r\nSET @Schema = N'").Append(database.Schema).Append("'; -- <== ejeren af AX-tabellerne: bmssa i Axapta 3.0, ellers dbo\r\n");
if (watermark)
{
sql.Append("DECLARE @FraRecId bigint;\r\nSET @FraRecId = 0; -- <== SQLInsight bruger den højeste RECID, den har læst, minus ")
.Append(AxSchemaAdapter.HistoryOverlap).Append("\r\n");
}
sql.Append("DECLARE @Sql nvarchar(max);\r\nSET @Sql = ").Append(AxSql.Literal(AxSql.Batch(query.Sql!))).Append(";\r\n")
.Append("SET @Sql = REPLACE(@Sql, N'[").Append(database.Schema).Append("].', QUOTENAME(@Schema) + N'.');\r\n");
if (watermark)
{
sql.Append("SET @Sql = REPLACE(@Sql, N'> ").Append(Watermark).Append("', N'> ' + CAST(@FraRecId AS nvarchar(20)));\r\n");
}
return sql.Append("DECLARE @Procedure nvarchar(300);\r\nSET @Procedure = QUOTENAME(@Database) + N'.sys.sp_executesql';\r\n")
.Append("EXEC @Procedure @Sql;\r\n").Append(AxSql.Reset).Append("\r\nGO").ToString();
}
private static string Join(IEnumerable parts) => string.Join("\r\n\r\n", parts);
private static List<(string Id, string Minimum, string Query)> CommunityAxRules()
{
using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(RepositoryRoot(), "rules", "community", "dynamics-ax.json")),
new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
return document.RootElement.EnumerateArray()
.Select(r => (r.GetProperty("id").GetString()!, r.TryGetProperty("minimumVersion", out var v) ? v.GetString()! : "9.0", r.GetProperty("query").GetString()!))
.ToList();
}
private static string RepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "SQLInsight.slnx")))
{
directory = directory.Parent;
}
return directory?.FullName ?? throw new InvalidOperationException("Repository-roden blev ikke fundet.");
}
}