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,
};
}
}