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:
parent
66aa47ecb3
commit
4ebae4efe6
20 changed files with 2730 additions and 6 deletions
101
db/permissions/grant-dynamics-ax-read.sql
Normal file
101
db/permissions/grant-dynamics-ax-read.sql
Normal 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;
|
||||
48
db/permissions/revoke-dynamics-ax-read.sql
Normal file
48
db/permissions/revoke-dynamics-ax-read.sql
Normal 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;
|
||||
129
docs/dynamics-ax/schema-ax2009.documented.tsv
Normal file
129
docs/dynamics-ax/schema-ax2009.documented.tsv
Normal 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.
|
135
docs/dynamics-ax/schema-ax2012.documented.tsv
Normal file
135
docs/dynamics-ax/schema-ax2012.documented.tsv
Normal 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.
|
|
|
@ -68,15 +68,34 @@ public sealed class DatabaseSelection
|
|||
/// </summary>
|
||||
public static class DynamicsAxFindings
|
||||
{
|
||||
/// <summary>The community AX rules (rules/community/dynamics-ax.json).</summary>
|
||||
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: ";
|
||||
|
||||
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 string? DatabaseOf(string? subject) =>
|
||||
subject is not null && subject.StartsWith(DatabaseSubject, StringComparison.OrdinalIgnoreCase) && subject.Length > DatabaseSubject.Length
|
||||
? subject[DatabaseSubject.Length..]
|
||||
: null;
|
||||
public static bool IsDynamicsAxRule(string ruleId) =>
|
||||
ruleId?.StartsWith(RulePrefix, StringComparison.OrdinalIgnoreCase) == true || ruleId?.StartsWith(BuiltInRulePrefix, StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
/// <summary>The subject of an AX finding about <paramref name="detail"/> in <paramref name="database"/>.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,4 +45,10 @@ public sealed record SessionInfo
|
|||
public string? SqlText { 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; }
|
||||
}
|
||||
|
|
|
|||
216
src/SQLInsight.Core/DynamicsAx/AxBatch.cs
Normal file
216
src/SQLInsight.Core/DynamicsAx/AxBatch.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
99
src/SQLInsight.Core/DynamicsAx/AxDatabase.cs
Normal file
99
src/SQLInsight.Core/DynamicsAx/AxDatabase.cs
Normal 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 (<database>_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));
|
||||
}
|
||||
154
src/SQLInsight.Core/DynamicsAx/AxDetection.cs
Normal file
154
src/SQLInsight.Core/DynamicsAx/AxDetection.cs
Normal 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
|
||||
/// <db>_model on the instance → AX 2012 R2/R3; PARTITION without any model store → Dynamics 365 F&O (formodet).
|
||||
/// Only the AX 3.0 rules are verified against a real customer schema; the rest follow Microsoft's documentation.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AxDetection
|
||||
{
|
||||
/// <summary>The AX tables SQLInsight knows: the markers and every table an AX check reads.</summary>
|
||||
public static readonly IReadOnlyList<string> KnownTables =
|
||||
[
|
||||
"SQLDICTIONARY", "DATAAREA", "SYSCONFIG",
|
||||
"BATCH", "BATCHJOB", "BATCHJOBHISTORY", "BATCHGROUP",
|
||||
"NUMBERSEQUENCETABLE", "NUMBERSEQUENCESCOPE", "NUMBERSEQUENCEREFERENCE",
|
||||
"SYSTEMSEQUENCES", "MODELELEMENT",
|
||||
];
|
||||
|
||||
private static string TableList => string.Join(", ", KnownTables.Select(t => "N'" + t + "'"));
|
||||
|
||||
/// <summary>
|
||||
/// Run inside the database (<see cref="AxSql.InDatabase"/>), SQL Server 2005 and later: every column of the known AX
|
||||
/// tables, whether the agent may SELECT from the table, and the agent's database user. Names are upper-cased, so a
|
||||
/// case-sensitive collation does not matter.
|
||||
/// </summary>
|
||||
public static string MetadataSql => $"""
|
||||
SELECT s.name, UPPER(t.name), UPPER(c.name), TYPE_NAME(c.system_type_id), c.precision, c.max_length,
|
||||
ISNULL(HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N'.' + QUOTENAME(t.name), N'OBJECT', N'SELECT'), 0),
|
||||
(SELECT TOP 1 p.name FROM sys.database_principals p WHERE p.sid = SUSER_SID())
|
||||
FROM sys.tables t
|
||||
JOIN sys.schemas s ON s.schema_id = t.schema_id
|
||||
JOIN sys.columns c ON c.object_id = t.object_id
|
||||
WHERE UPPER(t.name) IN ({TableList})
|
||||
ORDER BY s.name, t.name, c.column_id
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// SQL Server 2000 (legacy ODBC), with a three-part name so the session never changes database. No permission
|
||||
/// information: the AX data checks need SQL Server 2005 or later.
|
||||
/// </summary>
|
||||
public static string MetadataSql2000(string database) => $"""
|
||||
SELECT u.name, UPPER(o.name), UPPER(c.name), TYPE_NAME(c.xtype), c.xprec, c.length, 0, NULL
|
||||
FROM {AxSql.Identifier(database)}.dbo.sysobjects o
|
||||
JOIN {AxSql.Identifier(database)}.dbo.sysusers u ON u.uid = o.uid
|
||||
JOIN {AxSql.Identifier(database)}.dbo.syscolumns c ON c.id = o.id
|
||||
WHERE o.xtype = 'U' AND UPPER(o.name) IN ({TableList})
|
||||
ORDER BY u.name, o.name, c.colid
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Decides from a database's detection rows whether it is AX, and which version.
|
||||
/// </summary>
|
||||
/// <param name="databasesOnInstance">Every database name on the instance, to find the AX 2012 R2/R3 model store (<db>_model).</param>
|
||||
public static AxDatabase? Recognise(string database, IReadOnlyList<AxDetectionRow> rows, IEnumerable<string> databasesOnInstance)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
ArgumentNullException.ThrowIfNull(databasesOnInstance);
|
||||
|
||||
var schemas = rows
|
||||
.GroupBy(r => r.Schema, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => (Schema: g.Key, Tables: g.GroupBy(r => r.Table, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(t => new AxTable(t.Key.ToUpperInvariant(), t.Any(r => r.Readable),
|
||||
t.Select(r => new AxColumn(r.Column.ToUpperInvariant(), r.Type, r.Precision, r.MaxLength)).ToList()))
|
||||
.ToList()))
|
||||
.Where(s => s.Tables.Any(t => t.Name == "SQLDICTIONARY") && s.Tables.Any(t => t.Name == "DATAAREA"))
|
||||
.OrderByDescending(s => s.Tables.Count)
|
||||
.ThenBy(s => string.Equals(s.Schema, "dbo", StringComparison.OrdinalIgnoreCase) ? 0 : 1)
|
||||
.ToList();
|
||||
if (schemas.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var (schema, tables) = schemas[0];
|
||||
var notes = new List<string>();
|
||||
bool Has(string table) => tables.Any(t => t.Name == table);
|
||||
var recId = tables.Single(t => t.Name == "SQLDICTIONARY").Column("RECID");
|
||||
var partition = tables.Any(t => t.Name != "SQLDICTIONARY" && t.HasColumn("PARTITION"));
|
||||
var modelDatabase = databasesOnInstance.FirstOrDefault(d => string.Equals(d, database + "_model", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
AxVersion version;
|
||||
if (recId is null)
|
||||
{
|
||||
version = AxVersion.Unknown;
|
||||
notes.Add("SQLDICTIONARY har ingen RECID-kolonne, så versionen kan ikke bestemmes. AX-datatjek køres ikke.");
|
||||
}
|
||||
else if (recId.Precision <= 10)
|
||||
{
|
||||
version = AxVersion.Axapta30;
|
||||
notes.Add($"RECID er 32-bit (int) i skemaet {schema}: Axapta 2.5 eller 3.0 (de to kan ikke skelnes ud fra skemaet).");
|
||||
}
|
||||
else if (!Has("BATCHJOB"))
|
||||
{
|
||||
version = AxVersion.Ax40;
|
||||
notes.Add("RECID er 64-bit, og BATCHJOB findes ikke: AX 4.0.");
|
||||
}
|
||||
else if (!Has("NUMBERSEQUENCESCOPE"))
|
||||
{
|
||||
version = AxVersion.Ax2009;
|
||||
notes.Add("BATCHJOB findes, NUMBERSEQUENCESCOPE gør ikke: AX 2009.");
|
||||
}
|
||||
else if (!partition)
|
||||
{
|
||||
version = AxVersion.Ax2012;
|
||||
notes.Add(Has("MODELELEMENT")
|
||||
? "NUMBERSEQUENCESCOPE uden PARTITION-kolonne, og modellageret ligger i selve databasen: AX 2012 RTM/FP."
|
||||
: "NUMBERSEQUENCESCOPE uden PARTITION-kolonne: AX 2012 RTM/FP.");
|
||||
}
|
||||
else if (modelDatabase is not null || Has("MODELELEMENT"))
|
||||
{
|
||||
version = AxVersion.Ax2012R2;
|
||||
notes.Add(modelDatabase is not null
|
||||
? $"PARTITION-kolonne og modeldatabasen {modelDatabase}: AX 2012 R2/R3."
|
||||
: "PARTITION-kolonne og modellager i databasen: AX 2012 R2/R3.");
|
||||
}
|
||||
else
|
||||
{
|
||||
version = AxVersion.FinanceOperations;
|
||||
notes.Add($"PARTITION-kolonne, men intet modellager ({database}_model findes ikke på instansen): formodet Dynamics 365 F&O on-premises. "
|
||||
+ "Ligger AX 2012 R2/R3-modeldatabasen på en anden instans, er det i stedet AX 2012 R2/R3.");
|
||||
}
|
||||
|
||||
if (!Has("SYSCONFIG"))
|
||||
{
|
||||
notes.Add("SYSCONFIG findes ikke (genkendt på SQLDICTIONARY og DATAAREA).");
|
||||
}
|
||||
|
||||
if (!AxVersions.IsVerified(version) && version != AxVersion.Unknown)
|
||||
{
|
||||
notes.Add(AxVersions.VerificationText(version));
|
||||
}
|
||||
|
||||
return new AxDatabase
|
||||
{
|
||||
Database = database,
|
||||
Schema = schema,
|
||||
Version = version,
|
||||
Tables = tables,
|
||||
ModelDatabase = modelDatabase,
|
||||
DatabaseUser = rows.FirstOrDefault(r => r.DatabaseUser is not null)?.DatabaseUser,
|
||||
Notes = notes,
|
||||
};
|
||||
}
|
||||
}
|
||||
85
src/SQLInsight.Core/DynamicsAx/AxReadAccess.cs
Normal file
85
src/SQLInsight.Core/DynamicsAx/AxReadAccess.cs
Normal 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()}";
|
||||
}
|
||||
}
|
||||
729
src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs
Normal file
729
src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs
Normal 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&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 <db>_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 & Operations on-premises: recognised, and read with the AX 2012 layout (BATCHJOB,
|
||||
/// NUMBERSEQUENCETABLE with NUMBERSEQUENCESCOPE), which F&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";
|
||||
}
|
||||
90
src/SQLInsight.Core/DynamicsAx/AxSchemaFile.cs
Normal file
90
src/SQLInsight.Core/DynamicsAx/AxSchemaFile.cs
Normal 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))});";
|
||||
}
|
||||
}
|
||||
}
|
||||
153
src/SQLInsight.Core/DynamicsAx/AxSequences.cs
Normal file
153
src/SQLInsight.Core/DynamicsAx/AxSequences.cs
Normal 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;
|
||||
}
|
||||
98
src/SQLInsight.Core/DynamicsAx/AxSessionMapper.cs
Normal file
98
src/SQLInsight.Core/DynamicsAx/AxSessionMapper.cs
Normal 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&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;
|
||||
}
|
||||
}
|
||||
136
src/SQLInsight.Core/DynamicsAx/AxSql.cs
Normal file
136
src/SQLInsight.Core/DynamicsAx/AxSql.cs
Normal 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.";
|
||||
}
|
||||
57
src/SQLInsight.Core/DynamicsAx/AxThresholds.cs
Normal file
57
src/SQLInsight.Core/DynamicsAx/AxThresholds.cs
Normal 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;
|
||||
}
|
||||
90
src/SQLInsight.Core/DynamicsAx/AxVersion.cs
Normal file
90
src/SQLInsight.Core/DynamicsAx/AxVersion.cs
Normal 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 <database>_model database.</summary>
|
||||
Ax2012R2 = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Dynamics 365 Finance & 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -5,5 +5,7 @@
|
|||
<ItemGroup>
|
||||
<!-- 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" />
|
||||
<!-- 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>
|
||||
</Project>
|
||||
|
|
|
|||
373
tests/SQLInsight.Core.Tests/DynamicsAxTests.cs
Normal file
373
tests/SQLInsight.Core.Tests/DynamicsAxTests.cs
Normal 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")));
|
||||
}
|
||||
|
|
@ -8,6 +8,10 @@
|
|||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\SQLInsight.Core\SQLInsight.Core.csproj" />
|
||||
</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>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue