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."); } }