using System.Globalization; namespace SQLInsight.Core.DynamicsAx; /// /// 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 /// ), so a schema that differs gives "ikke målbar" with the exact column, never an error. /// /// Only 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). /// /// public abstract class AxSchemaAdapter { /// How many batch jobs are read per collection (newest RECID first). public const int MaxBatchJobs = 1000; /// How many history rows are read per collection. public const int MaxHistoryRows = 1000; /// RecIds are handed out in blocks per AOS, so history rows may arrive slightly out of RECID order: re-read this far back. public const long HistoryOverlap = 2000; /// /// Rows read after the watermark: the overlap plus new rows. AX 4.0 and later hand out /// RecIds per table, so BATCHJOBHISTORY's RecIds are dense; with no more rows than the overlap the read would return the /// same old rows every time and the watermark would never move again. /// public const int MaxHistoryRowsAfterWatermark = (int)HistoryOverlap + MaxHistoryRows; /// How many number sequences are read (the most used first). public const int MaxNumberSequences = 5000; /// How many RecId sequences are read (the highest first). public const int MaxRecIdSequences = 500; /// DATEDIFF from the server's local time to UTC: turns AX 3.0/4.0's local times into UTC. 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 All => [Ax30, Ax40, Ax2009, Ax2012, FinanceOperations]; /// The adapter for a version, or null when the version is unknown (then no AX data checks run). 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, }; /// The versions this adapter serves. public abstract IReadOnlyList Versions { get; } /// The schema file name under docs/dynamics-ax (without .tsv / .documented.tsv): schema-ax30, schema-ax2009 … public abstract string SchemaFileName { get; } /// The tables the batch checks read. public abstract IReadOnlyList BatchTables { get; } /// The tables the number sequence checks read. public abstract IReadOnlyList NumberSequenceTables { get; } /// The tables the RecId check reads (SQLDICTIONARY only for table names). public static IReadOnlyList RecIdTables => ["SYSTEMSEQUENCES", "SQLDICTIONARY"]; /// Every table the optional AX read access covers for this version: batch, number sequences, RecId, companies. public IReadOnlyList ReadTables => [.. BatchTables.Concat(NumberSequenceTables).Concat(RecIdTables).Append("DATAAREA").Distinct(StringComparer.OrdinalIgnoreCase)]; /// Columns each check cannot do without, per table (the adapter tests check them against the schema files). public abstract IReadOnlyDictionary> RequiredColumns { get; } /// Batch times are a local date plus seconds since midnight (3.0/4.0), not UTC datetimes. public abstract bool LocalBatchTimes { get; } /// The version's batch STATUS enum. public abstract AxBatchStatus BatchStatus(int status); /// Whether the batch STATUS mapping is verified against customer data. public virtual bool BatchStatusVerified => false; public abstract AxQuery BatchJobs(AxDatabase database); /// Completed runs from AX's own history after (newest first when null), or null: no history table in this version. public virtual AxQuery? BatchHistory(AxDatabase database, long? afterRecId) => null; public abstract AxQuery NumberSequences(AxDatabase database); /// The companies: DATAAREA (ID, NAME, ISVIRTUAL). 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")}"); } /// RecId allocation per table (per company in AX 3.0): SYSTEMSEQUENCES rows named SEQNO, the highest first. 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(); 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]); } /// /// "Ikke målbar" with the exact GRANT, when the agent may not read one of (or it is missing). /// protected static string? Unreadable(AxDatabase database, IReadOnlyList 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) /// A stable key for a job, so its runs can be compared over time. public virtual string JobKey(AxBatchJobRow row) { ArgumentNullException.ThrowIfNull(row); return row.RecId.ToString(CultureInfo.InvariantCulture); } /// What the UI calls a job: its caption, or class, company and group when AX stores no caption (3.0/4.0). public virtual string Caption(AxBatchJobRow row) { ArgumentNullException.ThrowIfNull(row); return string.IsNullOrWhiteSpace(row.Caption) ? $"Batchjob {row.RecId.ToString(CultureInfo.InvariantCulture)}" : row.Caption.Trim(); } /// /// A time as stored by AX in UTC: a local date plus seconds since midnight (3.0/4.0, in the AX time zone; see /// ), or a UTC datetime. AX's empty date (1900-01-01) is null. /// /// The AX time zone for local times (); null: the server's offset. public DateTime? ToUtc(DateTime? date, int? seconds, int offsetMinutes, TimeZoneInfo? zone = null) { if (date is not { } d || d.Year <= 1900) { return null; } if (!LocalBatchTimes) { return DateTime.SpecifyKind(d, DateTimeKind.Utc); } return AxLocalTime.ToUtc(d, seconds, offsetMinutes, zone); } public AxBatchJob Interpret(AxBatchJobRow row, AxBatchHistory history, string database, DateTime? firstSeenExecuting, TimeZoneInfo? zone = null) { 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, zone), StartedUtc = ToUtc(row.StartDate, row.StartSeconds, row.ServerUtcOffsetMinutes, zone), EndedUtc = ToUtc(row.EndDate, row.EndSeconds, row.ServerUtcOffsetMinutes, zone), FirstSeenExecutingUtc = firstSeenExecuting, HistoryRuns = runs.Count, MedianSeconds = median, P90Seconds = p90, LastRunSeconds = last?.Duration.TotalSeconds, LastRunStatus = last?.Status, LastRunEndedUtc = last?.EndedUtc, }; } /// /// 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. /// 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.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture), job.Caption, job.Company, start, ended, job.Status); } /// A run from AX's batch history (2009 and later). 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)); } /// Stable key of a number sequence for its sampled history. public virtual string SequenceKey(AxNumberSequenceRow row) { ArgumentNullException.ThrowIfNull(row); return (Trim(row.Company) ?? "*") + "/" + row.Code.Trim(); } public AxNumberSequence Interpret(AxNumberSequenceRow row, IEnumerable samples, DateTime nowUtc, int forecastDays, TimeZoneInfo? zone = null) { 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 = (long)Math.Min(long.MaxValue, Math.Max(0m, (decimal)max - row.Next + 1)); // Axapta 3.0/4.0: a local date plus seconds (LATESTCLEANTIME may be missing: midnight); AX 2009 and later: one UTC datetime. var lastClean = LocalBatchTimes ? AxLocalTime.ToUtc(row.LastCleanDate, row.LastCleanSeconds ?? 0, row.ServerUtcOffsetMinutes, zone) : row.LastCleanDate is { Year: > 1900 } utcClean ? DateTime.SpecifyKind(utcClean, DateTimeKind.Utc) : null; 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, CleanupKnown = row.LastCleanDate is not null, 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) ?? "*"); } /// SYSTEMSEQUENCES.NEXTVAL (and RECID) is int: AX 3.0's 32-bit RecId. public static AxRecIdSequence Interpret(AxRecIdRow row, bool nextValIsInt, IEnumerable 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), }; } protected static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); // ---------------------------------------------------------------- shared SQL builders /// /// 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. /// internal static AxQuery NumberSequenceQuery(AxDatabase database, string companyExpression, string? companyJoin, string? referencedExpression, IEnumerable 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]); } /// The old batch table BATCH of AX 3.0 and 4.0: a local start date plus seconds since midnight, no caption. 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); } /// BATCHJOB of AX 2009 and later: one row per job, planned start ORIGSTARTDATETIME, actual STARTDATETIME, all UTC. 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); } /// /// 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 RecIds back for blocks handed out by other AOS'es. /// 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 ({MaxHistoryRowsAfterWatermark}) {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); } /// The 2009+ BatchStatus enum (Microsoft's documentation of BatchStatus). 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, }; } /// /// 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. /// public sealed class Ax30Adapter : AxSchemaAdapter { public override IReadOnlyList Versions => [AxVersion.Axapta30]; public override string SchemaFileName => "schema-ax30"; public override IReadOnlyList BatchTables => ["BATCH"]; public override IReadOnlyList NumberSequenceTables => ["NUMBERSEQUENCETABLE", "NUMBERSEQUENCEREFERENCE"]; public override bool LocalBatchTimes => true; public override IReadOnlyDictionary> RequiredColumns => new Dictionary> { ["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"], }; /// /// 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. /// 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); /// NUMBERSEQUENCETABLE per DATAAREAID; references by code and company (3.0, 4.0, 2009). internal static AxQuery LegacyNumberSequences(AxDatabase database) { var notes = new List(); 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); } } /// /// 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. /// public sealed class Ax40Adapter : AxSchemaAdapter { public override IReadOnlyList Versions => [AxVersion.Ax40]; public override string SchemaFileName => "schema-ax40"; public override IReadOnlyList BatchTables => ["BATCH"]; public override IReadOnlyList NumberSequenceTables => ["NUMBERSEQUENCETABLE", "NUMBERSEQUENCEREFERENCE"]; public override bool LocalBatchTimes => true; public override IReadOnlyDictionary> 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); } /// /// 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. /// public sealed class Ax2009Adapter : AxSchemaAdapter { public override IReadOnlyList Versions => [AxVersion.Ax2009]; public override string SchemaFileName => "schema-ax2009"; public override IReadOnlyList BatchTables => ["BATCHJOB", "BATCHJOBHISTORY"]; public override IReadOnlyList NumberSequenceTables => ["NUMBERSEQUENCETABLE", "NUMBERSEQUENCEREFERENCE"]; public override bool LocalBatchTimes => false; public override IReadOnlyDictionary> RequiredColumns => new Dictionary> { ["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); } /// /// 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. /// public class Ax2012Adapter : AxSchemaAdapter { public override IReadOnlyList Versions => [AxVersion.Ax2012, AxVersion.Ax2012R2]; public override string SchemaFileName => "schema-ax2012"; public override IReadOnlyList BatchTables => ["BATCHJOB", "BATCHJOBHISTORY"]; public override IReadOnlyList NumberSequenceTables => ["NUMBERSEQUENCETABLE", "NUMBERSEQUENCESCOPE", "NUMBERSEQUENCEREFERENCE"]; public override bool LocalBatchTimes => false; public override IReadOnlyDictionary> RequiredColumns => new Dictionary> { ["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); /// A 2012 sequence is identified by its RECID: the same code can exist in several scopes. 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(); 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); } } /// /// 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. /// public sealed class FinanceOperationsAdapter : Ax2012Adapter { public override IReadOnlyList Versions => [AxVersion.FinanceOperations]; public override string SchemaFileName => "schema-fo"; }