namespace SQLInsight.Core.DynamicsAx; /// One row of the detection query: a column of a known AX table. public sealed record AxDetectionRow(string Schema, string Table, string Column, string Type, int Precision, int MaxLength, bool Readable, string? DatabaseUser = null); /// /// The one place Dynamics AX is recognised: discovery (Servere og databaser), the AX collectors, the AX community /// rules and the web UI all use it. Reads metadata only (sys.tables, sys.columns, HAS_PERMS_BY_NAME), never AX data. /// /// A database is AX when SQLDICTIONARY and DATAAREA are in the same schema. The version follows from the schema: /// RECID int (precision 10) → Axapta 2.5/3.0; bigint without BATCHJOB → AX 4.0; BATCHJOB without NUMBERSEQUENCESCOPE → /// AX 2009; NUMBERSEQUENCESCOPE without a PARTITION column → AX 2012 RTM/FP; PARTITION with the model store database /// <db>_model on the instance → AX 2012 R2/R3; PARTITION without any model store → Dynamics 365 F&O (formodet). /// Only the AX 3.0 rules are verified against a real customer schema; the rest follow Microsoft's documentation. /// /// public static class AxDetection { /// The AX tables SQLInsight knows: the markers and every table an AX check reads. public static readonly IReadOnlyList KnownTables = [ "SQLDICTIONARY", "DATAAREA", "SYSCONFIG", "BATCH", "BATCHJOB", "BATCHJOBHISTORY", "BATCHGROUP", "NUMBERSEQUENCETABLE", "NUMBERSEQUENCESCOPE", "NUMBERSEQUENCEREFERENCE", "SYSTEMSEQUENCES", "MODELELEMENT", ]; private static string TableList => string.Join(", ", KnownTables.Select(t => "N'" + t + "'")); /// /// Run inside the database (), SQL Server 2005 and later: every column of the known AX /// tables, whether the agent may SELECT from the table, and the agent's database user. Names are upper-cased, so a /// case-sensitive collation does not matter. /// public static string MetadataSql => $""" SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length, ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N'.' + QUOTENAME(t.name), N'OBJECT', N'SELECT'), 0), (SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID()) FROM sys.tables t JOIN sys.schemas s ON s.schema_id = t.schema_id JOIN sys.columns c ON c.object_id = t.object_id WHERE UPPER(t.name) IN ({TableList}) ORDER BY s.name, t.name, c.column_id """; /// /// SQL Server 2000 (legacy ODBC), with a three-part name so the session never changes database. No permission /// information: the AX data checks need SQL Server 2005 or later. /// public static string MetadataSql2000(string database) => $""" SELECT u.name, UPPER(o.name), UPPER(c.name), TYPE_NAME(c.xtype), c.xprec, c.length, 0, NULL FROM {AxSql.Identifier(database)}.dbo.sysobjects o JOIN {AxSql.Identifier(database)}.dbo.sysusers u ON u.uid = o.uid JOIN {AxSql.Identifier(database)}.dbo.syscolumns c ON c.id = o.id WHERE o.xtype = 'U' AND UPPER(o.name) IN ({TableList}) ORDER BY u.name, o.name, c.colid """; /// /// Decides from a database's detection rows whether it is AX, and which version. /// /// Every database name on the instance, to find the AX 2012 R2/R3 model store (<db>_model). public static AxDatabase? Recognise(string database, IReadOnlyList rows, IEnumerable databasesOnInstance) { ArgumentNullException.ThrowIfNull(database); ArgumentNullException.ThrowIfNull(rows); ArgumentNullException.ThrowIfNull(databasesOnInstance); var schemas = rows .GroupBy(r => r.Schema, StringComparer.OrdinalIgnoreCase) .Select(g => (Schema: g.Key, Tables: g.GroupBy(r => r.Table, StringComparer.OrdinalIgnoreCase) .Select(t => new AxTable(t.Key.ToUpperInvariant(), t.Any(r => r.Readable), t.Select(r => new AxColumn(r.Column.ToUpperInvariant(), r.Type, r.Precision, r.MaxLength)).ToList())) .ToList())) .Where(s => s.Tables.Any(t => t.Name == "SQLDICTIONARY") && s.Tables.Any(t => t.Name == "DATAAREA")) .OrderByDescending(s => s.Tables.Count) .ThenBy(s => string.Equals(s.Schema, "dbo", StringComparison.OrdinalIgnoreCase) ? 0 : 1) .ToList(); if (schemas.Count == 0) { return null; } var (schema, tables) = schemas[0]; var notes = new List(); bool Has(string table) => tables.Any(t => t.Name == table); var recId = tables.Single(t => t.Name == "SQLDICTIONARY").Column("RECID"); var partition = tables.Any(t => t.Name != "SQLDICTIONARY" && t.HasColumn("PARTITION")); var modelDatabase = databasesOnInstance.FirstOrDefault(d => string.Equals(d, database + "_model", StringComparison.OrdinalIgnoreCase)); AxVersion version; if (recId is null) { version = AxVersion.Unknown; notes.Add("SQLDICTIONARY har ingen RECID-kolonne, så versionen kan ikke bestemmes. AX-datatjek køres ikke."); } else if (recId.Precision <= 10) { version = AxVersion.Axapta30; notes.Add($"RECID er 32-bit (int) i skemaet {schema}: Axapta 2.5 eller 3.0 (de to kan ikke skelnes ud fra skemaet)."); } else if (!Has("BATCHJOB")) { version = AxVersion.Ax40; notes.Add("RECID er 64-bit, og BATCHJOB findes ikke: AX 4.0."); } else if (!Has("NUMBERSEQUENCESCOPE")) { version = AxVersion.Ax2009; notes.Add("BATCHJOB findes, NUMBERSEQUENCESCOPE gør ikke: AX 2009."); } else if (!partition) { version = AxVersion.Ax2012; notes.Add(Has("MODELELEMENT") ? "NUMBERSEQUENCESCOPE uden PARTITION-kolonne, og modellageret ligger i selve databasen: AX 2012 RTM/FP." : "NUMBERSEQUENCESCOPE uden PARTITION-kolonne: AX 2012 RTM/FP."); } else if (modelDatabase is not null || Has("MODELELEMENT")) { version = AxVersion.Ax2012R2; notes.Add(modelDatabase is not null ? $"PARTITION-kolonne og modeldatabasen {modelDatabase}: AX 2012 R2/R3." : "PARTITION-kolonne og modellager i databasen: AX 2012 R2/R3."); } else { version = AxVersion.FinanceOperations; notes.Add($"PARTITION-kolonne, men intet modellager ({database}_model findes ikke på instansen): formodet Dynamics 365 F&O on-premises. " + "Ligger AX 2012 R2/R3-modeldatabasen på en anden instans, er det i stedet AX 2012 R2/R3."); } if (!Has("SYSCONFIG")) { notes.Add("SYSCONFIG findes ikke (genkendt på SQLDICTIONARY og DATAAREA)."); } if (!AxVersions.IsVerified(version) && version != AxVersion.Unknown) { notes.Add(AxVersions.VerificationText(version)); } return new AxDatabase { Database = database, Schema = schema, Version = version, Tables = tables, ModelDatabase = modelDatabase, DatabaseUser = rows.FirstOrDefault(r => r.DatabaseUser is not null)?.DatabaseUser, Notes = notes, }; } }