Codeunit 9500 Sequence No. Mgt., source in 29

Source29

src/Layers/W1/BaseApp/Foundation/NoSeries/SequenceNoMgt.Codeunit.al350 lines, Copyright (c) Microsoft Corporation. MIT

// ------------------------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
// ------------------------------------------------------------------------------------------------
namespace Microsoft.Foundation.NoSeries;

codeunit 9500 "Sequence No. Mgt."
{
    SingleInstance = true;
    InherentPermissions = X;
    Permissions = tabledata "Sequence No. Preview State" = rid;

    var
        GlobalPreviewMode: Boolean;
        GlobalPreviewModeID: Integer;
        LastSeqNoChecked: List of [Integer];
        SeqNoBufferFrom: Dictionary of [Integer, Integer];
        SeqNoBufferTo: Dictionary of [Integer, Integer];
        PendingAllocation: Dictionary of [Integer, Integer];
        SeqNameLbl: Label 'TableSeq%1', Comment = '%1 - Table No.', Locked = true;
        PreviewSeqNameLbl: Label 'PreviewTableSeq%1', Comment = '%1 - Table No.', Locked = true;

    /// <summary>
    /// Clears allocations and other internal states
    /// </summary>
    procedure ClearState()
    begin
        ClearAll(); // may not work for SingleInstance codeunits....
        Clear(GlobalPreviewMode);
        Clear(GlobalPreviewModeID);
        Clear(LastSeqNoChecked);
        Clear(SeqNoBufferFrom);
        Clear(SeqNoBufferFrom);
        Clear(SeqNoBufferTo);
        Clear(PendingAllocation);
    end;

    /// <summary>
    /// Allocates sequence numbers for a given table ID.
    /// if the sequence does not exist, it will be created.
    /// </summary>
    /// <param name="TableNo">The ID of the table being checked</param>
    procedure AllocateSeqNoBuffer(TableNo: Integer; NoOfEntries: Integer)
    var
        SignedTableNo: Integer;
        PreviewMode: Boolean;
        RemainingNoOfEntries: Integer;
    begin
        if NoOfEntries < 2 then  // no need reserve 1, as it it still one sql call
            exit;
        ValidateSeqNo(TableNo);
        PreviewMode := IsPreviewMode();  // Only call once to minimize sql calls during preview.
        SignedTableNo := PreviewMode ? -TableNo : TableNo;
        if SeqNoBufferFrom.ContainsKey(SignedTableNo) and SeqNoBufferTo.ContainsKey(SignedTableNo) then  // lefterovers from previous allocation?
            RemainingNoOfEntries := SeqNoBufferTo.Get(SignedTableNo) - SeqNoBufferFrom.Get(SignedTableNo);
        if RemainingNoOfEntries >= NoOfEntries then
            exit; // we have enough already    
        if PendingAllocation.ContainsKey(SignedTableNo) then
            PendingAllocation.Set(SignedTableNo, PendingAllocation.Get(SignedTableNo) + NoOfEntries - RemainingNoOfEntries)
        else
            PendingAllocation.Add(SignedTableNo, NoOfEntries - RemainingNoOfEntries);
    end;

    /// <summary>
    /// Returns the next buffered NumberSequence value for a given table ID.
    /// if the sequence does not exist, it will be created.
    /// </summary>
    /// <param name="TableNo">The ID of the table being checked</param>
    procedure GetNextSeqNo(TableNo: Integer): Integer
    var
        NewSeqNo: Integer;
        FromNo: Integer;
        NoOfEntries: Integer;
        SignedTableNo: Integer;
        PreviewMode: Boolean;
    begin
        PreviewMode := IsPreviewMode();  // Only call once to minimize sql calls during preview.

        // First check if we have pre-allocated entry numbers
        SignedTableNo := PreviewMode ? -TableNo : TableNo;  // we use -tableno for index for preview numbers.
        if PendingAllocation.ContainsKey(SignedTableNo) then
            if not SeqNoBufferFrom.ContainsKey(SignedTableNo) or not SeqNoBufferTo.ContainsKey(SignedTableNo) then begin
                NoOfEntries := PendingAllocation.Get(SignedTableNo);
                PendingAllocation.Remove(SignedTableNo);
                ValidateSeqNo(TableNo);
                if not TryGetRange(PreviewMode, TableNo, NoOfEntries, NewSeqNo) then begin
                    ClearLastError();
                    CreateNewTableSequence(PreviewMode, TableNo);
                    TryGetRange(PreviewMode, TableNo, NoOfEntries, NewSeqNo);
                end;
                if SeqNoBufferFrom.ContainsKey(SignedTableNo) then
                    SeqNoBufferFrom.Set(SignedTableNo, NewSeqNo)
                else
                    SeqNoBufferFrom.Add(SignedTableNo, NewSeqNo);
                if SeqNoBufferTo.ContainsKey(SignedTableNo) then
                    SeqNoBufferTo.Set(SignedTableNo, NewSeqNo + NoOfEntries - 1)
                else
                    SeqNoBufferTo.Add(SignedTableNo, NewSeqNo + NoOfEntries - 1);
            end;
        if SeqNoBufferFrom.ContainsKey(SignedTableNo) and SeqNoBufferTo.ContainsKey(SignedTableNo) then begin
            FromNo := SeqNoBufferFrom.Get(SignedTableNo);
            NewSeqNo := FromNo;
            FromNo += 1;
            if FromNo <= SeqNoBufferTo.Get(SignedTableNo) then
                SeqNoBufferFrom.Set(SignedTableNo, FromNo)
            else begin
                if SeqNoBufferFrom.Remove(SignedTableNo) then;
                if SeqNoBufferTo.Remove(SignedTableNo) then;
            end;
            exit(NewSeqNo);
        end;

        // No pre-allocated numbers - get entry no. from sequence
        ValidateSeqNo(TableNo);
        if TryGetNextNo(PreviewMode, TableNo, NewSeqNo) then
            exit(NewSeqNo);
        ClearLastError();
        CreateNewTableSequence(PreviewMode, TableNo);
        TryGetNextNo(PreviewMode, TableNo, NewSeqNo);
        exit(NewSeqNo);
    end;

    /// <summary>
    /// Returns the current NumberSequence value for a given table ID.
    /// if the sequence does not exist, it will be created.
    /// </summary>
    /// <param name="TableNo">The ID of the table being checked</param>
    procedure GetCurrentSeqNo(TableNo: Integer): Integer
    var
        CurrSeqNo: Integer;
        PreviewMode: Boolean;
    begin
        PreviewMode := IsPreviewMode();  // Only call once to minimize sql calls during preview.
        if TryGetCurrentNo(PreviewMode, TableNo, CurrSeqNo) then
            exit(CurrSeqNo);
        ClearLastError();
        CreateNewTableSequence(PreviewMode, TableNo);
        TryGetCurrentNo(PreviewMode, TableNo, CurrSeqNo);
        exit(CurrSeqNo);
    end;

    /// <summary>
    /// Ensures that the NumberSequence is not behind the last entry in the table.
    /// if the sequence does not exist, it will be created.
    /// The result will be cached for the current transaction. The cache can be cleared by calling ClearSequenceNoCheck.
    /// </summary>
    /// <param name="TableNo">The ID of the table being checked</param>
    procedure ValidateSeqNo(TableNo: Integer)
    var
        LastEntryNo: Integer;
    begin
        if IsPreviewMode() then
            exit;
        if LastSeqNoChecked.Contains(TableNo) then
            exit;

        LastEntryNo := GetLastEntryNoFromTable(TableNo, false);
        if GetCurrentSeqNo(TableNo) < LastEntryNo then
            RebaseSeqNo(TableNo);

        LastSeqNoChecked.Add(TableNo);
    end;

    /// <summary>
    /// Clears the cache that is used by procedure ValidateSeqNo(TableNo).
    /// </summary>
    procedure ClearSequenceNoCheck()
    begin
        Clear(LastSeqNoChecked);
    end;

    [TryFunction]
    local procedure TryGetNextNo(PreviewMode: Boolean; TableNo: Integer; var NewSeqNo: Integer)
    begin
        NewSeqNo := NumberSequence.Next(GetTableSequenceName(PreviewMode, TableNo));
    end;

    [TryFunction]
    local procedure TryGetRange(PreviewMode: Boolean; TableNo: Integer; NoOfEntries: Integer; var NewSeqNo: Integer)
    begin
        NewSeqNo := NumberSequence.Range(GetTableSequenceName(PreviewMode, TableNo), NoOfEntries);
    end;

    [TryFunction]
    local procedure TryGetCurrentNo(PreviewMode: Boolean; TableNo: Integer; var CurrSeqNo: Integer)
    begin
        CurrSeqNo := NumberSequence.Current(GetTableSequenceName(PreviewMode, TableNo));
    end;

    /// <summary>
    /// Restarts or recreates the NumberSequence for the specified Table ID.
    /// </summary>
    /// <param name="TableNo">The ID of the table being checked</param>
    procedure RebaseSeqNo(TableNo: Integer)
    begin
        CreateNewTableSequence(IsPreviewMode(), TableNo);
    end;

    local procedure CreateNewTableSequence(PreviewMode: Boolean; TableNo: Integer)
    var
        StartSeqNo: BigInteger;
        IsPreviewable: Boolean;
    begin
        OnPreviewableLedgerEntry(TableNo, IsPreviewable);
        if PreviewMode and IsPreviewable then
            StartSeqNo := -2000000000
        else
            StartSeqNo := GetLastEntryNoFromTable(TableNo, true) + 1;

        CreateSequence(GetTableSequenceName(PreviewMode and IsPreviewable, TableNo), StartSeqNo);

        if PreviewMode or not IsPreviewable then
            exit;

        // Creating/deleting a number sequence is transactional, so we benefit from creating the preview sequence in a non-preview transaction
        CreateSequence(GetTableSequenceName(true, TableNo), -2000000000);
    end;

    local procedure CreateSequence(SequenceName: Text; StartSeqNo: BigInteger)
    begin
        if NumberSequence.Exists(SequenceName) then
            NumberSequence.Restart(SequenceName, StartSeqNo - 1)  // to avoid the issue with current and next being the same for a new sequence.
        else
            NumberSequence.Insert(SequenceName, StartSeqNo - 1, 1, true);  // to avoid the issue with current and next being the same for a new sequence.
        if NumberSequence.Next(SequenceName) = 1 then;  // do.
    end;

    local procedure GetLastEntryNoFromTable(TableNo: Integer; WithLock: Boolean): BigInteger
    var
        [SecurityFiltering(SecurityFilter::Ignored)]
        RecRef: RecordRef;
        FldRef: FieldRef;
        KeyRef: KeyRef;
        LastEntryNo: BigInteger;
    begin
        OnBeforeGetLastEntryNoFromTable(TableNo);
        RecRef.Open(TableNo);
        if WithLock then
            RecRef.ReadIsolation(IsolationLevel::UpdLock)
        else
            RecRef.ReadIsolation(IsolationLevel::ReadUncommitted);
        KeyRef := RecRef.KeyIndex(1);
        RecRef.SetLoadFields(KeyRef.FieldIndex(KeyRef.FieldCount).Number);
        if RecRef.FindLast() then begin
            FldRef := KeyRef.FieldIndex(KeyRef.FieldCount);
            LastEntryNo := FldRef.Value
        end else
            LastEntryNo := 0;
        OnAfterGetLastEntryNoFromTable(TableNo);
        exit(LastEntryNo);
    end;

    /// <summary>
    /// Returns the name of the NumberSequence for the specified Table ID.
    /// </summary>
    /// <param name="TableNo">The ID of the table being checked</param>
    procedure GetTableSequenceName(TableNo: Integer): Text
    begin
        exit(GetTableSequenceName(IsPreviewMode(), TableNo));
    end;

    /// <summary>
    /// Returns the name of the NumberSequence for the specified Table ID. We have one sequence for preview and one for non-preview.
    /// </summary>
    /// <param name="PreviewMode">Is true for posting preview</param>
    /// <param name="TableNo">The ID of the table being checked</param>
    procedure GetTableSequenceName(PreviewMode: Boolean; TableNo: Integer): Text
    var
        IsPreviewable: Boolean;
    begin
        if PreviewMode then
            OnPreviewableLedgerEntry(TableNo, IsPreviewable);
        if IsPreviewable then
            exit(StrSubstNo(PreviewSeqNameLbl, TableNo));
        exit(StrSubstNo(SeqNameLbl, TableNo));
    end;

    // An error during preview may mean that the PreviewMode variable doesn't get reset after preview, so we need to rely on transactional consistency
    local procedure IsPreviewMode(): Boolean
    var
        SequenceNoPreviewState: Record "Sequence No. Preview State";
    begin
        if not GlobalPreviewMode then
            exit(false);
        if GlobalPreviewModeID <> 0 then
            if SequenceNoPreviewState.Get(GlobalPreviewModeID) then  // double-check
                exit(true);
        GlobalPreviewMode := false;
        GlobalPreviewModeID := 0;
    end;

    internal procedure StartPreviewMode()
    var
        SequenceNoPreviewState: Record "Sequence No. Preview State";
    begin
        if GlobalPreviewMode then // missing cleanup from previous preview?
            StopPreviewMode();
        SequenceNoPreviewState.ID := 0;
        SequenceNoPreviewState.Insert();
        SequenceNoPreviewState.Consistent(false); // make sure we cannot commit the transaction
        GlobalPreviewMode := true;
        GlobalPreviewModeID := SequenceNoPreviewState.ID;
    end;

    internal procedure StopPreviewMode()
    var
        SequenceNoPreviewState: Record "Sequence No. Preview State";
    begin
        GlobalPreviewMode := false;
        if GlobalPreviewModeID <> 0 then
            if SequenceNoPreviewState.Get(GlobalPreviewModeID) then
                SequenceNoPreviewState.Delete();
        GlobalPreviewModeID := 0;
        SequenceNoPreviewState.Consistent(true); // make sure we can commit the transaction
    end;

    internal procedure SetPreviewMode(NewPreviewMode: Boolean)
    begin
        if NewPreviewMode then
            StartPreviewMode()
        else
            StopPreviewMode();
    end;

    [IntegrationEvent(false, false)]
    local procedure OnPreviewableLedgerEntry(TableNo: Integer; var IsPreviewable: Boolean)
    begin
    end;

    /// <summary>
    /// Integration event raised at the beginning of GetLastEntryNoFromTable, before the table is opened.
    /// Allows extensions to remove filters (for example global dimension filters) that could affect the last entry number lookup.
    /// </summary>
    /// <param name="TableNo">The ID of the table being read.</param>
    [IntegrationEvent(false, false)]
    local procedure OnBeforeGetLastEntryNoFromTable(TableNo: Integer)
    begin
    end;

    /// <summary>
    /// Integration event raised at the end of GetLastEntryNoFromTable, after the last entry number has been read.
    /// Allows extensions to reapply filters that were removed in OnBeforeGetLastEntryNoFromTable.
    /// </summary>
    /// <param name="TableNo">The ID of the table being read.</param>
    [IntegrationEvent(false, false)]
    local procedure OnAfterGetLastEntryNoFromTable(TableNo: Integer)
    begin
    end;
}