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>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-02 12:53:22 +02:00
parent 66aa47ecb3
commit 4ebae4efe6
20 changed files with 2730 additions and 6 deletions

View file

@ -0,0 +1,101 @@
/*
SQLInsight Pro: valgfri læseadgang til Dynamics AX' systemtabeller
=====================================================================
TILVALG. Agenten læser normalt kun metadata (grant-least-privilege.sql). Med dette script må den desuden læse de
få AX-systemtabeller, som AX-tjekkene bruger, i ÉN navngiven AX-database, så SQLInsight kan advare om batchjob i
fejl, forsinkede eller hængende batchjob, nummerserier og RecId'er der er ved at løbe tør:
Axapta 3.0 og AX 4.0: BATCH, NUMBERSEQUENCETABLE, NUMBERSEQUENCEREFERENCE, SYSTEMSEQUENCES, SQLDICTIONARY, DATAAREA
AX 2009: BATCHJOB, BATCHJOBHISTORY, NUMBERSEQUENCETABLE, NUMBERSEQUENCEREFERENCE, SYSTEMSEQUENCES,
SQLDICTIONARY, DATAAREA
AX 2012 og F&O: BATCHJOB, BATCHJOBHISTORY, NUMBERSEQUENCETABLE, NUMBERSEQUENCESCOPE, NUMBERSEQUENCEREFERENCE,
SYSTEMSEQUENCES, SQLDICTIONARY, DATAAREA
Kun SELECT, kun disse tabeller: ingen kundedata (debitorer, ordrer, posteringer), ingen skriverettigheder. Scriptet
finder selv AX-versionen og skemaet, tabellerne ligger i (bmssa i Axapta 3.0, dbo senere). Tabeller, der ikke findes
i databasen, springes over. Scriptet kan køres igen uden skade. Fjern adgangen igen med revoke-dynamics-ax-read.sql.
SQLInsight Web viser det samme som færdige GRANT-sætninger for hver AX-database (Dynamics AX → AX-databaser).
AX 2009, 2012 og F&O er baseret på Microsofts dokumenterede skema – ikke verificeret mod kundedata.
Understøtter SQL Server 2005 og nyere. Kør som sysadmin eller som db_owner/db_securityadmin i AX-databasen.
*/
SET NOCOUNT ON;
DECLARE @DatabaseName sysname;
DECLARE @LoginName sysname;
SET @DatabaseName = N'AXDB'; -- <== ret til AX-databasen
SET @LoginName = N'DOMÆNE\svc-sqlinsight$'; -- <== ret til agentens login
IF DB_ID(@DatabaseName) IS NULL
BEGIN
RAISERROR(N'Databasen %s findes ikke.', 16, 1, @DatabaseName);
RETURN;
END;
IF SUSER_SID(@LoginName) IS NULL
BEGIN
RAISERROR(N'Login %s findes ikke. Kør grant-least-privilege.sql først.', 16, 1, @LoginName);
RETURN;
END;
DECLARE @Procedure nvarchar(300);
DECLARE @Sql nvarchar(max);
SET @Procedure = QUOTENAME(@DatabaseName) + N'.sys.sp_executesql';
SET @Sql = N'
SET NOCOUNT ON;
DECLARE @schema sysname;
DECLARE @precision int;
DECLARE @version nvarchar(10);
DECLARE @tables nvarchar(400);
DECLARE @user sysname;
DECLARE @grant nvarchar(max);
DECLARE @count int;
SELECT TOP 1 @schema = s.name, @precision = c.precision
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
LEFT JOIN sys.columns c ON c.object_id = t.object_id AND UPPER(c.name) = N''RECID''
WHERE UPPER(t.name) = N''SQLDICTIONARY'' AND OBJECT_ID(QUOTENAME(s.name) + N''.DATAAREA'', N''U'') IS NOT NULL
ORDER BY CASE WHEN s.name = N''dbo'' THEN 0 ELSE 1 END, s.name;
IF @schema IS NULL
BEGIN
DECLARE @database sysname;
SET @database = DB_NAME();
RAISERROR(N''%s er ikke en Dynamics AX-database (SQLDICTIONARY og DATAAREA findes ikke i samme skema). Intet er ændret.'', 16, 1, @database);
RETURN;
END;
/* The same version rules as SQLInsight''s detection (Core/DynamicsAx/AxDetection.cs). */
IF @precision <= 10 SET @version = N''3.0'';
ELSE IF OBJECT_ID(QUOTENAME(@schema) + N''.BATCHJOB'', N''U'') IS NULL SET @version = N''4.0'';
ELSE IF OBJECT_ID(QUOTENAME(@schema) + N''.NUMBERSEQUENCESCOPE'', N''U'') IS NULL SET @version = N''2009'';
ELSE SET @version = N''2012'';
/* The tables per version: the same lists as the version adapters'' ReadTables (Core/DynamicsAx/AxSchemaAdapter.cs). */
SET @tables = CASE
WHEN @version IN (N''3.0'', N''4.0'') THEN N''|BATCH|NUMBERSEQUENCETABLE|NUMBERSEQUENCEREFERENCE|SYSTEMSEQUENCES|SQLDICTIONARY|DATAAREA|''
WHEN @version = N''2009'' THEN N''|BATCHJOB|BATCHJOBHISTORY|NUMBERSEQUENCETABLE|NUMBERSEQUENCEREFERENCE|SYSTEMSEQUENCES|SQLDICTIONARY|DATAAREA|''
ELSE N''|BATCHJOB|BATCHJOBHISTORY|NUMBERSEQUENCETABLE|NUMBERSEQUENCESCOPE|NUMBERSEQUENCEREFERENCE|SYSTEMSEQUENCES|SQLDICTIONARY|DATAAREA|''
END;
SELECT @user = name FROM sys.database_principals WHERE sid = SUSER_SID(@login);
IF @user IS NULL
BEGIN
SET @user = @login;
SET @grant = N''CREATE USER '' + QUOTENAME(@user) + N'' FOR LOGIN '' + QUOTENAME(@login) + N'';'';
EXEC (@grant);
PRINT N''Bruger oprettet uden rettigheder: '' + @user;
END;
SET @grant = N'''';
SET @count = 0;
SELECT @grant = @grant + N''GRANT SELECT ON '' + QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name) + N'' TO '' + QUOTENAME(@user) + N''; '', @count = @count + 1
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
WHERE s.name = @schema AND CHARINDEX(N''|'' + UPPER(t.name) + N''|'', @tables) > 0;
IF @count > 0 EXEC (@grant);
PRINT N''Dynamics AX '' + @version + N'' i skemaet '' + @schema + N'': SELECT givet på '' + CAST(@count AS nvarchar(10)) + N'' tabeller i '' + DB_NAME() + N'' til '' + @user + N''.'';';
EXEC @Procedure @Sql, N'@login sysname', @login = @LoginName;

View file

@ -0,0 +1,48 @@
/*
SQLInsight Pro: fjern den valgfri læseadgang til Dynamics AX' systemtabeller
==============================================================================
Fjerner det, grant-dynamics-ax-read.sql gav, i én AX-database: SELECT på AX' batch-, nummerserie-, RecId- og
firmatabeller. Agentens database-bruger slettes ikke (den kan være oprettet af grant-least-privilege.sql). Bagefter
viser SQLInsight AX-datatjekkene som "ikke målbar" med den GRANT, der skal til for at slå dem til igen.
Understøtter SQL Server 2005 og nyere. Kør som sysadmin eller som db_owner/db_securityadmin i AX-databasen.
*/
SET NOCOUNT ON;
DECLARE @DatabaseName sysname;
DECLARE @LoginName sysname;
SET @DatabaseName = N'AXDB'; -- <== ret til AX-databasen
SET @LoginName = N'DOMÆNE\svc-sqlinsight$'; -- <== ret til agentens login
IF DB_ID(@DatabaseName) IS NULL
BEGIN
RAISERROR(N'Databasen %s findes ikke.', 16, 1, @DatabaseName);
RETURN;
END;
DECLARE @Procedure nvarchar(300);
DECLARE @Sql nvarchar(max);
SET @Procedure = QUOTENAME(@DatabaseName) + N'.sys.sp_executesql';
SET @Sql = N'
SET NOCOUNT ON;
DECLARE @user sysname;
DECLARE @revoke nvarchar(max);
DECLARE @count int;
SELECT @user = name FROM sys.database_principals WHERE sid = SUSER_SID(@login);
IF @user IS NULL
BEGIN
PRINT N''Login''''et har ingen bruger i '' + DB_NAME() + N''. Intet at fjerne.'';
RETURN;
END;
SET @revoke = N'''';
SET @count = 0;
SELECT @revoke = @revoke + N''REVOKE SELECT ON '' + QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name) + N'' FROM '' + QUOTENAME(@user) + N''; '', @count = @count + 1
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
WHERE UPPER(t.name) IN (N''BATCH'', N''BATCHJOB'', N''BATCHJOBHISTORY'', N''NUMBERSEQUENCETABLE'', N''NUMBERSEQUENCESCOPE'',
N''NUMBERSEQUENCEREFERENCE'', N''SYSTEMSEQUENCES'', N''SQLDICTIONARY'', N''DATAAREA'');
IF @count > 0 EXEC (@revoke);
PRINT N''SELECT fjernet på '' + CAST(@count AS nvarchar(10)) + N'' AX-tabeller i '' + DB_NAME() + N'' for '' + @user + N''.'';';
EXEC @Procedure @Sql, N'@login sysname', @login = @LoginName;

View file

@ -0,0 +1,129 @@
skema tabel kolonne type max_length column_id
# Baseret på Microsofts dokumenterede skema – ikke verificeret mod kundedata. Erstattes af schema-ax2009.tsv, når en kunde leverer det rigtige skema.
dbo SQLDICTIONARY TABLEID int 4 1
dbo SQLDICTIONARY FIELDID int 4 2
dbo SQLDICTIONARY ARRAY int 4 3
dbo SQLDICTIONARY NAME nvarchar 80 4
dbo SQLDICTIONARY SQLNAME nvarchar 80 5
dbo SQLDICTIONARY FIELDTYPE int 4 6
dbo SQLDICTIONARY STRSIZE int 4 7
dbo SQLDICTIONARY SHADOW int 4 8
dbo SQLDICTIONARY RIGHTJUSTIFY int 4 9
dbo SQLDICTIONARY NULLABLE int 4 10
dbo SQLDICTIONARY FLAGS int 4 11
dbo SQLDICTIONARY RECVERSION int 4 12
dbo SQLDICTIONARY RECID bigint 8 13
dbo DATAAREA ID nvarchar 8 1
dbo DATAAREA NAME nvarchar 120 2
dbo DATAAREA ISVIRTUAL int 4 3
dbo DATAAREA ALWAYSNATIVE int 4 4
dbo DATAAREA TIMEZONE int 4 5
dbo DATAAREA RECVERSION int 4 6
dbo DATAAREA RECID bigint 8 7
dbo SYSCONFIG CONFIGTYPE int 4 1
dbo SYSCONFIG ID int 4 2
dbo SYSCONFIG VALUE nvarchar 160 3
dbo SYSCONFIG MODIFIEDDATETIME datetime 8 4
dbo SYSCONFIG MODIFIEDBY nvarchar 10 5
dbo SYSCONFIG CREATEDDATETIME datetime 8 6
dbo SYSCONFIG CREATEDBY nvarchar 10 7
dbo SYSCONFIG RECVERSION int 4 8
dbo SYSCONFIG RECID bigint 8 9
dbo BATCHJOB CAPTION nvarchar 200 1
dbo BATCHJOB STATUS int 4 2
dbo BATCHJOB ORIGSTARTDATETIME datetime 8 3
dbo BATCHJOB ORIGSTARTDATETIMETZID int 4 4
dbo BATCHJOB STARTDATETIME datetime 8 5
dbo BATCHJOB ENDDATETIME datetime 8 6
dbo BATCHJOB RECURRENCEDATA image 16 7
dbo BATCHJOB ALERTSPROCESSED int 4 8
dbo BATCHJOB CANCELEDBY nvarchar 10 9
dbo BATCHJOB COMPANY nvarchar 8 10
dbo BATCHJOB LOGLEVEL int 4 11
dbo BATCHJOB BATCHCREATEDBY nvarchar 10 12
dbo BATCHJOB MODIFIEDDATETIME datetime 8 13
dbo BATCHJOB MODIFIEDBY nvarchar 10 14
dbo BATCHJOB CREATEDDATETIME datetime 8 15
dbo BATCHJOB CREATEDBY nvarchar 10 16
dbo BATCHJOB RECVERSION int 4 17
dbo BATCHJOB RECID bigint 8 18
dbo BATCHJOBHISTORY BATCHJOBID bigint 8 1
dbo BATCHJOBHISTORY CAPTION nvarchar 200 2
dbo BATCHJOBHISTORY STATUS int 4 3
dbo BATCHJOBHISTORY ORIGSTARTDATETIME datetime 8 4
dbo BATCHJOBHISTORY STARTDATETIME datetime 8 5
dbo BATCHJOBHISTORY ENDDATETIME datetime 8 6
dbo BATCHJOBHISTORY COMPANY nvarchar 8 7
dbo BATCHJOBHISTORY CANCELEDBY nvarchar 10 8
dbo BATCHJOBHISTORY BATCHCREATEDBY nvarchar 10 9
dbo BATCHJOBHISTORY RECVERSION int 4 10
dbo BATCHJOBHISTORY RECID bigint 8 11
dbo BATCH BATCHJOBID bigint 8 1
dbo BATCH GROUPID nvarchar 20 2
dbo BATCH STATUS int 4 3
dbo BATCH CLASSNUMBER int 4 4
dbo BATCH CAPTION nvarchar 200 5
dbo BATCH STARTDATETIME datetime 8 6
dbo BATCH ENDDATETIME datetime 8 7
dbo BATCH SERVERID nvarchar 40 8
dbo BATCH COMPANY nvarchar 8 9
dbo BATCH PARAMETERS image 16 10
dbo BATCH INFO image 16 11
dbo BATCH SESSIONIDX int 4 12
dbo BATCH SESSIONLOGINDATETIME datetime 8 13
dbo BATCH RECVERSION int 4 14
dbo BATCH RECID bigint 8 15
dbo BATCHGROUP GROUP_ nvarchar 20 1
dbo BATCHGROUP DESCRIPTION nvarchar 120 2
dbo BATCHGROUP RECVERSION int 4 3
dbo BATCHGROUP RECID bigint 8 4
dbo NUMBERSEQUENCETABLE NUMBERSEQUENCE nvarchar 40 1
dbo NUMBERSEQUENCETABLE TXT nvarchar 120 2
dbo NUMBERSEQUENCETABLE LOWEST int 4 3
dbo NUMBERSEQUENCETABLE HIGHEST int 4 4
dbo NUMBERSEQUENCETABLE NEXTREC int 4 5
dbo NUMBERSEQUENCETABLE BLOCKED int 4 6
dbo NUMBERSEQUENCETABLE FORMAT nvarchar 40 7
dbo NUMBERSEQUENCETABLE CONTINUOUS int 4 8
dbo NUMBERSEQUENCETABLE CYCLIC int 4 9
dbo NUMBERSEQUENCETABLE CLEANATACCESS int 4 10
dbo NUMBERSEQUENCETABLE INUSE int 4 11
dbo NUMBERSEQUENCETABLE NOINCREMENT int 4 12
dbo NUMBERSEQUENCETABLE CLEANINTERVAL numeric 13 13
dbo NUMBERSEQUENCETABLE LATESTCLEANDATETIME datetime 8 14
dbo NUMBERSEQUENCETABLE ALLOWCHANGEUP int 4 15
dbo NUMBERSEQUENCETABLE ALLOWCHANGEDOWN int 4 16
dbo NUMBERSEQUENCETABLE MANUAL int 4 17
dbo NUMBERSEQUENCETABLE FETCHAHEADQTY int 4 18
dbo NUMBERSEQUENCETABLE FETCHAHEAD int 4 19
dbo NUMBERSEQUENCETABLE DATAAREAID nvarchar 8 20
dbo NUMBERSEQUENCETABLE RECVERSION int 4 21
dbo NUMBERSEQUENCETABLE RECID bigint 8 22
dbo NUMBERSEQUENCEREFERENCE NUMBERSEQUENCE nvarchar 40 1
dbo NUMBERSEQUENCEREFERENCE DATATYPEID int 4 2
dbo NUMBERSEQUENCEREFERENCE CONFIGURATIONKEYID int 4 3
dbo NUMBERSEQUENCEREFERENCE MODULE int 4 4
dbo NUMBERSEQUENCEREFERENCE REFERENCEHELP nvarchar 510 5
dbo NUMBERSEQUENCEREFERENCE REFERENCELABEL nvarchar 120 6
dbo NUMBERSEQUENCEREFERENCE DATATYPESAMEASID int 4 7
dbo NUMBERSEQUENCEREFERENCE WIZARDCONTINUOUS int 4 8
dbo NUMBERSEQUENCEREFERENCE ALLOWSAMEAS int 4 9
dbo NUMBERSEQUENCEREFERENCE SORTFIELD int 4 10
dbo NUMBERSEQUENCEREFERENCE GROUPENABLED int 4 11
dbo NUMBERSEQUENCEREFERENCE WIZARDHIGHEST int 4 12
dbo NUMBERSEQUENCEREFERENCE WIZARDLOWEST int 4 13
dbo NUMBERSEQUENCEREFERENCE WIZARDMANUAL int 4 14
dbo NUMBERSEQUENCEREFERENCE WIZARDFETCHAHEADQTY int 4 15
dbo NUMBERSEQUENCEREFERENCE DATAAREAID nvarchar 8 16
dbo NUMBERSEQUENCEREFERENCE RECVERSION int 4 17
dbo NUMBERSEQUENCEREFERENCE RECID bigint 8 18
dbo SYSTEMSEQUENCES ID int 4 1
dbo SYSTEMSEQUENCES NEXTVAL bigint 8 2
dbo SYSTEMSEQUENCES MINVAL bigint 8 3
dbo SYSTEMSEQUENCES MAXVAL bigint 8 4
dbo SYSTEMSEQUENCES CYCLE int 4 5
dbo SYSTEMSEQUENCES NAME nvarchar 40 6
dbo SYSTEMSEQUENCES TABID int 4 7
dbo SYSTEMSEQUENCES DATAAREAID nvarchar 8 8
dbo SYSTEMSEQUENCES RECVERSION int 4 9
dbo SYSTEMSEQUENCES RECID bigint 8 10
Can't render this file because it has a wrong number of fields in line 2.

View file

@ -0,0 +1,135 @@
skema tabel kolonne type max_length column_id
# Baseret på Microsofts dokumenterede skema – ikke verificeret mod kundedata. Erstattes af schema-ax2012.tsv, når en kunde leverer det rigtige skema.
dbo SQLDICTIONARY TABLEID int 4 1
dbo SQLDICTIONARY FIELDID int 4 2
dbo SQLDICTIONARY ARRAY int 4 3
dbo SQLDICTIONARY NAME nvarchar 80 4
dbo SQLDICTIONARY SQLNAME nvarchar 80 5
dbo SQLDICTIONARY FIELDTYPE int 4 6
dbo SQLDICTIONARY STRSIZE int 4 7
dbo SQLDICTIONARY SHADOW int 4 8
dbo SQLDICTIONARY RIGHTJUSTIFY int 4 9
dbo SQLDICTIONARY NULLABLE int 4 10
dbo SQLDICTIONARY FLAGS int 4 11
dbo SQLDICTIONARY RECVERSION int 4 12
dbo SQLDICTIONARY RECID bigint 8 13
dbo DATAAREA ID nvarchar 8 1
dbo DATAAREA NAME nvarchar 120 2
dbo DATAAREA ISVIRTUAL int 4 3
dbo DATAAREA ALWAYSNATIVE int 4 4
dbo DATAAREA TIMEZONE int 4 5
dbo DATAAREA RECVERSION int 4 6
dbo DATAAREA PARTITION bigint 8 7
dbo DATAAREA RECID bigint 8 8
dbo SYSCONFIG CONFIGTYPE int 4 1
dbo SYSCONFIG ID int 4 2
dbo SYSCONFIG VALUE nvarchar 160 3
dbo SYSCONFIG MODIFIEDDATETIME datetime 8 4
dbo SYSCONFIG MODIFIEDBY nvarchar 16 5
dbo SYSCONFIG CREATEDDATETIME datetime 8 6
dbo SYSCONFIG CREATEDBY nvarchar 16 7
dbo SYSCONFIG RECVERSION int 4 8
dbo SYSCONFIG PARTITION bigint 8 9
dbo SYSCONFIG RECID bigint 8 10
dbo BATCHJOB CAPTION nvarchar 200 1
dbo BATCHJOB STATUS int 4 2
dbo BATCHJOB ORIGSTARTDATETIME datetime 8 3
dbo BATCHJOB ORIGSTARTDATETIMETZID int 4 4
dbo BATCHJOB STARTDATETIME datetime 8 5
dbo BATCHJOB ENDDATETIME datetime 8 6
dbo BATCHJOB RECURRENCEDATA image 16 7
dbo BATCHJOB ALERTSPROCESSED int 4 8
dbo BATCHJOB CANCELEDBY nvarchar 16 9
dbo BATCHJOB COMPANY nvarchar 8 10
dbo BATCHJOB LOGLEVEL int 4 11
dbo BATCHJOB BATCHCREATEDBY nvarchar 16 12
dbo BATCHJOB RUNTIMEJOB int 4 13
dbo BATCHJOB MODIFIEDDATETIME datetime 8 14
dbo BATCHJOB MODIFIEDBY nvarchar 16 15
dbo BATCHJOB CREATEDDATETIME datetime 8 16
dbo BATCHJOB CREATEDBY nvarchar 16 17
dbo BATCHJOB RECVERSION int 4 18
dbo BATCHJOB PARTITION bigint 8 19
dbo BATCHJOB RECID bigint 8 20
dbo BATCHJOBHISTORY BATCHJOBID bigint 8 1
dbo BATCHJOBHISTORY CAPTION nvarchar 200 2
dbo BATCHJOBHISTORY STATUS int 4 3
dbo BATCHJOBHISTORY ORIGSTARTDATETIME datetime 8 4
dbo BATCHJOBHISTORY STARTDATETIME datetime 8 5
dbo BATCHJOBHISTORY ENDDATETIME datetime 8 6
dbo BATCHJOBHISTORY COMPANY nvarchar 8 7
dbo BATCHJOBHISTORY CANCELEDBY nvarchar 16 8
dbo BATCHJOBHISTORY BATCHCREATEDBY nvarchar 16 9
dbo BATCHJOBHISTORY RECVERSION int 4 10
dbo BATCHJOBHISTORY PARTITION bigint 8 11
dbo BATCHJOBHISTORY RECID bigint 8 12
dbo BATCH BATCHJOBID bigint 8 1
dbo BATCH GROUPID nvarchar 20 2
dbo BATCH STATUS int 4 3
dbo BATCH CLASSNUMBER int 4 4
dbo BATCH CAPTION nvarchar 200 5
dbo BATCH STARTDATETIME datetime 8 6
dbo BATCH ENDDATETIME datetime 8 7
dbo BATCH SERVERID nvarchar 40 8
dbo BATCH COMPANY nvarchar 8 9
dbo BATCH PARAMETERS image 16 10
dbo BATCH INFO image 16 11
dbo BATCH SESSIONIDX int 4 12
dbo BATCH SESSIONLOGINDATETIME datetime 8 13
dbo BATCH RETRYCOUNT int 4 14
dbo BATCH RECVERSION int 4 15
dbo BATCH PARTITION bigint 8 16
dbo BATCH RECID bigint 8 17
dbo BATCHGROUP GROUP_ nvarchar 20 1
dbo BATCHGROUP DESCRIPTION nvarchar 120 2
dbo BATCHGROUP RECVERSION int 4 3
dbo BATCHGROUP PARTITION bigint 8 4
dbo BATCHGROUP RECID bigint 8 5
dbo NUMBERSEQUENCETABLE NUMBERSEQUENCE nvarchar 40 1
dbo NUMBERSEQUENCETABLE TXT nvarchar 120 2
dbo NUMBERSEQUENCETABLE LOWEST bigint 8 3
dbo NUMBERSEQUENCETABLE HIGHEST bigint 8 4
dbo NUMBERSEQUENCETABLE NEXTREC bigint 8 5
dbo NUMBERSEQUENCETABLE BLOCKED int 4 6
dbo NUMBERSEQUENCETABLE FORMAT nvarchar 80 7
dbo NUMBERSEQUENCETABLE ANNOTATEDFORMAT nvarchar 80 8
dbo NUMBERSEQUENCETABLE CONTINUOUS int 4 9
dbo NUMBERSEQUENCETABLE CYCLIC int 4 10
dbo NUMBERSEQUENCETABLE CLEANATACCESS int 4 11
dbo NUMBERSEQUENCETABLE INUSE int 4 12
dbo NUMBERSEQUENCETABLE NOINCREMENT int 4 13
dbo NUMBERSEQUENCETABLE CLEANINTERVAL numeric 13 14
dbo NUMBERSEQUENCETABLE LATESTCLEANDATETIME datetime 8 15
dbo NUMBERSEQUENCETABLE ALLOWCHANGEUP int 4 16
dbo NUMBERSEQUENCETABLE ALLOWCHANGEDOWN int 4 17
dbo NUMBERSEQUENCETABLE MANUAL int 4 18
dbo NUMBERSEQUENCETABLE FETCHAHEADQTY int 4 19
dbo NUMBERSEQUENCETABLE FETCHAHEAD int 4 20
dbo NUMBERSEQUENCETABLE NUMBERSEQUENCESCOPE bigint 8 21
dbo NUMBERSEQUENCETABLE RECVERSION int 4 22
dbo NUMBERSEQUENCETABLE PARTITION bigint 8 23
dbo NUMBERSEQUENCETABLE RECID bigint 8 24
dbo NUMBERSEQUENCESCOPE DATAAREA nvarchar 8 1
dbo NUMBERSEQUENCESCOPE LEGALENTITY bigint 8 2
dbo NUMBERSEQUENCESCOPE OPERATINGUNIT bigint 8 3
dbo NUMBERSEQUENCESCOPE OPERATINGUNITTYPE int 4 4
dbo NUMBERSEQUENCESCOPE FISCALCALENDARPERIOD bigint 8 5
dbo NUMBERSEQUENCESCOPE RECVERSION int 4 6
dbo NUMBERSEQUENCESCOPE PARTITION bigint 8 7
dbo NUMBERSEQUENCESCOPE RECID bigint 8 8
dbo NUMBERSEQUENCEREFERENCE NUMBERSEQUENCEDATATYPE bigint 8 1
dbo NUMBERSEQUENCEREFERENCE NUMBERSEQUENCEID bigint 8 2
dbo NUMBERSEQUENCEREFERENCE NUMBERSEQUENCESCOPE bigint 8 3
dbo NUMBERSEQUENCEREFERENCE RECVERSION int 4 4
dbo NUMBERSEQUENCEREFERENCE PARTITION bigint 8 5
dbo NUMBERSEQUENCEREFERENCE RECID bigint 8 6
dbo SYSTEMSEQUENCES ID int 4 1
dbo SYSTEMSEQUENCES NEXTVAL bigint 8 2
dbo SYSTEMSEQUENCES MINVAL bigint 8 3
dbo SYSTEMSEQUENCES MAXVAL bigint 8 4
dbo SYSTEMSEQUENCES CYCLE int 4 5
dbo SYSTEMSEQUENCES NAME nvarchar 40 6
dbo SYSTEMSEQUENCES TABID int 4 7
dbo SYSTEMSEQUENCES DATAAREAID nvarchar 8 8
dbo SYSTEMSEQUENCES RECVERSION int 4 9
dbo SYSTEMSEQUENCES RECID bigint 8 10
Can't render this file because it has a wrong number of fields in line 2.

View file

@ -68,15 +68,34 @@ public sealed class DatabaseSelection
/// </summary> /// </summary>
public static class DynamicsAxFindings public static class DynamicsAxFindings
{ {
/// <summary>The community AX rules (rules/community/dynamics-ax.json).</summary>
public const string RulePrefix = "COM-AX-"; public const string RulePrefix = "COM-AX-";
/// <summary>The built-in AX rules (1.4: batch jobs, number sequences, RecId, AX users behind blocking).</summary>
public const string BuiltInRulePrefix = "SQLI-AX-";
private const string DatabaseSubject = "database: "; private const string DatabaseSubject = "database: ";
public static bool IsDynamicsAxRule(string ruleId) => ruleId?.StartsWith(RulePrefix, StringComparison.OrdinalIgnoreCase) == true; /// <summary>Separates the database from the rest of an AX finding's subject: <c>database: AX30 · batch: 4711</c>.</summary>
public const string SubjectSeparator = " · ";
/// <summary>The database a finding is about, from its subject (<c>database: AX30</c>), or null.</summary> public static bool IsDynamicsAxRule(string ruleId) =>
public static string? DatabaseOf(string? subject) => ruleId?.StartsWith(RulePrefix, StringComparison.OrdinalIgnoreCase) == true || ruleId?.StartsWith(BuiltInRulePrefix, StringComparison.OrdinalIgnoreCase) == true;
subject is not null && subject.StartsWith(DatabaseSubject, StringComparison.OrdinalIgnoreCase) && subject.Length > DatabaseSubject.Length
? subject[DatabaseSubject.Length..] /// <summary>The subject of an AX finding about <paramref name="detail"/> in <paramref name="database"/>.</summary>
: null; public static string Subject(string database, string? detail = null) =>
detail is null ? DatabaseSubject + database : DatabaseSubject + database + SubjectSeparator + detail;
/// <summary>The database a finding is about, from its subject (<c>database: AX30</c> or <c>database: AX30 · …</c>), or null.</summary>
public static string? DatabaseOf(string? subject)
{
if (subject is null || !subject.StartsWith(DatabaseSubject, StringComparison.OrdinalIgnoreCase) || subject.Length <= DatabaseSubject.Length)
{
return null;
}
var rest = subject[DatabaseSubject.Length..];
var separator = rest.IndexOf(SubjectSeparator, StringComparison.Ordinal);
return separator > 0 ? rest[..separator] : rest;
}
} }

View file

@ -45,4 +45,10 @@ public sealed record SessionInfo
public string? SqlText { get; init; } public string? SqlText { get; init; }
public DateTime? LastRequestEndTime { get; init; } public DateTime? LastRequestEndTime { get; init; }
/// <summary>
/// The session's CONTEXT_INFO as hex, without trailing zero bytes, or null when not set. Dynamics AX 2009/2012 AOS
/// can put the AX user and session there (Core/DynamicsAx/AxSessionMapper.cs). Sensitive: it can hold a user id.
/// </summary>
public string? ContextInfo { get; init; }
} }

View file

@ -0,0 +1,216 @@
using System.Text.Json.Serialization;
namespace SQLInsight.Core.DynamicsAx;
/// <summary>A batch job's status, translated from the version's STATUS enum by its adapter.</summary>
public enum AxBatchStatus
{
Unknown,
Hold,
Waiting,
Executing,
Error,
Finished,
Ready,
NotRun,
Cancelling,
Canceled,
}
/// <summary>
/// A batch job row as read from AX, before interpretation: dates and times exactly as stored. AX 3.0/4.0 store a local
/// date (time part 00:00) plus seconds since midnight; AX 2009 and later a UTC datetime and no seconds.
/// </summary>
/// <param name="ServerUtcOffsetMinutes">DATEDIFF(minute, GETDATE(), GETUTCDATE()) on the server when read: turns local AX times into UTC.</param>
public sealed record AxBatchJobRow(
long RecId,
int? ClassNumber,
string? Caption,
string? Company,
string? Group,
int Status,
DateTime? PlannedDate,
int? PlannedSeconds,
DateTime? StartDate,
int? StartSeconds,
DateTime? EndDate,
int? EndSeconds,
int ServerUtcOffsetMinutes);
/// <summary>A finished run from AX's batch history (BATCHJOBHISTORY, 2009 and later).</summary>
public sealed record AxBatchHistoryRow(
long RecId,
long? JobRecId,
string? Caption,
string? Company,
int Status,
DateTime? StartDate,
DateTime? EndDate,
int ServerUtcOffsetMinutes);
/// <summary>A completed run of a batch job: from AX's history, or seen by SQLInsight itself. Stored in insight.AxBatchRuns.</summary>
/// <param name="RunKey">Unique per database, so a run seen twice is stored once.</param>
public sealed record AxBatchRun(string JobKey, string RunKey, string? Caption, string? Company, DateTime StartedUtc, DateTime EndedUtc, AxBatchStatus Status)
{
[JsonIgnore]
public TimeSpan Duration => EndedUtc - StartedUtc;
}
/// <summary>A batch job now, with what its own history says is normal.</summary>
public sealed record AxBatchJob
{
/// <summary>Stable per job: the BATCHJOB RECID (2009 and later), or class, company and group (3.0/4.0).</summary>
public required string Key { get; init; }
public long RecId { get; init; }
public required string Caption { get; init; }
public string? Company { get; init; }
public string? Group { get; init; }
public int StatusCode { get; init; }
public AxBatchStatus Status { get; init; }
public DateTime? PlannedStartUtc { get; init; }
public DateTime? StartedUtc { get; init; }
public DateTime? EndedUtc { get; init; }
/// <summary>When SQLInsight first saw the job executing (AX 3.0/4.0 do not store the actual start of a running job).</summary>
public DateTime? FirstSeenExecutingUtc { get; init; }
/// <summary>Completed runs in SQLInsight's history of the job (at most <see cref="AxBatchHistory.RunsKept"/>).</summary>
public int HistoryRuns { get; init; }
public double? MedianSeconds { get; init; }
public double? P90Seconds { get; init; }
public double? LastRunSeconds { get; init; }
public AxBatchStatus? LastRunStatus { get; init; }
public DateTime? LastRunEndedUtc { get; init; }
/// <summary>When the job started running, as well as SQLInsight knows it.</summary>
[JsonIgnore]
public DateTime? RunningSince => Status == AxBatchStatus.Executing ? StartedUtc ?? FirstSeenExecutingUtc : null;
public TimeSpan? Running(DateTimeOffset now) => RunningSince is { } since ? now.UtcDateTime - since : null;
/// <summary>How late a waiting job is: now minus its planned start, when positive.</summary>
public TimeSpan? Late(DateTimeOffset now) =>
Status == AxBatchStatus.Waiting && PlannedStartUtc is { } planned && now.UtcDateTime > planned ? now.UtcDateTime - planned : null;
}
/// <summary>Batch jobs in one AX database, or why they could not be read.</summary>
public sealed record AxBatchDatabase
{
public required string Database { get; init; }
public AxVersion Version { get; init; }
/// <summary>Null when measured; else "Ikke målbar: …" with the exact GRANT text when it is the read access.</summary>
public string? NotMeasurable { get; init; }
public IReadOnlyList<AxBatchJob> Jobs { get; init; } = [];
/// <summary>Runs completed since the previous collection (stored in insight.AxBatchRuns).</summary>
public IReadOnlyList<AxBatchRun> NewRuns { get; init; } = [];
/// <summary>
/// How many jobs had each raw STATUS value: shows whether the version's status mapping fits the customer's data
/// (the AX 3.0 mapping is not verified, see docs/dynamics-ax.md).
/// </summary>
public IReadOnlyDictionary<int, int> StatusCounts { get; init; } = new Dictionary<int, int>();
/// <summary>Optional columns missing and what that leaves out.</summary>
public IReadOnlyList<string> Notes { get; init; } = [];
/// <summary>The highest BATCHJOBHISTORY RECID read: the next read starts there (2009 and later).</summary>
public long? HistoryWatermark { get; init; }
}
/// <summary>The batch jobs of every monitored AX database on an instance (collector "dynamics-ax-batch", every 5 minutes).</summary>
public sealed record AxBatchSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
public required AxThresholds Thresholds { get; init; }
public required IReadOnlyList<AxBatchDatabase> Databases { get; init; }
/// <summary>
/// Durations of recent runs per database and job, for comparing a job with itself. Kept in memory and rebuilt from
/// insight.AxBatchRuns after a restart; not part of the stored snapshot.
/// </summary>
[JsonIgnore]
public AxBatchHistory History { get; init; } = new();
/// <summary>When each running job was first seen executing, per database and job. Kept in memory.</summary>
[JsonIgnore]
public IReadOnlyDictionary<string, DateTime> FirstSeenExecuting { get; init; } = new Dictionary<string, DateTime>();
/// <summary>Run keys stored lately, so a run read again is not stored twice. Kept in memory.</summary>
[JsonIgnore]
public IReadOnlySet<string> RecentRunKeys { get; init; } = new HashSet<string>();
}
/// <summary>Recent run durations per database and job; bounded to <see cref="RunsKept"/> runs per job.</summary>
public sealed class AxBatchHistory
{
public const int RunsKept = 20;
private readonly Dictionary<string, List<AxBatchRun>> _runs = new(StringComparer.OrdinalIgnoreCase);
public static string Key(string database, string jobKey) => database + "\u001f" + jobKey;
public void Add(string database, AxBatchRun run)
{
ArgumentNullException.ThrowIfNull(run);
var key = Key(database, run.JobKey);
if (!_runs.TryGetValue(key, out var list))
{
_runs[key] = list = [];
}
if (list.Any(r => r.RunKey == run.RunKey))
{
return;
}
list.Add(run);
list.Sort((a, b) => a.EndedUtc.CompareTo(b.EndedUtc));
if (list.Count > RunsKept)
{
list.RemoveRange(0, list.Count - RunsKept);
}
}
/// <summary>The job's runs, oldest first.</summary>
public IReadOnlyList<AxBatchRun> Runs(string database, string jobKey) =>
_runs.TryGetValue(Key(database, jobKey), out var list) ? list : [];
public int Count => _runs.Count;
/// <summary>A copy, so the previous snapshot is never changed.</summary>
public AxBatchHistory Clone()
{
var copy = new AxBatchHistory();
foreach (var (key, list) in _runs)
{
copy._runs[key] = [.. list];
}
return copy;
}
/// <summary>Median and 90th percentile of the successful runs' durations, in seconds.</summary>
public static (double? Median, double? P90) Statistics(IEnumerable<AxBatchRun> runs)
{
var seconds = runs.Where(r => r.Status == AxBatchStatus.Finished).Select(r => r.Duration.TotalSeconds).Where(s => s >= 0).Order().ToList();
if (seconds.Count == 0)
{
return (null, null);
}
return (Percentile(seconds, 0.5), Percentile(seconds, 0.9));
}
private static double Percentile(List<double> sorted, double p)
{
var position = p * (sorted.Count - 1);
var lower = (int)Math.Floor(position);
var upper = Math.Min(lower + 1, sorted.Count - 1);
return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
}
}

View file

@ -0,0 +1,99 @@
namespace SQLInsight.Core.DynamicsAx;
/// <summary>A column of an AX table as sys.columns describes it.</summary>
public sealed record AxColumn(string Name, string Type, int Precision, int MaxLength);
/// <param name="Readable">The agent has SELECT on the table (the optional AX read access, grant-dynamics-ax-read.sql).</param>
public sealed record AxTable(string Name, bool Readable, IReadOnlyList<AxColumn> Columns)
{
public bool HasColumn(string column) => Columns.Any(c => string.Equals(c.Name, column, StringComparison.OrdinalIgnoreCase));
public AxColumn? Column(string column) => Columns.FirstOrDefault(c => string.Equals(c.Name, column, StringComparison.OrdinalIgnoreCase));
}
/// <summary>A company (DATAAREA.ID) in an AX database.</summary>
public sealed record AxCompany(string Id, string? Name, bool IsVirtual);
/// <summary>
/// A database recognised as Dynamics AX by the central detection (<see cref="AxDetection"/>): which version, which
/// schema owns the AX tables (bmssa in AX 3.0, dbo later), the AX tables SQLInsight knows with their columns and
/// whether the agent may read them, and the companies when DATAAREA is readable.
/// </summary>
public sealed record AxDatabase
{
public required string Database { get; init; }
/// <summary>The schema of the AX tables: bmssa (AX 3.0, usually), dbo (later).</summary>
public required string Schema { get; init; }
public required AxVersion Version { get; init; }
/// <summary>The AX tables SQLInsight looks at (<see cref="AxDetection.KnownTables"/>) found in <see cref="Schema"/>.</summary>
public IReadOnlyList<AxTable> Tables { get; init; } = [];
/// <summary>The separate model store database (&lt;database&gt;_model) of AX 2012 R2/R3, when it is on the instance.</summary>
public string? ModelDatabase { get; init; }
/// <summary>The companies (DATAAREA), or null when the agent may not read DATAAREA.</summary>
public IReadOnlyList<AxCompany>? Companies { get; init; }
/// <summary>Why <see cref="Companies"/> is null.</summary>
public string? CompaniesReason { get; init; }
/// <summary>The database user the agent's login is in this database, or null when it has none (CONNECT ANY DATABASE).</summary>
public string? DatabaseUser { get; init; }
/// <summary>The agent's login (SUSER_SNAME()), so the GRANT text names it exactly.</summary>
public string? AgentLogin { get; init; }
/// <summary>Reads use snapshot isolation (ALLOW_SNAPSHOT_ISOLATION) or read committed snapshot; else read committed with a short lock timeout.</summary>
public AxReadIsolation Isolation { get; init; } = AxReadIsolation.ReadCommitted;
/// <summary>How the version was decided, and anything uncertain about it (Danish).</summary>
public IReadOnlyList<string> Notes { get; init; } = [];
public string VersionText => AxVersions.Text(Version);
public bool Verified => AxVersions.IsVerified(Version);
public AxTable? Table(string name) => Tables.FirstOrDefault(t => string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase));
public bool HasTable(string name) => Table(name) is not null;
public bool HasColumn(string table, string column) => Table(table)?.HasColumn(column) == true;
public bool CanRead(string table) => Table(table)?.Readable == true;
/// <summary>[schema].[TABLE], quoted.</summary>
public string Qualified(string table) => AxSql.Identifier(Schema) + "." + AxSql.Identifier(table);
}
public enum AxReadIsolation
{
/// <summary>Read committed with SET LOCK_TIMEOUT: a locked row makes the read give up after a few seconds, never wait.</summary>
ReadCommitted,
/// <summary>The database has READ_COMMITTED_SNAPSHOT on: read committed reads versions and never waits for locks.</summary>
ReadCommittedSnapshot,
/// <summary>ALLOW_SNAPSHOT_ISOLATION is on: reads use SET TRANSACTION ISOLATION LEVEL SNAPSHOT.</summary>
Snapshot,
}
/// <summary>The AX databases on an instance, as the detector last saw them (collector "dynamics-ax", hourly).</summary>
public sealed record AxDatabaseSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
/// <summary>Recognised AX databases that are monitored (chosen in Servere og databaser).</summary>
public required IReadOnlyList<AxDatabase> Databases { get; init; }
/// <summary>The agent's login (SUSER_SNAME()), for the GRANT text.</summary>
public string? AgentLogin { get; init; }
/// <summary>Databases the detection could not look into, with why (Danish).</summary>
public IReadOnlyDictionary<string, string> Problems { get; init; } = new Dictionary<string, string>();
public AxDatabase? Find(string? database) =>
database is null ? null : Databases.FirstOrDefault(d => string.Equals(d.Database, database, StringComparison.OrdinalIgnoreCase));
}

View file

@ -0,0 +1,154 @@
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,
};
}
}

View file

@ -0,0 +1,85 @@
using System.Text;
namespace SQLInsight.Core.DynamicsAx;
/// <summary>
/// The optional read access to AX tables. The agent normally reads only metadata (grant-least-privilege.sql); the AX
/// data checks need SELECT on a few AX system tables (batch jobs, number sequences, RecId allocation, companies), and
/// the customer decides whether to give it, per database. No customer data tables, never more than SELECT.
/// db/permissions/grant-dynamics-ax-read.sql does the same for any database; <see cref="GrantSql"/> makes the exact
/// statements for one database, with its schema (bmssa in AX 3.0) and the agent's login filled in.
/// </summary>
public static class AxReadAccess
{
public const string ScriptPath = "db/permissions/grant-dynamics-ax-read.sql";
public const string RevokeScriptPath = "db/permissions/revoke-dynamics-ax-read.sql";
/// <summary>Used in the GRANT text when the agent's login is not known yet.</summary>
public const string LoginPlaceholder = @"DOMÆNE\svc-sqlinsight$";
/// <summary>The tables the read access covers for a version (those that exist in the database).</summary>
public static IReadOnlyList<string> Tables(AxDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
return AxSchemaAdapter.For(database.Version) is { } adapter
? adapter.ReadTables.Where(database.HasTable).ToList()
: [];
}
/// <summary>The tables the agent may not read yet.</summary>
public static IReadOnlyList<string> Missing(AxDatabase database) => Tables(database).Where(t => !database.CanRead(t)).ToList();
/// <summary>
/// The exact statements that give the agent SELECT on <paramref name="tables"/> (all the version's tables when null)
/// in this database. Run by a sysadmin or db_owner; SQLInsight never runs it.
/// </summary>
public static string GrantSql(AxDatabase database, string? login, IEnumerable<string>? tables = null)
{
ArgumentNullException.ThrowIfNull(database);
login = string.IsNullOrWhiteSpace(login) ? LoginPlaceholder : login.Trim();
var user = database.DatabaseUser ?? login;
var sql = new StringBuilder()
.Append("USE ").Append(AxSql.Identifier(database.Database)).Append(";\r\n");
if (database.DatabaseUser is null)
{
sql.Append("IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE sid = SUSER_SID(").Append(AxSql.Literal(login)).Append("))\r\n")
.Append(" CREATE USER ").Append(AxSql.Identifier(login)).Append(" FOR LOGIN ").Append(AxSql.Identifier(login)).Append(";\r\n");
}
foreach (var table in (tables ?? Tables(database)).Distinct(StringComparer.OrdinalIgnoreCase))
{
sql.Append("GRANT SELECT ON ").Append(database.Qualified(table)).Append(" TO ").Append(AxSql.Identifier(user)).Append(";\r\n");
}
return sql.ToString();
}
/// <summary>db/permissions/grant-dynamics-ax-read.sql as shipped.</summary>
public static string Template()
{
using var stream = typeof(AxReadAccess).Assembly.GetManifestResourceStream("SQLInsight.Core.grant-dynamics-ax-read.sql")
?? throw new InvalidOperationException("grant-dynamics-ax-read.sql mangler i programmet.");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
/// <summary>The shipped script with the database and the agent's login filled in.</summary>
public static string Script(string database, string? login)
{
ArgumentNullException.ThrowIfNull(database);
return Template()
.Replace("SET @DatabaseName = N'AXDB';", "SET @DatabaseName = " + AxSql.Literal(database) + ";", StringComparison.Ordinal)
.Replace("SET @LoginName = N'" + LoginPlaceholder + "';", "SET @LoginName = " + AxSql.Literal(string.IsNullOrWhiteSpace(login) ? LoginPlaceholder : login.Trim()) + ";",
StringComparison.Ordinal);
}
/// <summary>"Ikke målbar" for a check that needs <paramref name="tables"/>, with the exact GRANT text.</summary>
public static string NotMeasurable(AxDatabase database, IReadOnlyList<string> tables, string? login = null)
{
ArgumentNullException.ThrowIfNull(database);
ArgumentNullException.ThrowIfNull(tables);
return $"Ikke målbar: agenten må ikke læse {string.Join(", ", tables.Select(t => database.Schema + "." + t))} i {database.Database} "
+ $"(valgfri AX-læseadgang). Kør som sysadmin eller db_owner: {GrantSql(database, login ?? database.AgentLogin, tables).Replace("\r\n", " ", StringComparison.Ordinal).Trim()}";
}
}

View file

@ -0,0 +1,729 @@
using System.Globalization;
namespace SQLInsight.Core.DynamicsAx;
/// <summary>
/// Everything SQLInsight knows about one Dynamics AX version's tables, in one place: which tables and columns the AX
/// checks read (and so which tables the optional read access must cover), the batch STATUS enum, and whether times are
/// local or UTC. Every query is built against the columns the database actually has (sys.columns, via
/// <see cref="AxDetection"/>), so a schema that differs gives "ikke målbar" with the exact column, never an error.
/// <para>
/// Only <see cref="Ax30Adapter"/> is verified against a real customer schema (docs/dynamics-ax/schema-ax30.tsv). AX 4.0,
/// 2009, 2012 and F&amp;O are built from Microsoft's documented schemas and NOT verified against customer data; their
/// documented tables and columns are in docs/dynamics-ax/schema-ax*.documented.tsv. When a customer supplies a real
/// schema, drop it in as docs/dynamics-ax/schema-ax2009.tsv or schema-ax2012.tsv: the adapter tests then check this
/// adapter against it instead (AxSchemaFile).
/// </para>
/// </summary>
public abstract class AxSchemaAdapter
{
/// <summary>How many batch jobs are read per collection (newest RECID first).</summary>
public const int MaxBatchJobs = 1000;
/// <summary>How many history rows are read per collection.</summary>
public const int MaxHistoryRows = 1000;
/// <summary>RecIds are handed out in blocks per AOS, so history rows may arrive slightly out of RECID order: re-read this far back.</summary>
public const long HistoryOverlap = 2000;
/// <summary>How many number sequences are read (the most used first).</summary>
public const int MaxNumberSequences = 5000;
/// <summary>How many RecId sequences are read (the highest first).</summary>
public const int MaxRecIdSequences = 500;
/// <summary>DATEDIFF from the server's local time to UTC: turns AX 3.0/4.0's local times into UTC.</summary>
protected const string UtcOffset = "DATEDIFF(minute, GETDATE(), GETUTCDATE())";
public static readonly AxSchemaAdapter Ax30 = new Ax30Adapter();
public static readonly AxSchemaAdapter Ax40 = new Ax40Adapter();
public static readonly AxSchemaAdapter Ax2009 = new Ax2009Adapter();
public static readonly AxSchemaAdapter Ax2012 = new Ax2012Adapter();
public static readonly AxSchemaAdapter FinanceOperations = new FinanceOperationsAdapter();
public static IReadOnlyList<AxSchemaAdapter> All => [Ax30, Ax40, Ax2009, Ax2012, FinanceOperations];
/// <summary>The adapter for a version, or null when the version is unknown (then no AX data checks run).</summary>
public static AxSchemaAdapter? For(AxVersion version) => version switch
{
AxVersion.Axapta30 => Ax30,
AxVersion.Ax40 => Ax40,
AxVersion.Ax2009 => Ax2009,
AxVersion.Ax2012 or AxVersion.Ax2012R2 => Ax2012,
AxVersion.FinanceOperations => FinanceOperations,
_ => null,
};
/// <summary>The versions this adapter serves.</summary>
public abstract IReadOnlyList<AxVersion> Versions { get; }
/// <summary>The schema file name under docs/dynamics-ax (without .tsv / .documented.tsv): schema-ax30, schema-ax2009 …</summary>
public abstract string SchemaFileName { get; }
/// <summary>The tables the batch checks read.</summary>
public abstract IReadOnlyList<string> BatchTables { get; }
/// <summary>The tables the number sequence checks read.</summary>
public abstract IReadOnlyList<string> NumberSequenceTables { get; }
/// <summary>The tables the RecId check reads (SQLDICTIONARY only for table names).</summary>
public static IReadOnlyList<string> RecIdTables => ["SYSTEMSEQUENCES", "SQLDICTIONARY"];
/// <summary>Every table the optional AX read access covers for this version: batch, number sequences, RecId, companies.</summary>
public IReadOnlyList<string> ReadTables => [.. BatchTables.Concat(NumberSequenceTables).Concat(RecIdTables).Append("DATAAREA").Distinct(StringComparer.OrdinalIgnoreCase)];
/// <summary>Columns each check cannot do without, per table (the adapter tests check them against the schema files).</summary>
public abstract IReadOnlyDictionary<string, IReadOnlyList<string>> RequiredColumns { get; }
/// <summary>Batch times are a local date plus seconds since midnight (3.0/4.0), not UTC datetimes.</summary>
public abstract bool LocalBatchTimes { get; }
/// <summary>The version's batch STATUS enum.</summary>
public abstract AxBatchStatus BatchStatus(int status);
/// <summary>Whether the batch STATUS mapping is verified against customer data.</summary>
public virtual bool BatchStatusVerified => false;
public abstract AxQuery BatchJobs(AxDatabase database);
/// <summary>Completed runs from AX's own history after <paramref name="afterRecId"/> (newest first when null), or null: no history table in this version.</summary>
public virtual AxQuery? BatchHistory(AxDatabase database, long? afterRecId) => null;
public abstract AxQuery NumberSequences(AxDatabase database);
/// <summary>The companies: DATAAREA (ID, NAME, ISVIRTUAL).</summary>
public static AxQuery Companies(AxDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
if (Unreadable(database, ["DATAAREA"]) is { } reason)
{
return AxQuery.Unmeasurable(reason);
}
var select = new AxSelect(database, "DATAAREA", "d")
.Add("ID", "nvarchar(10)", required: true)
.Add("NAME", "nvarchar(60)")
.Add("ISVIRTUAL");
return select.MissingReason() is { } missing
? AxQuery.Unmeasurable(missing)
: AxQuery.Ok($"SELECT TOP (500) {select.SelectList} FROM {database.Qualified("DATAAREA")} d ORDER BY d.{AxSql.Identifier("ID")}");
}
/// <summary>RecId allocation per table (per company in AX 3.0): SYSTEMSEQUENCES rows named SEQNO, the highest first.</summary>
public virtual AxQuery RecIds(AxDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
if (Unreadable(database, ["SYSTEMSEQUENCES"]) is { } reason)
{
return AxQuery.Unmeasurable(reason);
}
var s = new AxSelect(database, "SYSTEMSEQUENCES", "s");
var notes = new List<string>();
var name = database.CanRead("SQLDICTIONARY") && database.HasColumn("SQLDICTIONARY", "NAME") && database.HasColumn("SQLDICTIONARY", "FIELDID")
&& database.HasColumn("SQLDICTIONARY", "TABLEID")
? $"(SELECT TOP 1 d.{AxSql.Identifier("NAME")} FROM {database.Qualified("SQLDICTIONARY")} d WHERE d.{AxSql.Identifier("TABLEID")} = s.{AxSql.Identifier("TABID")} AND d.{AxSql.Identifier("FIELDID")} = 0)"
: "CAST(NULL AS nvarchar(40))";
if (name.StartsWith("CAST", StringComparison.Ordinal))
{
notes.Add("Tabelnavne vises ikke: agenten må ikke læse SQLDICTIONARY (eller den mangler NAME/FIELDID). Tabellerne vises med deres tabel-id.");
}
s.Add("TABID", required: true)
.Add("DATAAREAID", "nvarchar(10)")
.AddExpression("CAST(" + s.Column("NEXTVAL", "bigint", required: true) + " AS bigint)")
.AddExpression("CAST(" + s.Column("MAXVAL", "bigint") + " AS bigint)")
.AddExpression(name);
var filter = database.HasColumn("SYSTEMSEQUENCES", "NAME") ? $" WHERE s.{AxSql.Identifier("NAME")} = N'SEQNO'" : string.Empty;
return s.MissingReason() is { } missing
? AxQuery.Unmeasurable(missing)
: AxQuery.Ok($"SELECT TOP ({MaxRecIdSequences}) {s.SelectList} FROM {database.Qualified("SYSTEMSEQUENCES")} s{filter} ORDER BY s.{AxSql.Identifier("NEXTVAL")} DESC", [.. notes, .. s.Notes]);
}
/// <summary>
/// "Ikke målbar" with the exact GRANT, when the agent may not read one of <paramref name="tables"/> (or it is missing).
/// </summary>
protected static string? Unreadable(AxDatabase database, IReadOnlyList<string> tables)
{
var missing = tables.Where(t => !database.HasTable(t)).ToList();
if (missing.Count > 0)
{
return $"Ikke målbar: {database.Database} har ikke {(missing.Count == 1 ? "tabellen" : "tabellerne")} {string.Join(", ", missing.Select(t => database.Schema + "." + t))}, "
+ $"som {AxVersions.Text(database.Version)} forventes at have.";
}
var denied = tables.Where(t => !database.CanRead(t)).ToList();
return denied.Count == 0 ? null : AxReadAccess.NotMeasurable(database, denied);
}
// ---------------------------------------------------------------- interpretation (no SQL; unit tested)
/// <summary>A stable key for a job, so its runs can be compared over time.</summary>
public virtual string JobKey(AxBatchJobRow row)
{
ArgumentNullException.ThrowIfNull(row);
return row.RecId.ToString(CultureInfo.InvariantCulture);
}
/// <summary>What the UI calls a job: its caption, or class, company and group when AX stores no caption (3.0/4.0).</summary>
public virtual string Caption(AxBatchJobRow row)
{
ArgumentNullException.ThrowIfNull(row);
return string.IsNullOrWhiteSpace(row.Caption) ? $"Batchjob {row.RecId.ToString(CultureInfo.InvariantCulture)}" : row.Caption.Trim();
}
/// <summary>
/// A time as stored by AX in UTC: a local date plus seconds since midnight (3.0/4.0, using the server's offset when
/// read), or a UTC datetime. AX's empty date (1900-01-01) is null.
/// </summary>
public DateTime? ToUtc(DateTime? date, int? seconds, int offsetMinutes)
{
if (date is not { } d || d.Year <= 1900)
{
return null;
}
if (!LocalBatchTimes)
{
return DateTime.SpecifyKind(d, DateTimeKind.Utc);
}
var local = d.Date.AddSeconds(Math.Clamp(seconds ?? 0, 0, 86_399));
return DateTime.SpecifyKind(local.AddMinutes(offsetMinutes), DateTimeKind.Utc);
}
public AxBatchJob Interpret(AxBatchJobRow row, AxBatchHistory history, string database, DateTime? firstSeenExecuting)
{
ArgumentNullException.ThrowIfNull(row);
ArgumentNullException.ThrowIfNull(history);
var key = JobKey(row);
var runs = history.Runs(database, key);
var (median, p90) = AxBatchHistory.Statistics(runs);
var last = runs.Count > 0 ? runs[^1] : null;
return new AxBatchJob
{
Key = key,
RecId = row.RecId,
Caption = Caption(row),
Company = Trim(row.Company),
Group = Trim(row.Group),
StatusCode = row.Status,
Status = BatchStatus(row.Status),
PlannedStartUtc = ToUtc(row.PlannedDate, row.PlannedSeconds, row.ServerUtcOffsetMinutes),
StartedUtc = ToUtc(row.StartDate, row.StartSeconds, row.ServerUtcOffsetMinutes),
EndedUtc = ToUtc(row.EndDate, row.EndSeconds, row.ServerUtcOffsetMinutes),
FirstSeenExecutingUtc = firstSeenExecuting,
HistoryRuns = runs.Count,
MedianSeconds = median,
P90Seconds = p90,
LastRunSeconds = last?.Duration.TotalSeconds,
LastRunStatus = last?.Status,
LastRunEndedUtc = last?.EndedUtc,
};
}
/// <summary>
/// A completed run seen in the job table itself: a job that SQLInsight saw executing and now has an end time. The
/// start is the actual start when AX stores it (2009 and later), else when SQLInsight first saw it executing.
/// </summary>
public static AxBatchRun? ObservedRun(AxBatchJob job, DateTime? seenExecutingSince)
{
ArgumentNullException.ThrowIfNull(job);
if (job.Status is not (AxBatchStatus.Finished or AxBatchStatus.Error) || job.EndedUtc is not { } ended)
{
return null;
}
var started = job.StartedUtc ?? seenExecutingSince;
if (started is not { } start || ended < start)
{
return null;
}
return new AxBatchRun(job.Key, $"J{job.RecId.ToString(CultureInfo.InvariantCulture)}@{start:yyyyMMddHHmmss}", job.Caption, job.Company, start, ended, job.Status);
}
/// <summary>A run from AX's batch history (2009 and later).</summary>
public AxBatchRun? HistoryRun(AxBatchHistoryRow row)
{
ArgumentNullException.ThrowIfNull(row);
var start = ToUtc(row.StartDate, null, row.ServerUtcOffsetMinutes);
var end = ToUtc(row.EndDate, null, row.ServerUtcOffsetMinutes);
if (start is not { } s || end is not { } e || e < s || row.JobRecId is not { } job)
{
return null;
}
return new AxBatchRun(job.ToString(CultureInfo.InvariantCulture), "H" + row.RecId.ToString(CultureInfo.InvariantCulture),
Trim(row.Caption), Trim(row.Company), s, e, BatchStatus(row.Status));
}
/// <summary>Stable key of a number sequence for its sampled history.</summary>
public virtual string SequenceKey(AxNumberSequenceRow row)
{
ArgumentNullException.ThrowIfNull(row);
return (Trim(row.Company) ?? "*") + "/" + row.Code.Trim();
}
public AxNumberSequence Interpret(AxNumberSequenceRow row, IEnumerable<AxSample> samples, DateTime nowUtc, int forecastDays)
{
ArgumentNullException.ThrowIfNull(row);
var digits = row.Format?.Count(c => c == '#') is > 0 and var n ? n : (int?)null;
long? capacity = digits is { } d ? d >= 19 ? long.MaxValue : (long)Math.Pow(10, d) - 1 : null;
var max = capacity is { } c && c < row.Highest ? c : row.Highest;
var span = (double)max - row.Lowest + 1;
var used = span <= 0 ? 100 : Math.Clamp((row.Next - (double)row.Lowest) / span * 100, 0, 100);
var remaining = Math.Max(0, max - row.Next + 1);
var lastClean = row.LastCleanSeconds is null && row.LastCleanDate is { } utcClean && utcClean.Year > 1900
? DateTime.SpecifyKind(utcClean, DateTimeKind.Utc)
: ToLocalUtc(row.LastCleanDate, row.LastCleanSeconds, row.ServerUtcOffsetMinutes);
return new AxNumberSequence
{
Key = SequenceKey(row),
Code = row.Code.Trim(),
Company = Trim(row.Company),
Text = Trim(row.Text),
Lowest = row.Lowest,
Highest = row.Highest,
Next = row.Next,
Format = row.Format,
FormatDigits = digits,
FormatCapacity = capacity,
Max = max,
UsedPercent = used,
Remaining = remaining,
Blocked = row.Blocked,
Continuous = row.Continuous,
Cyclic = row.Cyclic,
Manual = row.Manual,
Referenced = row.Referenced,
LastCleanUtc = lastClean,
CleanIntervalHours = row.CleanIntervalHours,
Forecast = row.Manual || row.Cyclic ? null : AxForecast.From(samples, nowUtc, row.Next, max, forecastDays),
};
}
public static string RecIdKey(AxRecIdRow row)
{
ArgumentNullException.ThrowIfNull(row);
return row.TableId.ToString(CultureInfo.InvariantCulture) + "/" + (Trim(row.Company) ?? "*");
}
/// <param name="nextValIsInt">SYSTEMSEQUENCES.NEXTVAL (and RECID) is int: AX 3.0's 32-bit RecId.</param>
public static AxRecIdSequence Interpret(AxRecIdRow row, bool nextValIsInt, IEnumerable<AxSample> samples, DateTime nowUtc, int forecastDays)
{
ArgumentNullException.ThrowIfNull(row);
var typeMax = nextValIsInt ? AxVersions.Int32RecIdMax : long.MaxValue;
var max = row.MaxVal is > 0 and var m && m < typeMax ? m : typeMax;
var used = Math.Clamp((double)row.NextVal / max * 100, 0, 100);
return new AxRecIdSequence
{
Key = RecIdKey(row),
TableId = row.TableId,
TableName = Trim(row.TableName),
Company = Trim(row.Company),
Next = row.NextVal,
Max = max,
Bits = nextValIsInt ? 32 : 64,
UsedPercent = used,
Remaining = Math.Max(0, max - row.NextVal),
Forecast = AxForecast.From(samples, nowUtc, row.NextVal, max, forecastDays),
};
}
private static DateTime? ToLocalUtc(DateTime? date, int? seconds, int offsetMinutes) =>
date is { } d && d.Year > 1900 ? DateTime.SpecifyKind(d.Date.AddSeconds(Math.Clamp(seconds ?? 0, 0, 86_399)).AddMinutes(offsetMinutes), DateTimeKind.Utc) : null;
protected static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
// ---------------------------------------------------------------- shared SQL builders
/// <summary>
/// NUMBERSEQUENCETABLE with the company from DATAAREAID (3.0, 4.0, 2009) or NUMBERSEQUENCESCOPE (2012), and whether a
/// NUMBERSEQUENCEREFERENCE uses it. The most used sequences first, so TOP keeps those that matter.
/// </summary>
internal static AxQuery NumberSequenceQuery(AxDatabase database, string companyExpression, string? companyJoin, string? referencedExpression, IEnumerable<string> extraNotes)
{
ArgumentNullException.ThrowIfNull(database);
if (Unreadable(database, ["NUMBERSEQUENCETABLE"]) is { } reason)
{
return AxQuery.Unmeasurable(reason);
}
var n = new AxSelect(database, "NUMBERSEQUENCETABLE", "n");
string Big(string column) => "CAST(" + n.Column(column, "bigint", required: true) + " AS bigint)";
string Flag(string column, string lost) => "ISNULL(" + n.Column(column, whatIsLost: lost) + ", 0)";
// AX 2009 and later store the last cleanup as one UTC datetime; 3.0 as a local date plus seconds.
string cleanDate, cleanSeconds;
if (database.HasColumn("NUMBERSEQUENCETABLE", "LATESTCLEANDATETIME"))
{
cleanDate = n.Column("LATESTCLEANDATETIME", "datetime");
cleanSeconds = "CAST(NULL AS int)";
}
else
{
cleanDate = n.Column("LATESTCLEANDATE", "datetime", whatIsLost: "seneste oprydning af kontinuerlige nummerserier kendes ikke.");
cleanSeconds = n.Column("LATESTCLEANTIME");
}
n.AddExpression(Big("RECID"))
.AddExpression(n.Column("NUMBERSEQUENCE", "nvarchar(20)", required: true))
.AddExpression(companyExpression)
.AddExpression(n.Column("TXT", "nvarchar(60)"))
.AddExpression(Big("LOWEST"))
.AddExpression(Big("HIGHEST"))
.AddExpression(Big("NEXTREC"))
.AddExpression(n.Column("FORMAT", "nvarchar(40)", whatIsLost: "formatets bredde kan ikke sammenlignes med HIGHEST."))
.AddExpression(Flag("BLOCKED", "spærrede nummerserier kan ikke ses."))
.AddExpression(Flag("CONTINUOUS", "kontinuerlige nummerserier kan ikke ses."))
.AddExpression(Flag("CYCLIC", "cykliske nummerserier kan ikke ses (de regnes som almindelige)."))
.AddExpression(Flag("MANUAL", "manuelle nummerserier kan ikke ses (de regnes som almindelige)."))
.AddExpression(referencedExpression ?? "CAST(NULL AS int)")
.AddExpression(cleanDate)
.AddExpression(cleanSeconds)
.AddExpression("CAST(" + n.Column("CLEANINTERVAL", "float") + " AS float)")
.AddExpression(UtcOffset);
if (n.MissingReason() is { } missing)
{
return AxQuery.Unmeasurable(missing);
}
var used = $"(CAST(n.{AxSql.Identifier("NEXTREC")} AS float) - n.{AxSql.Identifier("LOWEST")}) / NULLIF(CAST(n.{AxSql.Identifier("HIGHEST")} AS float) - n.{AxSql.Identifier("LOWEST")}, 0)";
return AxQuery.Ok(
$"SELECT TOP ({MaxNumberSequences}) {n.SelectList} FROM {database.Qualified("NUMBERSEQUENCETABLE")} n{(companyJoin is null ? string.Empty : " " + companyJoin)} ORDER BY {used} DESC",
[.. n.Notes, .. extraNotes]);
}
/// <summary>The old batch table BATCH of AX 3.0 and 4.0: a local start date plus seconds since midnight, no caption.</summary>
protected static AxQuery LegacyBatchJobs(AxDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
if (Unreadable(database, ["BATCH"]) is { } reason)
{
return AxQuery.Unmeasurable(reason);
}
var b = new AxSelect(database, "BATCH", "b");
b.AddExpression("CAST(" + b.Column("RECID", "bigint", required: true) + " AS bigint)")
.Add("CLASSNUM", whatIsLost: "jobbene kan ikke skelnes på klasse.")
.AddExpression("CAST(NULL AS nvarchar(60))")
.Add("COMPANY", "nvarchar(10)")
.Add("GROUPID", "nvarchar(20)")
.Add("STATUS", required: true)
.Add("STARTDATE", "datetime", required: true)
.Add("STARTTIME", required: true)
.AddExpression("CAST(NULL AS datetime)")
.AddExpression("CAST(NULL AS int)")
.Add("ENDDATE", "datetime", whatIsLost: "kørselstider kan ikke måles.")
.Add("ENDTIME")
.AddExpression(UtcOffset);
return b.MissingReason() is { } missing
? AxQuery.Unmeasurable(missing)
: AxQuery.Ok($"SELECT TOP ({MaxBatchJobs}) {b.SelectList} FROM {database.Qualified("BATCH")} b ORDER BY b.{AxSql.Identifier("RECID")} DESC", b.Notes);
}
/// <summary>BATCHJOB of AX 2009 and later: one row per job, planned start ORIGSTARTDATETIME, actual STARTDATETIME, all UTC.</summary>
protected static AxQuery JobBatchJobs(AxDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
if (Unreadable(database, ["BATCHJOB"]) is { } reason)
{
return AxQuery.Unmeasurable(reason);
}
var j = new AxSelect(database, "BATCHJOB", "j");
j.AddExpression("CAST(" + j.Column("RECID", "bigint", required: true) + " AS bigint)")
.AddExpression("CAST(NULL AS int)")
.Add("CAPTION", "nvarchar(100)", whatIsLost: "jobbene vises med RECID i stedet for navn.")
.Add("COMPANY", "nvarchar(10)")
.AddExpression("CAST(NULL AS nvarchar(20))")
.Add("STATUS", required: true)
.Add("ORIGSTARTDATETIME", "datetime", required: true)
.AddExpression("CAST(NULL AS int)")
.Add("STARTDATETIME", "datetime", whatIsLost: "den faktiske starttid kendes ikke; SQLInsight bruger, hvornår den selv så jobbet køre.")
.AddExpression("CAST(NULL AS int)")
.Add("ENDDATETIME", "datetime", whatIsLost: "sluttider kendes ikke.")
.AddExpression("CAST(NULL AS int)")
.AddExpression(UtcOffset);
return j.MissingReason() is { } missing
? AxQuery.Unmeasurable(missing)
: AxQuery.Ok($"SELECT TOP ({MaxBatchJobs}) {j.SelectList} FROM {database.Qualified("BATCHJOB")} j ORDER BY j.{AxSql.Identifier("RECID")} DESC", j.Notes);
}
/// <summary>
/// BATCHJOBHISTORY of AX 2009 and later, by RECID (always indexed in AX): the newest rows the first time, then the rows
/// after the previous read, re-reading <see cref="HistoryOverlap"/> RecIds back for blocks handed out by other AOS'es.
/// </summary>
protected static AxQuery? JobBatchHistory(AxDatabase database, long? afterRecId)
{
ArgumentNullException.ThrowIfNull(database);
if (!database.HasTable("BATCHJOBHISTORY"))
{
return null;
}
if (Unreadable(database, ["BATCHJOBHISTORY"]) is { } reason)
{
return AxQuery.Unmeasurable(reason);
}
var h = new AxSelect(database, "BATCHJOBHISTORY", "h");
h.AddExpression("CAST(" + h.Column("RECID", "bigint", required: true) + " AS bigint)")
.AddExpression("CAST(" + h.Column("BATCHJOBID", "bigint", required: true) + " AS bigint)")
.Add("CAPTION", "nvarchar(100)")
.Add("COMPANY", "nvarchar(10)")
.Add("STATUS", required: true)
.Add("STARTDATETIME", "datetime", required: true)
.Add("ENDDATETIME", "datetime", required: true)
.AddExpression(UtcOffset);
if (h.MissingReason() is { } missing)
{
return AxQuery.Unmeasurable(missing);
}
var recId = "h." + AxSql.Identifier("RECID");
return AxQuery.Ok(afterRecId is { } after
? $"SELECT TOP ({MaxHistoryRows}) {h.SelectList} FROM {database.Qualified("BATCHJOBHISTORY")} h WHERE {recId} > {AxSql.Number(Math.Max(0, after - HistoryOverlap))} ORDER BY {recId}"
: $"SELECT TOP ({MaxHistoryRows}) {h.SelectList} FROM {database.Qualified("BATCHJOBHISTORY")} h ORDER BY {recId} DESC", h.Notes);
}
/// <summary>The 2009+ BatchStatus enum (Microsoft's documentation of BatchStatus).</summary>
protected static AxBatchStatus JobStatus(int status) => status switch
{
0 => AxBatchStatus.Hold,
1 => AxBatchStatus.Waiting,
2 => AxBatchStatus.Executing,
3 => AxBatchStatus.Error,
4 => AxBatchStatus.Finished,
5 => AxBatchStatus.Ready,
6 => AxBatchStatus.NotRun,
7 => AxBatchStatus.Cancelling,
8 => AxBatchStatus.Canceled,
_ => AxBatchStatus.Unknown,
};
}
/// <summary>
/// Axapta 2.5/3.0. Verified against the real AX 3.0 schema from a customer (docs/dynamics-ax/schema-ax30.tsv): tables in
/// the bmssa schema, 32-bit RECID, BATCH with STARTDATE (datetime, local) + STARTTIME (int, seconds since midnight,
/// local), NUMBERSEQUENCETABLE with NEXTREC/HIGHEST/FORMAT/BLOCKED/INUSE per DATAAREAID, SYSTEMSEQUENCES with
/// NEXTVAL/MAXVAL per TABID and DATAAREAID. The batch STATUS values are NOT verified against customer data (the schema
/// file has no data): the UI shows the raw values found so the mapping can be checked.
/// </summary>
public sealed class Ax30Adapter : AxSchemaAdapter
{
public override IReadOnlyList<AxVersion> Versions => [AxVersion.Axapta30];
public override string SchemaFileName => "schema-ax30";
public override IReadOnlyList<string> BatchTables => ["BATCH"];
public override IReadOnlyList<string> NumberSequenceTables => ["NUMBERSEQUENCETABLE", "NUMBERSEQUENCEREFERENCE"];
public override bool LocalBatchTimes => true;
public override IReadOnlyDictionary<string, IReadOnlyList<string>> RequiredColumns => new Dictionary<string, IReadOnlyList<string>>
{
["BATCH"] = ["RECID", "CLASSNUM", "COMPANY", "GROUPID", "STATUS", "STARTDATE", "STARTTIME", "ENDDATE", "ENDTIME"],
["NUMBERSEQUENCETABLE"] = ["RECID", "NUMBERSEQUENCE", "DATAAREAID", "TXT", "LOWEST", "HIGHEST", "NEXTREC", "FORMAT", "BLOCKED", "CONTINUOUS", "CYCLIC", "MANUAL",
"LATESTCLEANDATE", "LATESTCLEANTIME", "CLEANINTERVAL"],
["NUMBERSEQUENCEREFERENCE"] = ["NUMBERSEQUENCE", "DATAAREAID"],
["SYSTEMSEQUENCES"] = ["TABID", "DATAAREAID", "NEXTVAL", "MAXVAL", "NAME"],
["SQLDICTIONARY"] = ["TABLEID", "FIELDID", "NAME", "RECID"],
["DATAAREA"] = ["ID", "NAME", "ISVIRTUAL"],
};
/// <summary>
/// The Axapta 3.0 BatchStatus enum as SQLInsight assumes it: 0 Venter, 1 Udfører, 2 Fejl, 3 Afsluttet, 4 Tilbageholdt.
/// NOT verified against customer data; check with SELECT STATUS, COUNT(*) FROM bmssa.BATCH GROUP BY STATUS and the
/// batch list in AX. The web UI shows the values found.
/// </summary>
public override AxBatchStatus BatchStatus(int status) => status switch
{
0 => AxBatchStatus.Waiting,
1 => AxBatchStatus.Executing,
2 => AxBatchStatus.Error,
3 => AxBatchStatus.Finished,
4 => AxBatchStatus.Hold,
_ => AxBatchStatus.Unknown,
};
public override string JobKey(AxBatchJobRow row)
{
ArgumentNullException.ThrowIfNull(row);
// AX 3.0 makes a new BATCH row for each recurrence, so a job is its class, company and batch group.
return string.Join('|', row.ClassNumber?.ToString(CultureInfo.InvariantCulture) ?? "?", Trim(row.Company) ?? string.Empty, Trim(row.Group) ?? string.Empty);
}
public override string Caption(AxBatchJobRow row)
{
ArgumentNullException.ThrowIfNull(row);
var text = row.ClassNumber is { } c ? $"Klasse {c.ToString(CultureInfo.InvariantCulture)}" : $"Batch {row.RecId.ToString(CultureInfo.InvariantCulture)}";
return Trim(row.Group) is { } group ? $"{text} (gruppe {group})" : text;
}
public override AxQuery BatchJobs(AxDatabase database) => LegacyBatchJobs(database);
public override AxQuery NumberSequences(AxDatabase database) => LegacyNumberSequences(database);
/// <summary>NUMBERSEQUENCETABLE per DATAAREAID; references by code and company (3.0, 4.0, 2009).</summary>
internal static AxQuery LegacyNumberSequences(AxDatabase database)
{
var notes = new List<string>();
string? referenced = null;
if (database.CanRead("NUMBERSEQUENCEREFERENCE") && database.HasColumn("NUMBERSEQUENCEREFERENCE", "NUMBERSEQUENCE")
&& database.HasColumn("NUMBERSEQUENCEREFERENCE", "DATAAREAID") && database.HasColumn("NUMBERSEQUENCETABLE", "DATAAREAID"))
{
referenced = $"CASE WHEN EXISTS (SELECT 1 FROM {database.Qualified("NUMBERSEQUENCEREFERENCE")} r WHERE r.{AxSql.Identifier("NUMBERSEQUENCE")} = n.{AxSql.Identifier("NUMBERSEQUENCE")} "
+ $"AND r.{AxSql.Identifier("DATAAREAID")} = n.{AxSql.Identifier("DATAAREAID")}) THEN 1 ELSE 0 END";
}
else
{
notes.Add("Det kan ikke ses, om en spærret nummerserie stadig bruges: agenten må ikke læse NUMBERSEQUENCEREFERENCE (eller den mangler kolonner).");
}
var company = database.HasColumn("NUMBERSEQUENCETABLE", "DATAAREAID") ? "n." + AxSql.Identifier("DATAAREAID") : "CAST(NULL AS nvarchar(10))";
return NumberSequenceQuery(database, company, null, referenced, notes);
}
}
/// <summary>
/// AX 4.0: like 3.0 (the old BATCH table with local date + seconds, NUMBERSEQUENCETABLE per DATAAREAID) but dbo and
/// 64-bit RECID. Built from Microsoft's documentation and the 3.0 schema – NOT verified against customer data; the batch
/// STATUS values are assumed to be 3.0's.
/// </summary>
public sealed class Ax40Adapter : AxSchemaAdapter
{
public override IReadOnlyList<AxVersion> Versions => [AxVersion.Ax40];
public override string SchemaFileName => "schema-ax40";
public override IReadOnlyList<string> BatchTables => ["BATCH"];
public override IReadOnlyList<string> NumberSequenceTables => ["NUMBERSEQUENCETABLE", "NUMBERSEQUENCEREFERENCE"];
public override bool LocalBatchTimes => true;
public override IReadOnlyDictionary<string, IReadOnlyList<string>> RequiredColumns => Ax30.RequiredColumns;
public override AxBatchStatus BatchStatus(int status) => Ax30.BatchStatus(status);
public override string JobKey(AxBatchJobRow row) => Ax30.JobKey(row);
public override string Caption(AxBatchJobRow row) => Ax30.Caption(row);
public override AxQuery BatchJobs(AxDatabase database) => LegacyBatchJobs(database);
public override AxQuery NumberSequences(AxDatabase database) => Ax30Adapter.LegacyNumberSequences(database);
}
/// <summary>
/// AX 2009: batch framework with BATCHJOB (one row per job: STATUS, ORIGSTARTDATETIME planned, STARTDATETIME and
/// ENDDATETIME actual, all UTC) and BATCHJOBHISTORY; NUMBERSEQUENCETABLE per DATAAREAID with NUMBERSEQUENCE, NEXTREC,
/// HIGHEST; SYSTEMSEQUENCES per table; 64-bit RECID. Baseret på Microsofts dokumenterede skema – ikke verificeret mod
/// kundedata.
/// </summary>
public sealed class Ax2009Adapter : AxSchemaAdapter
{
public override IReadOnlyList<AxVersion> Versions => [AxVersion.Ax2009];
public override string SchemaFileName => "schema-ax2009";
public override IReadOnlyList<string> BatchTables => ["BATCHJOB", "BATCHJOBHISTORY"];
public override IReadOnlyList<string> NumberSequenceTables => ["NUMBERSEQUENCETABLE", "NUMBERSEQUENCEREFERENCE"];
public override bool LocalBatchTimes => false;
public override IReadOnlyDictionary<string, IReadOnlyList<string>> RequiredColumns => new Dictionary<string, IReadOnlyList<string>>
{
["BATCHJOB"] = ["RECID", "CAPTION", "COMPANY", "STATUS", "ORIGSTARTDATETIME", "STARTDATETIME", "ENDDATETIME"],
["BATCHJOBHISTORY"] = ["RECID", "BATCHJOBID", "CAPTION", "COMPANY", "STATUS", "STARTDATETIME", "ENDDATETIME"],
["NUMBERSEQUENCETABLE"] = ["RECID", "NUMBERSEQUENCE", "DATAAREAID", "TXT", "LOWEST", "HIGHEST", "NEXTREC", "FORMAT", "BLOCKED", "CONTINUOUS", "CYCLIC", "MANUAL",
"LATESTCLEANDATETIME", "CLEANINTERVAL"],
["NUMBERSEQUENCEREFERENCE"] = ["NUMBERSEQUENCE", "DATAAREAID"],
["SYSTEMSEQUENCES"] = ["TABID", "DATAAREAID", "NEXTVAL", "MAXVAL", "NAME"],
["SQLDICTIONARY"] = ["TABLEID", "FIELDID", "NAME", "RECID"],
["DATAAREA"] = ["ID", "NAME", "ISVIRTUAL"],
};
public override AxBatchStatus BatchStatus(int status) => JobStatus(status);
public override AxQuery BatchJobs(AxDatabase database) => JobBatchJobs(database);
public override AxQuery? BatchHistory(AxDatabase database, long? afterRecId) => JobBatchHistory(database, afterRecId);
public override AxQuery NumberSequences(AxDatabase database) => Ax30Adapter.LegacyNumberSequences(database);
}
/// <summary>
/// AX 2012 (RTM/FP and R2/R3): BATCHJOB/BATCHJOBHISTORY as in 2009; NUMBERSEQUENCETABLE keyed by RECID with
/// NUMBERSEQUENCESCOPE (the company is NUMBERSEQUENCESCOPE.DATAAREA, empty for shared sequences) and
/// NUMBERSEQUENCEREFERENCE.NUMBERSEQUENCEID; a PARTITION column on every table from R2, whose model store is the
/// separate &lt;db&gt;_model database. Baseret på Microsofts dokumenterede skema – ikke verificeret mod kundedata.
/// </summary>
public class Ax2012Adapter : AxSchemaAdapter
{
public override IReadOnlyList<AxVersion> Versions => [AxVersion.Ax2012, AxVersion.Ax2012R2];
public override string SchemaFileName => "schema-ax2012";
public override IReadOnlyList<string> BatchTables => ["BATCHJOB", "BATCHJOBHISTORY"];
public override IReadOnlyList<string> NumberSequenceTables => ["NUMBERSEQUENCETABLE", "NUMBERSEQUENCESCOPE", "NUMBERSEQUENCEREFERENCE"];
public override bool LocalBatchTimes => false;
public override IReadOnlyDictionary<string, IReadOnlyList<string>> RequiredColumns => new Dictionary<string, IReadOnlyList<string>>
{
["BATCHJOB"] = ["RECID", "CAPTION", "COMPANY", "STATUS", "ORIGSTARTDATETIME", "STARTDATETIME", "ENDDATETIME"],
["BATCHJOBHISTORY"] = ["RECID", "BATCHJOBID", "CAPTION", "COMPANY", "STATUS", "STARTDATETIME", "ENDDATETIME"],
["NUMBERSEQUENCETABLE"] = ["RECID", "NUMBERSEQUENCE", "NUMBERSEQUENCESCOPE", "TXT", "LOWEST", "HIGHEST", "NEXTREC", "FORMAT", "BLOCKED", "CONTINUOUS", "CYCLIC", "MANUAL",
"LATESTCLEANDATETIME", "CLEANINTERVAL"],
["NUMBERSEQUENCESCOPE"] = ["RECID", "DATAAREA"],
["NUMBERSEQUENCEREFERENCE"] = ["NUMBERSEQUENCEID"],
["SYSTEMSEQUENCES"] = ["TABID", "NEXTVAL", "MAXVAL", "NAME"],
["SQLDICTIONARY"] = ["TABLEID", "FIELDID", "NAME", "RECID"],
["DATAAREA"] = ["ID", "NAME", "ISVIRTUAL"],
};
public override AxBatchStatus BatchStatus(int status) => JobStatus(status);
public override AxQuery BatchJobs(AxDatabase database) => JobBatchJobs(database);
public override AxQuery? BatchHistory(AxDatabase database, long? afterRecId) => JobBatchHistory(database, afterRecId);
/// <summary>A 2012 sequence is identified by its RECID: the same code can exist in several scopes.</summary>
public override string SequenceKey(AxNumberSequenceRow row)
{
ArgumentNullException.ThrowIfNull(row);
return row.RecId.ToString(CultureInfo.InvariantCulture);
}
public override AxQuery NumberSequences(AxDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
var notes = new List<string>();
var company = "CAST(NULL AS nvarchar(10))";
string? join = null;
if (database.CanRead("NUMBERSEQUENCESCOPE") && database.HasColumn("NUMBERSEQUENCESCOPE", "DATAAREA") && database.HasColumn("NUMBERSEQUENCESCOPE", "RECID")
&& database.HasColumn("NUMBERSEQUENCETABLE", "NUMBERSEQUENCESCOPE"))
{
company = "NULLIF(sc." + AxSql.Identifier("DATAAREA") + ", N'')";
join = $"LEFT JOIN {database.Qualified("NUMBERSEQUENCESCOPE")} sc ON sc.{AxSql.Identifier("RECID")} = n.{AxSql.Identifier("NUMBERSEQUENCESCOPE")}";
}
else
{
notes.Add("Firma vises ikke: agenten må ikke læse NUMBERSEQUENCESCOPE (eller den mangler DATAAREA).");
}
string? referenced = null;
if (database.CanRead("NUMBERSEQUENCEREFERENCE") && database.HasColumn("NUMBERSEQUENCEREFERENCE", "NUMBERSEQUENCEID"))
{
referenced = $"CASE WHEN EXISTS (SELECT 1 FROM {database.Qualified("NUMBERSEQUENCEREFERENCE")} r WHERE r.{AxSql.Identifier("NUMBERSEQUENCEID")} = n.{AxSql.Identifier("RECID")}) THEN 1 ELSE 0 END";
}
else
{
notes.Add("Det kan ikke ses, om en spærret nummerserie stadig bruges: agenten må ikke læse NUMBERSEQUENCEREFERENCE (eller den mangler NUMBERSEQUENCEID).");
}
return NumberSequenceQuery(database, company, join, referenced, notes);
}
}
/// <summary>
/// Dynamics 365 Finance &amp; Operations on-premises: recognised, and read with the AX 2012 layout (BATCHJOB,
/// NUMBERSEQUENCETABLE with NUMBERSEQUENCESCOPE), which F&amp;O kept. Only the general and column-checked checks run;
/// NOT verified against customer data.
/// </summary>
public sealed class FinanceOperationsAdapter : Ax2012Adapter
{
public override IReadOnlyList<AxVersion> Versions => [AxVersion.FinanceOperations];
public override string SchemaFileName => "schema-fo";
}

View file

@ -0,0 +1,90 @@
using System.Globalization;
namespace SQLInsight.Core.DynamicsAx;
/// <summary>
/// An AX schema as a tab-separated file (docs/dynamics-ax/schema-ax*.tsv: skema, tabel, kolonne, type, max_length,
/// column_id), as exported from a customer's database with sys.columns. Used to check the version adapters against real
/// schemas and to build realistic test databases. A real export (schema-ax2009.tsv) wins over the one SQLInsight wrote
/// from Microsoft's documentation (schema-ax2009.documented.tsv).
/// </summary>
public sealed record AxSchemaFile(string Path, bool Documented, IReadOnlyList<AxSchemaFile.Column> Columns)
{
public sealed record Column(string Schema, string Table, string Name, string Type, int MaxLength, int ColumnId)
{
/// <summary>sys.columns.precision for the types AX uses.</summary>
public int Precision => Type.ToLowerInvariant() switch
{
"int" => 10,
"bigint" => 19,
"smallint" => 5,
"tinyint" => 3,
"datetime" => 23,
"numeric" or "decimal" => 28,
"float" => 53,
_ => 0,
};
/// <summary>The column as T-SQL DDL, e.g. [NEXTREC] int NOT NULL.</summary>
public string Definition => "[" + Name + "] " + Type.ToLowerInvariant() switch
{
"varchar" or "nvarchar" or "char" or "nchar" or "varbinary" or "binary" => $"{Type}({(MaxLength < 0 ? "max" : MaxLength.ToString(CultureInfo.InvariantCulture))})",
"numeric" or "decimal" => $"{Type}(28, 12)",
_ => Type,
} + " NULL";
}
public IEnumerable<string> Tables => Columns.Select(c => c.Table).Distinct(StringComparer.OrdinalIgnoreCase);
public IEnumerable<Column> Of(string table) => Columns.Where(c => string.Equals(c.Table, table, StringComparison.OrdinalIgnoreCase)).OrderBy(c => c.ColumnId);
public bool Has(string table, string column) => Of(table).Any(c => string.Equals(c.Name, column, StringComparison.OrdinalIgnoreCase));
/// <summary>The file for an adapter in <paramref name="directory"/>: the real export when there is one, else the documented one, else null.</summary>
public static AxSchemaFile? Find(string directory, string fileName)
{
var real = System.IO.Path.Combine(directory, fileName + ".tsv");
if (File.Exists(real))
{
return Load(real, documented: false);
}
var documented = System.IO.Path.Combine(directory, fileName + ".documented.tsv");
return File.Exists(documented) ? Load(documented, documented: true) : null;
}
public static AxSchemaFile Load(string path, bool documented)
{
var columns = new List<Column>();
foreach (var line in File.ReadLines(path).Skip(1))
{
var parts = line.TrimEnd('\r').Split('\t');
if (parts.Length < 6 || parts[0].StartsWith('#'))
{
continue;
}
columns.Add(new Column(parts[0].Trim(), parts[1].Trim().ToUpperInvariant(), parts[2].Trim().ToUpperInvariant(), parts[3].Trim(),
int.Parse(parts[4].Trim(), CultureInfo.InvariantCulture), int.Parse(parts[5].Trim(), CultureInfo.InvariantCulture)));
}
return new AxSchemaFile(path, documented, columns);
}
/// <summary>The detection rows the schema would give, every table readable or none.</summary>
public IReadOnlyList<AxDetectionRow> DetectionRows(bool readable = true, string? databaseUser = null) =>
Columns.Where(c => AxDetection.KnownTables.Contains(c.Table, StringComparer.OrdinalIgnoreCase))
.Select(c => new AxDetectionRow(c.Schema, c.Table, c.Name, c.Type, c.Precision, c.MaxLength, readable, databaseUser))
.ToList();
/// <summary>CREATE TABLE statements for the known AX tables in the file (for test databases).</summary>
public IEnumerable<string> CreateTables(IEnumerable<string>? onlyTables = null)
{
var wanted = onlyTables?.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var table in Tables.Where(t => wanted?.Contains(t) ?? true))
{
var columns = Of(table).ToList();
yield return $"CREATE TABLE [{columns[0].Schema}].[{table}] ({string.Join(", ", columns.Select(c => c.Definition))});";
}
}
}

View file

@ -0,0 +1,153 @@
using System.Text.Json.Serialization;
namespace SQLInsight.Core.DynamicsAx;
/// <summary>A NUMBERSEQUENCETABLE row as read, before interpretation.</summary>
/// <param name="Referenced">Used by at least one NUMBERSEQUENCEREFERENCE; null when the reference table cannot be read.</param>
/// <param name="CleanIntervalHours">How often a continuous sequence's unused numbers are cleaned up (CLEANINTERVAL, hours).</param>
public sealed record AxNumberSequenceRow(
long RecId,
string Code,
string? Company,
string? Text,
long Lowest,
long Highest,
long Next,
string? Format,
bool Blocked,
bool Continuous,
bool Cyclic,
bool Manual,
bool? Referenced,
DateTime? LastCleanDate,
int? LastCleanSeconds,
double? CleanIntervalHours,
int ServerUtcOffsetMinutes);
/// <summary>A number sequence now, with its forecast from SQLInsight's own sampled history of NEXTREC.</summary>
public sealed record AxNumberSequence
{
/// <summary>Company/code, or the RECID for a shared sequence (2012).</summary>
public required string Key { get; init; }
public required string Code { get; init; }
public string? Company { get; init; }
public string? Text { get; init; }
public long Lowest { get; init; }
public long Highest { get; init; }
public long Next { get; init; }
public string? Format { get; init; }
/// <summary>The number of # in the format: the digits the number gets.</summary>
public int? FormatDigits { get; init; }
/// <summary>The largest number the format can show (10^digits − 1).</summary>
public long? FormatCapacity { get; init; }
/// <summary>The last number the sequence can hand out: HIGHEST, or the format's capacity when that is lower.</summary>
public long Max { get; init; }
public double UsedPercent { get; init; }
public long Remaining { get; init; }
public bool Blocked { get; init; }
public bool Continuous { get; init; }
public bool Cyclic { get; init; }
public bool Manual { get; init; }
public bool? Referenced { get; init; }
public DateTime? LastCleanUtc { get; init; }
public double? CleanIntervalHours { get; init; }
public AxForecast? Forecast { get; init; }
/// <summary>HIGHEST is larger than the format can show: numbers above the format's capacity cannot be made.</summary>
[JsonIgnore]
public bool FormatTooShort => FormatCapacity is { } capacity && Highest > capacity;
}
/// <summary>A SYSTEMSEQUENCES row (RecId allocation) as read.</summary>
public sealed record AxRecIdRow(int TableId, string? Company, long NextVal, long? MaxVal, string? TableName);
/// <summary>A RecId sequence now: one per table (per company in AX 3.0), with its forecast.</summary>
public sealed record AxRecIdSequence
{
public required string Key { get; init; }
public int TableId { get; init; }
public string? TableName { get; init; }
public string? Company { get; init; }
public long Next { get; init; }
/// <summary>The last RecId that can be handed out: the column type's limit (int in AX 3.0), or MAXVAL when lower.</summary>
public long Max { get; init; }
public int Bits { get; init; }
public double UsedPercent { get; init; }
public long Remaining { get; init; }
public AxForecast? Forecast { get; init; }
}
/// <summary>The consumption rate from the oldest sample in the window to now, and when that runs out.</summary>
/// <param name="PerDay">Numbers used per day.</param>
/// <param name="DaysLeft">Days until exhaustion at that rate; null when nothing is being used.</param>
/// <param name="BasisDays">How many days of history the rate is based on.</param>
public sealed record AxForecast(double PerDay, double? DaysLeft, double BasisDays)
{
/// <summary>
/// The forecast from daily samples of the value, or null with less than a day of history. Uses the oldest sample
/// at most <paramref name="windowDays"/> old (a recent rate) and at least one day old.
/// </summary>
public static AxForecast? From(IEnumerable<AxSample> samples, DateTime nowUtc, long current, long max, int windowDays = 30)
{
ArgumentNullException.ThrowIfNull(samples);
var basis = samples
.Where(s => s.SampledUtc <= nowUtc.AddDays(-1) && s.SampledUtc >= nowUtc.AddDays(-windowDays))
.OrderBy(s => s.SampledUtc)
.FirstOrDefault();
if (basis is null)
{
return null;
}
var days = (nowUtc - basis.SampledUtc).TotalDays;
var perDay = (current - basis.Value) / days;
if (perDay <= 0)
{
return new AxForecast(0, null, days);
}
return new AxForecast(perDay, Math.Max(0, (max - current) / perDay), days);
}
}
/// <summary>One stored daily sample of a sequence's value (insight.AxSequenceSamples).</summary>
public sealed record AxSample(DateTime SampledUtc, long Value);
/// <summary>Number sequences and RecIds of one AX database, or why they could not be read.</summary>
public sealed record AxSequenceDatabase
{
public required string Database { get; init; }
public AxVersion Version { get; init; }
public string? NumberSequencesNotMeasurable { get; init; }
public IReadOnlyList<AxNumberSequence> NumberSequences { get; init; } = [];
public string? RecIdsNotMeasurable { get; init; }
public IReadOnlyList<AxRecIdSequence> RecIds { get; init; } = [];
public IReadOnlyList<string> Notes { get; init; } = [];
}
/// <summary>Number sequences and RecIds of every monitored AX database on an instance (collector "dynamics-ax-sequences", hourly).</summary>
public sealed record AxSequenceSnapshot
{
public const char NumberSequenceKind = 'N';
public const char RecIdKind = 'R';
public required DateTimeOffset CapturedAt { get; init; }
public required AxThresholds Thresholds { get; init; }
public required IReadOnlyList<AxSequenceDatabase> Databases { get; init; }
/// <summary>
/// Daily samples per database, kind (<see cref="NumberSequenceKind"/>/<see cref="RecIdKind"/>) and key, for the
/// forecasts. Kept in memory (bounded to the forecast window) and rebuilt from insight.AxSequenceSamples after a restart.
/// </summary>
[JsonIgnore]
public IReadOnlyDictionary<string, IReadOnlyList<AxSample>> History { get; init; } = new Dictionary<string, IReadOnlyList<AxSample>>();
public static string HistoryKey(string database, char kind, string key) => database + "\u001f" + kind + "\u001f" + key;
}

View file

@ -0,0 +1,98 @@
using System.Globalization;
using System.Text;
using SQLInsight.Core.Data;
namespace SQLInsight.Core.DynamicsAx;
/// <summary>Who in AX is behind a SQL Server session, as far as SQL Server can tell.</summary>
/// <param name="AxUser">The AX user id, when the AOS put it in CONTEXT_INFO.</param>
/// <param name="AxSessionId">The AX session id, when the AOS put it in CONTEXT_INFO.</param>
/// <param name="Source">"context_info", or "ikke tilgængelig".</param>
/// <param name="Explanation">How it was found, or why it cannot be (Danish).</param>
/// <param name="RawContext">CONTEXT_INFO as text, when it is printable.</param>
public sealed record AxSessionIdentity(string? AxUser, int? AxSessionId, string Source, string Explanation, string? RawContext = null)
{
public bool Known => AxUser is not null;
}
/// <summary>
/// Maps SQL Server sessions to Dynamics AX users. All AX users reach SQL Server through the AOS's own login, so the
/// login says nothing about the AX user. AX 2009 and 2012 (and F&amp;O) AOS can write the AX user id and AX session id to
/// CONTEXT_INFO on every connection when "connectioncontext" is turned on in the AOS configuration (registry); Microsoft
/// documents this for AX 2012 – the exact text layout is not verified against customer data, so the user is taken as the
/// first token with a letter and the session as the first number. AX 3.0 and 4.0 never set CONTEXT_INFO: the AX user
/// cannot be found from SQL Server, only the host (the AOS in a 3-tier installation, the user's own PC for a 2-tier
/// client) and the program.
/// </summary>
public static class AxSessionMapper
{
public static AxSessionIdentity Map(SessionInfo session, AxVersion version)
{
ArgumentNullException.ThrowIfNull(session);
var text = Decode(session.ContextInfo);
if (version is AxVersion.Axapta30 or AxVersion.Ax40)
{
return new AxSessionIdentity(null, null, "ikke tilgængelig",
$"{AxVersions.Text(version)} sætter ikke CONTEXT_INFO, så AX-brugeren kan ikke ses fra SQL Server. "
+ $"Vært: {session.HostName ?? "ukendt"} (AOS'en ved 3-lags-installation, brugerens egen pc ved 2-lags-klient). "
+ $"Program: {session.ProgramName ?? "ukendt"}. Brugeren kan findes i AX under Online brugere ud fra tidspunktet.", text);
}
if (text is not null)
{
var tokens = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
var user = tokens.FirstOrDefault(t => t.Any(char.IsLetter));
int? axSession = tokens.FirstOrDefault(t => t.All(char.IsAsciiDigit)) is { } digits
&& int.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out var id) ? id : null;
if (user is not null)
{
return new AxSessionIdentity(user, axSession, "context_info",
"Fra CONTEXT_INFO, som AOS'en sætter, når connectioncontext er slået til.", text);
}
}
return new AxSessionIdentity(null, null, "ikke tilgængelig",
"AOS'en har ikke sat CONTEXT_INFO på sessionen. Slå connectioncontext til i AOS-konfigurationen (registreringsdatabasen på AOS-serveren, "
+ "kræver genstart af AOS) for at se AX-brugeren bag SQL-sessioner.", text);
}
/// <summary>CONTEXT_INFO hex as printable text (ASCII, or UTF-16 when every second byte is zero), or null.</summary>
public static string? Decode(string? hex)
{
if (string.IsNullOrWhiteSpace(hex))
{
return null;
}
byte[] bytes;
try
{
bytes = Convert.FromHexString(hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? hex[2..] : hex);
}
catch (FormatException)
{
return null;
}
var length = bytes.Length;
while (length > 0 && bytes[length - 1] == 0)
{
length--;
}
if (length == 0)
{
return null;
}
var unicode = length >= 4 && Enumerable.Range(0, (length + 1) / 2).All(i => 2 * i + 1 >= length || bytes[2 * i + 1] == 0);
var text = unicode
? Encoding.Unicode.GetString(bytes, 0, Math.Min(bytes.Length, length + (length % 2)))
: Encoding.Latin1.GetString(bytes, 0, length);
var printable = new string(text.Select(c => char.IsControl(c) ? ' ' : c).ToArray()).Trim();
return printable.Length == 0 || printable.Count(c => char.IsAsciiLetterOrDigit(c) || c is ' ' or '\\' or '-' or '_' or '.' or '@' or 'æ' or 'ø' or 'å' or 'Æ' or 'Ø' or 'Å')
< printable.Length * 0.8
? null
: printable;
}
}

View file

@ -0,0 +1,136 @@
using System.Globalization;
using System.Text;
namespace SQLInsight.Core.DynamicsAx;
/// <summary>
/// How SQLInsight reads AX tables: only SELECT, bounded with TOP, in the AX database's own context through
/// <c>[db].sys.sp_executesql</c> (the session never changes database), with a short lock timeout under read committed
/// so a read gives up instead of waiting behind AX, or snapshot isolation when the database allows it. Never NOLOCK.
/// </summary>
public static class AxSql
{
/// <summary>A read of an AX table gives up after this long waiting for a lock (SET LOCK_TIMEOUT).</summary>
public const int LockTimeoutMilliseconds = 2000;
/// <summary>SQL Server's error number for "Lock request time out period exceeded".</summary>
public const int LockTimeoutError = 1222;
/// <summary>Command timeout for AX reads: short; they are small and indexed.</summary>
public const int CommandTimeoutSeconds = 15;
public static string Identifier(string name)
{
ArgumentNullException.ThrowIfNull(name);
return "[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]";
}
public static string Literal(string value)
{
ArgumentNullException.ThrowIfNull(value);
return "N'" + value.Replace("'", "''", StringComparison.Ordinal) + "'";
}
public static string Number(long value) => value.ToString(CultureInfo.InvariantCulture);
/// <summary>
/// Runs <paramref name="innerSql"/> in <paramref name="database"/> without changing the session's database, with
/// the isolation and lock timeout for AX reads. The SET options only apply inside sp_executesql; they are reset
/// afterwards anyway.
/// </summary>
public static string InDatabase(string database, string innerSql, AxReadIsolation isolation = AxReadIsolation.ReadCommitted)
{
ArgumentNullException.ThrowIfNull(innerSql);
var inner = new StringBuilder()
.Append("SET NOCOUNT ON; SET LOCK_TIMEOUT ").Append(LockTimeoutMilliseconds.ToString(CultureInfo.InvariantCulture)).Append("; ")
.Append(isolation == AxReadIsolation.Snapshot ? "SET TRANSACTION ISOLATION LEVEL SNAPSHOT; " : "SET TRANSACTION ISOLATION LEVEL READ COMMITTED; ")
.Append(innerSql)
.ToString();
return "EXEC " + Identifier(database) + ".sys.sp_executesql " + Literal(inner) + ";\nSET LOCK_TIMEOUT -1; SET TRANSACTION ISOLATION LEVEL READ COMMITTED;";
}
}
/// <summary>
/// A query an AX check would run, or why it cannot (Danish, "Ikke målbar: …"). Built by the version adapters, which
/// check every table and column in sys.columns first, so a query never fails on a schema it does not fit.
/// </summary>
public sealed record AxQuery
{
private AxQuery(string? sql, string? notMeasurable, IReadOnlyList<string> notes)
{
Sql = sql;
NotMeasurable = notMeasurable;
Notes = notes;
}
/// <summary>The inner SQL (run with <see cref="AxSql.InDatabase"/>), when measurable.</summary>
public string? Sql { get; }
public string? NotMeasurable { get; }
/// <summary>Optional columns or tables that were missing or unreadable, and what that leaves out.</summary>
public IReadOnlyList<string> Notes { get; }
public bool IsMeasurable => Sql is not null;
public static AxQuery Ok(string sql, IReadOnlyList<string>? notes = null) => new(sql, null, notes ?? []);
public static AxQuery Unmeasurable(string reason) => new(null, reason, []);
}
/// <summary>
/// Builds a SELECT list where every column is checked against the database's actual columns: a missing required column
/// makes the query not measurable with the exact name, a missing optional column becomes NULL with a note.
/// </summary>
public sealed class AxSelect(AxDatabase database, string table, string alias)
{
private readonly List<string> _missingRequired = [];
private readonly List<string> _notes = [];
private readonly List<string> _columns = [];
public IReadOnlyList<string> MissingRequired => _missingRequired;
public IReadOnlyList<string> Notes => _notes;
public string Table => table;
/// <summary>The expression for a column, or NULL of <paramref name="nullType"/> when optional and missing.</summary>
public string Column(string column, string nullType = "int", bool required = false, string? whatIsLost = null)
{
if (database.HasColumn(table, column))
{
return alias + "." + AxSql.Identifier(column);
}
if (required)
{
_missingRequired.Add($"{database.Schema}.{table}.{column}");
}
else if (whatIsLost is not null)
{
_notes.Add($"Kolonnen {database.Schema}.{table}.{column} findes ikke: {whatIsLost}");
}
return "CAST(NULL AS " + nullType + ")";
}
/// <summary>Adds a column to the SELECT list (in order).</summary>
public AxSelect Add(string column, string nullType = "int", bool required = false, string? whatIsLost = null)
{
_columns.Add(Column(column, nullType, required, whatIsLost));
return this;
}
/// <summary>Adds an expression to the SELECT list (in order).</summary>
public AxSelect AddExpression(string expression)
{
_columns.Add(expression);
return this;
}
public string SelectList => string.Join(", ", _columns);
/// <summary>"Ikke målbar" with the exact missing columns, or null when all required columns exist.</summary>
public string? MissingReason() => _missingRequired.Count == 0
? null
: $"Ikke målbar: {database.Database} mangler {(_missingRequired.Count == 1 ? "kolonnen" : "kolonnerne")} {string.Join(", ", _missingRequired)}, "
+ $"som {AxVersions.Text(database.Version)}-skemaet forventes at have. Skemaet afviger fra det kendte, så tjekket springes over.";
}

View file

@ -0,0 +1,57 @@
namespace SQLInsight.Core.DynamicsAx;
/// <summary>
/// When the AX checks raise a finding (SQLInsight:DynamicsAx in the agent's configuration). Carried in the snapshots,
/// so the rules and the web UI use the same values as the collection.
/// </summary>
public sealed record AxThresholds
{
/// <summary>A waiting batch job whose planned start is more than this many minutes ago is late (SQLI-AX-003).</summary>
public int BatchLateMinutes { get; init; } = 30;
/// <summary>Late by more than this many hours: it has in practice never started (High).</summary>
public int BatchNeverStartedHours { get; init; } = 24;
/// <summary>A batch job executing longer than this many hours is stuck (SQLI-AX-004).</summary>
public int BatchStuckHours { get; init; } = 6;
/// <summary>Running longer than this many times its own 90th percentile is unusually long (SQLI-AX-002).</summary>
public double BatchLongRunningFactor { get; init; } = 2.0;
/// <summary>…but never for runs shorter than this many minutes.</summary>
public int BatchLongRunningMinimumMinutes { get; init; } = 10;
/// <summary>Earlier runs needed before a job's own history is used.</summary>
public int BatchHistoryMinimumRuns { get; init; } = 5;
/// <summary>A run that failed within this many hours is reported (SQLI-AX-001).</summary>
public int BatchFailedLookbackHours { get; init; } = 24;
/// <summary>A number sequence more used than this (percent) is a finding (SQLI-AX-005); <see cref="NumberSequenceCriticalPercent"/> is High.</summary>
public double NumberSequenceWarnPercent { get; init; } = 80;
public double NumberSequenceCriticalPercent { get; init; } = 95;
/// <summary>Forecast to run out within this many days: a finding; within <see cref="ForecastCriticalDays"/>: High.</summary>
public int ForecastWarnDays { get; init; } = 90;
public int ForecastCriticalDays { get; init; } = 30;
/// <summary>AX 3.0 (32-bit RecId): a finding from this share of the int range, earlier than for 64-bit.</summary>
public double RecId32WarnPercent { get; init; } = 50;
/// <summary>AX 3.0: Critical from this share.</summary>
public double RecId32CriticalPercent { get; init; } = 75;
/// <summary>AX 3.0: forecast to run out within this many days is a finding (Critical within half).</summary>
public int RecId32ForecastDays { get; init; } = 730;
/// <summary>AX 4.0 and later (64-bit RecId, or a lowered MAXVAL): a finding from this share.</summary>
public double RecId64WarnPercent { get; init; } = 80;
/// <summary>An AX session blocking others for at least this many seconds is reported with its AX user (SQLI-AX-010).</summary>
public int BlockingSeconds { get; init; } = 30;
/// <summary>How many days back the forecasts look for their oldest sample.</summary>
public int ForecastBasisDays { get; init; } = 30;
}

View file

@ -0,0 +1,90 @@
namespace SQLInsight.Core.DynamicsAx;
/// <summary>
/// The Dynamics AX generation of a database, as far as its schema tells (<see cref="AxDetection"/>). Stored as a number
/// in snapshots: only add at the end.
/// </summary>
public enum AxVersion
{
Unknown = 0,
/// <summary>Axapta 2.5 or 3.0: 32-bit RECID, tables usually owned by bmssa. The two cannot be told apart from the schema.</summary>
Axapta30 = 1,
/// <summary>AX 4.0: 64-bit RECID, the old batch table (BATCH with STARTDATE/STARTTIME), no BATCHJOB.</summary>
Ax40 = 2,
/// <summary>AX 2009: BATCHJOB/BATCHJOBHISTORY with UTC times, no NUMBERSEQUENCESCOPE.</summary>
Ax2009 = 3,
/// <summary>AX 2012 RTM/FP: NUMBERSEQUENCESCOPE, model store inside the business database, no PARTITION column.</summary>
Ax2012 = 4,
/// <summary>AX 2012 R2/R3: PARTITION column, model store in the separate &lt;database&gt;_model database.</summary>
Ax2012R2 = 5,
/// <summary>
/// Dynamics 365 Finance &amp; Operations on-premises (formodet): PARTITION column but no model store anywhere on the
/// instance. Only recognised, not verified.
/// </summary>
FinanceOperations = 6,
}
public static class AxVersions
{
/// <summary>The largest RECID AX 3.0 can hand out: RECID is int (32-bit).</summary>
public const long Int32RecIdMax = int.MaxValue;
/// <summary>Short text, as stored in insight.ConfiguredDatabases.DynamicsAxVersion (varchar(16)).</summary>
public static string ShortText(AxVersion version) => version switch
{
AxVersion.Axapta30 => "3.0",
AxVersion.Ax40 => "4.0",
AxVersion.Ax2009 => "2009",
AxVersion.Ax2012 => "2012",
AxVersion.Ax2012R2 => "2012 R2/R3",
AxVersion.FinanceOperations => "F&O",
_ => "ukendt",
};
/// <summary>The name shown in the web UI.</summary>
public static string Text(AxVersion version) => version switch
{
AxVersion.Axapta30 => "Axapta 3.0 (eller 2.5)",
AxVersion.Ax40 => "Dynamics AX 4.0",
AxVersion.Ax2009 => "Dynamics AX 2009",
AxVersion.Ax2012 => "Dynamics AX 2012 (RTM/FP)",
AxVersion.Ax2012R2 => "Dynamics AX 2012 R2/R3",
AxVersion.FinanceOperations => "Dynamics 365 Finance & Operations (on-premises, formodet)",
_ => "Ukendt AX-version",
};
/// <summary>
/// Whether SQLInsight's knowledge of the version's tables is checked against a real customer schema
/// (docs/dynamics-ax/schema-ax30.tsv). Everything else is built from Microsoft's documentation.
/// </summary>
public static bool IsVerified(AxVersion version) => version == AxVersion.Axapta30;
/// <summary>What the UI and the docs say about how sure SQLInsight is of the version's schema.</summary>
public static string VerificationText(AxVersion version) => IsVerified(version)
? "Verificeret mod en rigtig AX 3.0-database (kundeskema, docs/dynamics-ax/schema-ax30.tsv)."
: "Baseret på Microsofts dokumenterede skema – ikke verificeret mod kundedata.";
/// <summary>RECID is 32-bit (int) in Axapta 2.5/3.0 and 64-bit (bigint) from AX 4.0.</summary>
public static bool Has32BitRecId(AxVersion version) => version == AxVersion.Axapta30;
/// <summary>Batch times: local date + seconds since midnight (3.0/4.0) or UTC datetime (2009 and later).</summary>
public static bool HasUtcBatchTimes(AxVersion version) => version is AxVersion.Ax2009 or AxVersion.Ax2012 or AxVersion.Ax2012R2 or AxVersion.FinanceOperations;
/// <summary>Parses <see cref="ShortText"/> back; also the 1.3 values "3.0" and "4.0+".</summary>
public static AxVersion Parse(string? text) => text switch
{
"3.0" => AxVersion.Axapta30,
"4.0" => AxVersion.Ax40,
"2009" => AxVersion.Ax2009,
"2012" => AxVersion.Ax2012,
"2012 R2/R3" => AxVersion.Ax2012R2,
"F&O" => AxVersion.FinanceOperations,
_ => AxVersion.Unknown,
};
}

View file

@ -5,5 +5,7 @@
<ItemGroup> <ItemGroup>
<!-- Shown in SQLInsight Web for a newly added server (Configuration/GrantScript.cs). --> <!-- Shown in SQLInsight Web for a newly added server (Configuration/GrantScript.cs). -->
<EmbeddedResource Include="..\..\db\permissions\grant-least-privilege.sql" LogicalName="SQLInsight.Core.grant-least-privilege.sql" /> <EmbeddedResource Include="..\..\db\permissions\grant-least-privilege.sql" LogicalName="SQLInsight.Core.grant-least-privilege.sql" />
<!-- The optional Dynamics AX read access, shown under Dynamics AX → AX-databaser (DynamicsAx/AxReadAccess.cs). -->
<EmbeddedResource Include="..\..\db\permissions\grant-dynamics-ax-read.sql" LogicalName="SQLInsight.Core.grant-dynamics-ax-read.sql" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -0,0 +1,373 @@
using System.Text.RegularExpressions;
using SQLInsight.Core.Configuration;
using SQLInsight.Core.Data;
using SQLInsight.Core.DynamicsAx;
namespace SQLInsight.Core.Tests;
/// <summary>Builds AX databases as the detector would see them, from the schema files in docs/dynamics-ax.</summary>
internal static class AxFixtures
{
public static string Directory => Path.Combine(AppContext.BaseDirectory, "DynamicsAx");
public static AxSchemaFile Schema(string fileName) => AxSchemaFile.Find(Directory, fileName)
?? throw new InvalidOperationException($"{fileName}.tsv mangler i docs/dynamics-ax.");
/// <summary>The real AX 3.0 schema from a customer (bmssa, 32-bit RECID).</summary>
public static AxSchemaFile Ax30 => Schema("schema-ax30");
public static AxDatabase Recognise(AxSchemaFile schema, string name = "AXDB", bool readable = true, string? user = null, IEnumerable<string>? databases = null,
Func<AxDetectionRow, bool>? keep = null) =>
AxDetection.Recognise(name, schema.DetectionRows(readable, user).Where(keep ?? (_ => true)).ToList(), databases ?? [name])
?? throw new InvalidOperationException("Ikke genkendt som AX.");
}
public class AxDetectionTests
{
[Fact]
public void The_real_ax30_schema_is_axapta_30_in_bmssa_and_verified()
{
var ax = AxFixtures.Recognise(AxFixtures.Ax30);
Assert.Equal((AxVersion.Axapta30, "bmssa", true), (ax.Version, ax.Schema, ax.Verified));
Assert.Equal("int", ax.Table("BATCH")!.Column("STARTTIME")!.Type);
Assert.Equal(10, ax.Table("SQLDICTIONARY")!.Column("RECID")!.Precision);
Assert.False(AxFixtures.Ax30.Documented);
}
[Fact]
public void Ax2009_and_2012_are_told_apart_by_batchjob_scope_partition_and_the_model_database()
{
var ax2009 = AxFixtures.Schema("schema-ax2009");
var ax2012 = AxFixtures.Schema("schema-ax2012");
Assert.Equal(AxVersion.Ax2009, AxFixtures.Recognise(ax2009).Version);
Assert.Equal(AxVersion.Ax2012R2, AxFixtures.Recognise(ax2012, "AX2012", databases: ["AX2012", "AX2012_model"]).Version);
Assert.Equal("AX2012_model", AxFixtures.Recognise(ax2012, "AX2012", databases: ["AX2012", "ax2012_MODEL"]).ModelDatabase, StringComparer.OrdinalIgnoreCase);
Assert.Equal(AxVersion.FinanceOperations, AxFixtures.Recognise(ax2012, "AxDB").Version);
Assert.Equal(AxVersion.Ax2012, AxFixtures.Recognise(ax2012, keep: r => r.Column != "PARTITION").Version);
Assert.Equal(AxVersion.Ax40, AxFixtures.Recognise(ax2009, keep: r => r.Table is not ("BATCHJOB" or "BATCHJOBHISTORY")).Version);
Assert.All(new[] { AxVersion.Ax40, AxVersion.Ax2009, AxVersion.Ax2012, AxVersion.Ax2012R2, AxVersion.FinanceOperations }, v => Assert.False(AxVersions.IsVerified(v)));
Assert.Contains(AxFixtures.Recognise(ax2009).Notes, n => n.Contains("ikke verificeret mod kundedata", StringComparison.Ordinal));
}
[Fact]
public void A_database_without_sqldictionary_and_dataarea_in_one_schema_is_not_ax()
{
var rows = AxFixtures.Ax30.DetectionRows().Where(r => r.Table != "DATAAREA").ToList();
Assert.Null(AxDetection.Recognise("NotAx", rows, ["NotAx"]));
Assert.Null(AxDetection.Recognise("Empty", [], ["Empty"]));
Assert.Equal(AxVersion.Unknown, AxFixtures.Recognise(AxFixtures.Ax30, keep: r => !(r.Table == "SQLDICTIONARY" && r.Column == "RECID")).Version);
}
[Theory]
[InlineData("3.0", AxVersion.Axapta30)]
[InlineData("2012 R2/R3", AxVersion.Ax2012R2)]
[InlineData("F&O", AxVersion.FinanceOperations)]
[InlineData("4.0+", AxVersion.Unknown)]
public void Short_version_texts_round_trip(string text, AxVersion version)
{
Assert.Equal(version, AxVersions.Parse(text));
if (version != AxVersion.Unknown)
{
Assert.Equal(text, AxVersions.ShortText(version));
Assert.True(text.Length <= 16); // insight.ConfiguredDatabases.DynamicsAxVersion varchar(16)
}
}
}
public class AxSchemaAdapterTests
{
public static TheoryData<string> Adapters() => [.. AxSchemaAdapter.All.Select(a => a.GetType().Name)];
private static AxSchemaAdapter Adapter(string name) => AxSchemaAdapter.All.Single(a => a.GetType().Name == name);
[Theory]
[MemberData(nameof(Adapters))]
public void Every_column_an_adapter_needs_is_in_its_schema_file(string name)
{
var adapter = Adapter(name);
var schema = AxSchemaFile.Find(AxFixtures.Directory, adapter.SchemaFileName);
if (schema is null)
{
// AX 4.0 and F&O: no schema file yet (neither from a customer nor written from the documentation).
Assert.Contains(adapter.Versions, v => v is AxVersion.Ax40 or AxVersion.FinanceOperations);
return;
}
Assert.Equal(adapter is not Ax30Adapter, schema.Documented);
foreach (var (table, columns) in adapter.RequiredColumns)
{
Assert.All(columns, c => Assert.True(schema.Has(table, c), $"{schema.Path}: {table}.{c} mangler"));
}
Assert.All(adapter.ReadTables, t => Assert.Contains(t, schema.Tables, StringComparer.OrdinalIgnoreCase));
}
[Fact]
public void Ax30_queries_use_bmssa_top_and_no_nolock()
{
var ax = AxFixtures.Recognise(AxFixtures.Ax30);
var adapter = AxSchemaAdapter.For(ax.Version)!;
var queries = new[] { adapter.BatchJobs(ax), adapter.NumberSequences(ax), adapter.RecIds(ax), AxSchemaAdapter.Companies(ax) };
Assert.All(queries, q => Assert.True(q.IsMeasurable, q.NotMeasurable));
Assert.All(queries, q => Assert.Contains("TOP (", q.Sql!, StringComparison.Ordinal));
Assert.All(queries, q => Assert.DoesNotContain("NOLOCK", q.Sql!, StringComparison.OrdinalIgnoreCase));
Assert.Contains("[bmssa].[BATCH]", queries[0].Sql!, StringComparison.Ordinal);
Assert.Contains("[bmssa].[NUMBERSEQUENCEREFERENCE]", queries[1].Sql!, StringComparison.Ordinal);
Assert.Contains("N'SEQNO'", queries[2].Sql!, StringComparison.Ordinal);
Assert.Null(adapter.BatchHistory(ax, null));
Assert.Empty(queries[0].Notes);
}
[Fact]
public void Ax2012_reads_the_company_from_the_scope_and_history_by_recid()
{
var ax = AxFixtures.Recognise(AxFixtures.Schema("schema-ax2012"), "AX2012", databases: ["AX2012", "AX2012_model"]);
var adapter = AxSchemaAdapter.For(ax.Version)!;
var sequences = adapter.NumberSequences(ax);
var first = adapter.BatchHistory(ax, null)!;
var next = adapter.BatchHistory(ax, 10_000)!;
Assert.Contains("LEFT JOIN [dbo].[NUMBERSEQUENCESCOPE]", sequences.Sql!, StringComparison.Ordinal);
Assert.Contains("[NUMBERSEQUENCEID]", sequences.Sql!, StringComparison.Ordinal);
Assert.Contains("ORDER BY h.[RECID] DESC", first.Sql!, StringComparison.Ordinal);
Assert.Contains($"h.[RECID] > {10_000 - AxSchemaAdapter.HistoryOverlap}", next.Sql!, StringComparison.Ordinal);
Assert.Contains("[ORIGSTARTDATETIME]", adapter.BatchJobs(ax).Sql!, StringComparison.Ordinal);
}
[Fact]
public void Without_read_access_a_check_is_not_measurable_with_the_exact_grant()
{
var ax = AxFixtures.Recognise(AxFixtures.Ax30, "AX30", readable: false) with { AgentLogin = @"KUNDE\svc-sqlinsight$" };
var batch = AxSchemaAdapter.Ax30.BatchJobs(ax);
Assert.False(batch.IsMeasurable);
Assert.StartsWith("Ikke målbar: agenten må ikke læse bmssa.BATCH i AX30", batch.NotMeasurable, StringComparison.Ordinal);
Assert.Contains(@"CREATE USER [KUNDE\svc-sqlinsight$] FOR LOGIN [KUNDE\svc-sqlinsight$];", batch.NotMeasurable, StringComparison.Ordinal);
Assert.Contains(@"GRANT SELECT ON [bmssa].[BATCH] TO [KUNDE\svc-sqlinsight$];", batch.NotMeasurable, StringComparison.Ordinal);
}
[Fact]
public void A_missing_required_column_names_it_and_a_missing_optional_one_becomes_null_with_a_note()
{
var withoutStartTime = AxFixtures.Recognise(AxFixtures.Ax30, keep: r => !(r.Table == "BATCH" && r.Column == "STARTTIME"));
var withoutFormat = AxFixtures.Recognise(AxFixtures.Ax30, keep: r => !(r.Table == "NUMBERSEQUENCETABLE" && r.Column == "FORMAT"));
var batch = AxSchemaAdapter.Ax30.BatchJobs(withoutStartTime);
var sequences = AxSchemaAdapter.Ax30.NumberSequences(withoutFormat);
Assert.Contains("bmssa.BATCH.STARTTIME", batch.NotMeasurable, StringComparison.Ordinal);
Assert.True(sequences.IsMeasurable);
Assert.Contains("CAST(NULL AS nvarchar(40))", sequences.Sql!, StringComparison.Ordinal);
Assert.Contains(sequences.Notes, n => n.Contains("FORMAT", StringComparison.Ordinal));
}
[Fact]
public void Grant_script_covers_exactly_the_adapters_tables_per_version()
{
var lists = Regex.Matches(AxReadAccess.Template(), @"N''\|([A-Z|]+)\|''").Select(m => m.Groups[1].Value.Split('|')).ToList();
Assert.Equal(3, lists.Count);
Assert.Equal(AxSchemaAdapter.Ax30.ReadTables.Order(), lists[0].Order());
Assert.Equal(AxSchemaAdapter.Ax40.ReadTables.Order(), lists[0].Order());
Assert.Equal(AxSchemaAdapter.Ax2009.ReadTables.Order(), lists[1].Order());
Assert.Equal(AxSchemaAdapter.Ax2012.ReadTables.Order(), lists[2].Order());
Assert.Equal(AxSchemaAdapter.FinanceOperations.ReadTables.Order(), lists[2].Order());
Assert.Contains("SET @DatabaseName = N'AX30';", AxReadAccess.Script("AX30", "svc"), StringComparison.Ordinal);
}
[Fact]
public void Grant_text_uses_the_existing_database_user_and_only_existing_tables()
{
var ax = AxFixtures.Recognise(AxFixtures.Ax30, "AX30", user: "sqlinsight") with { AgentLogin = "svc" };
var grant = AxReadAccess.GrantSql(ax, ax.AgentLogin);
Assert.DoesNotContain("CREATE USER", grant, StringComparison.Ordinal);
Assert.Contains("GRANT SELECT ON [bmssa].[SYSTEMSEQUENCES] TO [sqlinsight];", grant, StringComparison.Ordinal);
Assert.DoesNotContain("BATCHJOB", grant, StringComparison.Ordinal);
Assert.Empty(AxReadAccess.Missing(ax));
}
}
public class AxInterpretationTests
{
private static readonly DateTime Now = new(2026, 10, 2, 10, 0, 0, DateTimeKind.Utc);
[Fact]
public void Ax30_times_are_a_local_date_plus_seconds_since_midnight()
{
// 02:30:00 local on a server two hours ahead of UTC (offset -120 minutes) is 00:30 UTC.
Assert.Equal(new DateTime(2026, 10, 2, 0, 30, 0, DateTimeKind.Utc), AxSchemaAdapter.Ax30.ToUtc(new DateTime(2026, 10, 2), 9000, -120));
Assert.Null(AxSchemaAdapter.Ax30.ToUtc(new DateTime(1900, 1, 1), 0, 0));
Assert.Equal(new DateTime(2026, 10, 2, 8, 0, 0, DateTimeKind.Utc), AxSchemaAdapter.Ax2009.ToUtc(new DateTime(2026, 10, 2, 8, 0, 0), null, -120));
}
[Fact]
public void Batch_status_enums_per_version()
{
Assert.Equal(AxBatchStatus.Executing, AxSchemaAdapter.Ax30.BatchStatus(1));
Assert.Equal(AxBatchStatus.Error, AxSchemaAdapter.Ax30.BatchStatus(2));
Assert.Equal(AxBatchStatus.Waiting, AxSchemaAdapter.Ax2009.BatchStatus(1));
Assert.Equal(AxBatchStatus.Executing, AxSchemaAdapter.Ax2012.BatchStatus(2));
Assert.Equal(AxBatchStatus.Canceled, AxSchemaAdapter.Ax2012.BatchStatus(8));
Assert.Equal(AxBatchStatus.Unknown, AxSchemaAdapter.Ax2012.BatchStatus(42));
Assert.All(AxSchemaAdapter.All, a => Assert.False(a.BatchStatusVerified));
}
[Fact]
public void Ax30_jobs_are_keyed_by_class_company_and_group_and_late_jobs_are_measured_from_the_planned_start()
{
var row = new AxBatchJobRow(17, 1234, null, "dat", "NAT", 0, new DateTime(2026, 10, 2), 9 * 3600, null, null, null, null, -120);
var job = AxSchemaAdapter.Ax30.Interpret(row, new AxBatchHistory(), "AX30", null);
Assert.Equal("1234|dat|NAT", job.Key);
Assert.Equal("Klasse 1234 (gruppe NAT)", job.Caption);
Assert.Equal(AxBatchStatus.Waiting, job.Status);
Assert.Equal(new DateTime(2026, 10, 2, 7, 0, 0, DateTimeKind.Utc), job.PlannedStartUtc);
Assert.Equal(TimeSpan.FromHours(3), job.Late(new DateTimeOffset(Now)));
}
[Fact]
public void Job_history_gives_median_and_90th_percentile_of_successful_runs_and_is_bounded()
{
var history = new AxBatchHistory();
for (var i = 1; i <= 30; i++)
{
history.Add("AX", new AxBatchRun("7", "H" + i, "Lager", "dat", Now.AddHours(-i), Now.AddHours(-i).AddMinutes(i <= 25 ? 10 : 200), i == 1 ? AxBatchStatus.Error : AxBatchStatus.Finished));
}
history.Add("AX", new AxBatchRun("7", "H3", "Lager", "dat", Now, Now.AddMinutes(500), AxBatchStatus.Finished)); // seen before: ignored
var runs = history.Runs("AX", "7");
var (median, p90) = AxBatchHistory.Statistics(runs);
Assert.Equal(AxBatchHistory.RunsKept, runs.Count);
Assert.Equal(600, median);
Assert.Equal(600, p90);
Assert.Empty(history.Runs("AX", "8"));
}
[Fact]
public void Observed_run_needs_an_end_and_a_start_from_ax_or_from_sqlinsight()
{
var finished = new AxBatchJob { Key = "k", RecId = 5, Caption = "c", Status = AxBatchStatus.Finished, EndedUtc = Now };
Assert.Null(AxSchemaAdapter.ObservedRun(finished, null));
Assert.Equal(TimeSpan.FromMinutes(30), AxSchemaAdapter.ObservedRun(finished, Now.AddMinutes(-30))!.Duration);
Assert.Equal(TimeSpan.FromMinutes(5), AxSchemaAdapter.ObservedRun(finished with { StartedUtc = Now.AddMinutes(-5) }, Now.AddMinutes(-30))!.Duration);
Assert.Null(AxSchemaAdapter.ObservedRun(finished with { Status = AxBatchStatus.Executing }, Now.AddMinutes(-30)));
}
[Fact]
public void Number_sequence_usage_format_width_and_forecast()
{
var row = new AxNumberSequenceRow(1, "Debitor", "dat", "Debitorer", 1, 9_999_999, 95_001, "DEB-####", false, false, false, false, true, null, null, null, -120);
var samples = new[] { new AxSample(Now.AddDays(-20), 85_001), new AxSample(Now.AddDays(-10), 90_001) };
var sequence = AxSchemaAdapter.Ax30.Interpret(row, samples, Now, 30);
Assert.Equal(4, sequence.FormatDigits);
Assert.Equal(9_999, sequence.FormatCapacity);
Assert.True(sequence.FormatTooShort);
Assert.Equal(9_999, sequence.Max);
Assert.Equal(100, sequence.UsedPercent);
Assert.Equal(0, sequence.Remaining);
Assert.Equal("dat/Debitor", sequence.Key);
Assert.Equal(500, sequence.Forecast!.PerDay, 3);
Assert.Equal(0, sequence.Forecast.DaysLeft);
}
[Fact]
public void Number_sequence_forecast_needs_a_day_of_history_and_ignores_manual_and_cyclic()
{
var row = new AxNumberSequenceRow(1, "Ordre", "dat", null, 1, 1_000_000, 500_001, "######", false, false, false, false, null, null, null, null, 0);
Assert.Null(AxSchemaAdapter.Ax2009.Interpret(row, [new AxSample(Now.AddHours(-2), 1)], Now, 30).Forecast);
Assert.Null(AxSchemaAdapter.Ax2009.Interpret(row with { Manual = true }, [new AxSample(Now.AddDays(-5), 1)], Now, 30).Forecast);
var sequence = AxSchemaAdapter.Ax2009.Interpret(row, [new AxSample(Now.AddDays(-5), 450_001), new AxSample(Now.AddDays(-60), 1)], Now, 30);
Assert.Equal(10_000, sequence.Forecast!.PerDay, 3);
Assert.Equal(50, sequence.Forecast.DaysLeft!.Value, 3);
Assert.Equal(50, sequence.UsedPercent, 3);
Assert.Equal("1", AxSchemaAdapter.Ax2012.SequenceKey(row));
}
[Fact]
public void Ax30_recid_is_limited_by_the_int_range()
{
var recId = AxSchemaAdapter.Interpret(new AxRecIdRow(-1, "dat", 1_610_612_736, 0, null), nextValIsInt: true,
[new AxSample(Now.AddDays(-10), 1_600_612_736)], Now, 30);
var bigint = AxSchemaAdapter.Interpret(new AxRecIdRow(77, null, 5_000_000_000, null, "CUSTTABLE"), nextValIsInt: false, [], Now, 30);
Assert.Equal((32, int.MaxValue, 75.0), (recId.Bits, recId.Max, Math.Round(recId.UsedPercent, 1)));
Assert.Equal(1_000_000, recId.Forecast!.PerDay, 3);
Assert.Equal(536.87, recId.Forecast.DaysLeft!.Value, 2);
Assert.Equal("-1/dat", recId.Key);
Assert.Equal((64, long.MaxValue), (bigint.Bits, bigint.Max));
Assert.True(bigint.UsedPercent < 0.001);
}
}
public class AxSessionMapperTests
{
private static string Hex(string text, bool unicode = false) =>
Convert.ToHexString(unicode ? System.Text.Encoding.Unicode.GetBytes(text) : System.Text.Encoding.ASCII.GetBytes(text)) + "000000";
private static SessionInfo Session(string? context) => new() { SessionId = 61, HostName = "AOS01", ProgramName = "Microsoft Dynamics AX", ContextInfo = context };
[Fact]
public void Ax2012_context_info_gives_the_ax_user_and_session()
{
var identity = AxSessionMapper.Map(Session(Hex("anne 23")), AxVersion.Ax2012R2);
Assert.Equal(("anne", 23, "context_info"), (identity.AxUser, identity.AxSessionId, identity.Source));
Assert.Equal("anne", AxSessionMapper.Map(Session(Hex("23 anne", unicode: true)), AxVersion.Ax2009).AxUser);
}
[Fact]
public void Ax30_is_honest_that_the_user_cannot_be_seen()
{
var identity = AxSessionMapper.Map(Session(null), AxVersion.Axapta30);
Assert.False(identity.Known);
Assert.Contains("sætter ikke CONTEXT_INFO", identity.Explanation, StringComparison.Ordinal);
Assert.Contains("AOS01", identity.Explanation, StringComparison.Ordinal);
}
[Fact]
public void Without_context_info_the_explanation_says_how_to_turn_it_on()
{
var identity = AxSessionMapper.Map(Session(null), AxVersion.Ax2012);
Assert.False(identity.Known);
Assert.Contains("connectioncontext", identity.Explanation, StringComparison.Ordinal);
Assert.Null(AxSessionMapper.Decode("00000000"));
Assert.Null(AxSessionMapper.Decode("zz"));
Assert.Null(AxSessionMapper.Decode("01FF02FE"));
}
}
public class DynamicsAxHubSubjectTests
{
[Theory]
[InlineData("database: AX30", "AX30")]
[InlineData("database: AX30 · batch: 17", "AX30")]
[InlineData("database: AX 30 · nummerserie: dat/Debitor", "AX 30")]
[InlineData("job: x", null)]
public void Database_of_an_ax_subject(string subject, string? database) => Assert.Equal(database, DynamicsAxFindings.DatabaseOf(subject));
[Theory]
[InlineData("SQLI-AX-005", true)]
[InlineData("COM-AX-001", true)]
[InlineData("SQLI-AG-001", false)]
public void Ax_rules(string ruleId, bool ax) => Assert.Equal(ax, DynamicsAxFindings.IsDynamicsAxRule(ruleId));
[Fact]
public void Subjects_round_trip() => Assert.Equal("AX30", DynamicsAxFindings.DatabaseOf(DynamicsAxFindings.Subject("AX30", "batch: 1")));
}

View file

@ -8,6 +8,10 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\SQLInsight.Core\SQLInsight.Core.csproj" /> <ProjectReference Include="..\..\src\SQLInsight.Core\SQLInsight.Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<!-- Real (customer) and documented Dynamics AX schemas: the version adapters are tested against them. -->
<None Include="..\..\docs\dynamics-ax\*.tsv" Link="DynamicsAx\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit" />
</ItemGroup> </ItemGroup>