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>
154 lines
7.6 KiB
C#
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
|
|
/// <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.
|
|
/// </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 (<db>_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,
|
|
};
|
|
}
|
|
}
|