sqlinsight-pro/src/SQLInsight.Core/DynamicsAx/AxDetection.cs
Søren Eggert Lundsteen Olsen 4ebae4efe6 Dynamics AX core: central detector, per-version schema adapters, read-access GRANT
One place recognises AX (version 3.0/4.0/2009/2012/2012 R2/F&O, schema owner,
model store) from metadata only. Per-version adapters hold all table/column
knowledge, check every column in sys.columns before use and degrade to
'ikke målbar' with the exact column or the exact GRANT text. Only AX 3.0 is
verified against a real customer schema; 2009/2012 are written from
Microsoft's documented schema (docs/dynamics-ax/*.documented.tsv, replaced
by a real export when one is dropped in). grant-dynamics-ax-read.sql is
version- and schema-aware (bmssa) and covers exactly the adapters' tables.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:52:58 +02:00

154 lines
7.6 KiB
C#

namespace SQLInsight.Core.DynamicsAx;
/// <summary>One row of the detection query: a column of a known AX table.</summary>
public sealed record AxDetectionRow(string Schema, string Table, string Column, string Type, int Precision, int MaxLength, bool Readable, string? DatabaseUser = null);
/// <summary>
/// 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.
/// <para>
/// 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
/// &lt;db&gt;_model on the instance → AX 2012 R2/R3; PARTITION without any model store → Dynamics 365 F&amp;O (formodet).
/// Only the AX 3.0 rules are verified against a real customer schema; the rest follow Microsoft's documentation.
/// </para>
/// </summary>
public static class AxDetection
{
/// <summary>The AX tables SQLInsight knows: the markers and every table an AX check reads.</summary>
public static readonly IReadOnlyList<string> KnownTables =
[
"SQLDICTIONARY", "DATAAREA", "SYSCONFIG",
"BATCH", "BATCHJOB", "BATCHJOBHISTORY", "BATCHGROUP",
"NUMBERSEQUENCETABLE", "NUMBERSEQUENCESCOPE", "NUMBERSEQUENCEREFERENCE",
"SYSTEMSEQUENCES", "MODELELEMENT",
];
private static string TableList => string.Join(", ", KnownTables.Select(t => "N'" + t + "'"));
/// <summary>
/// Run inside the database (<see cref="AxSql.InDatabase"/>), 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.
/// </summary>
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
""";
/// <summary>
/// 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.
/// </summary>
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
""";
/// <summary>
/// Decides from a database's detection rows whether it is AX, and which version.
/// </summary>
/// <param name="databasesOnInstance">Every database name on the instance, to find the AX 2012 R2/R3 model store (&lt;db&gt;_model).</param>
public static AxDatabase? Recognise(string database, IReadOnlyList<AxDetectionRow> rows, IEnumerable<string> 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<string>();
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,
};
}
}