# TriggerTracker.java

    /*
    ** Module   : TriggerTracker.java
    ** Abstract : Helper class to manage database trigger information for a single record buffer.
    **
    ** Copyright (c) 2014-2024, Golden Code Development Corporation.
    **
    ** -#- -I- --Date-- --------------------------------------Description----------------------------------------
    ** 001 ECF 20140801 Created initial version.
    ** 002 OM  20180307 Added context local awareness of other executing trigger for same record.
    ** 003 AIL 20180821 Switched pendingBatchFields to LinkedList to be aware of
    **                  multiple firing of the same trigger in the same batch.
    ** 004 ECF 20200705 Eliminated resetState to enable performance optimization in caller.
    ** 005 AIL 20201210 Added helper to check if any assign trigger is up for the buffer.
    **         20210104 Updated hasAnyAssignTrigger to take in account a predefined set of properties.
    **     ECF 20210505 Reworked hasAnyAssignTrigger to accept a DmoMeta object.
    **     OM  20220511 Fixed database trigger (WRITE and ASSIGN) regressions.
    **     OM  20221012 The TriggerTracker are bound to DMO, not to the buffer they are contained into.
    ** 006 OM  20230915 Added support for generic session triggers.
    ** 007 CA  20240423 Reduce the context-local lookup for TriggerTracker APIs.
    ** 008 TJD 20240123 Java 17 compatibility updates
    ** 009 SP  20240809 Replaced 'computeIfAbsent' with get/put, to avoid capturing lambda instantiation.
    */

    /*
    ** This program is free software: you can redistribute it and/or modify
    ** it under the terms of the GNU Affero General Public License as
    ** published by the Free Software Foundation, either version 3 of the
    ** License, or (at your option) any later version.
    **
    ** This program is distributed in the hope that it will be useful,
    ** but WITHOUT ANY WARRANTY; without even the implied warranty of
    ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    ** GNU Affero General Public License for more details.
    **
    ** You may find a copy of the GNU Affero GPL version 3 at the following
    ** location: https://www.gnu.org/licenses/agpl-3.0.en.html
    **
    ** Additional terms under GNU Affero GPL version 3 section 7:
    **
    **   Under Section 7 of the GNU Affero GPL version 3, the following additional
    **   terms apply to the works covered under the License.  These additional terms
    **   are non-permissive additional terms allowed under Section 7 of the GNU
    **   Affero GPL version 3 and may not be removed by you.
    **
    **   0. Attribution Requirement.
    **
    **     You must preserve all legal notices or author attributions in the covered
    **     work or Appropriate Legal Notices displayed by works containing the covered
    **     work.  You may not remove from the covered work any author or developer
    **     credit already included within the covered work.
    **
    **   1. No License To Use Trademarks.
    **
    **     This license does not grant any license or rights to use the trademarks
    **     Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
    **     of Golden Code Development Corporation. You are not authorized to use the
    **     name Golden Code, FWD, or the names of any author or contributor, for
    **     publicity purposes without written authorization.
    **
    **   2. No Misrepresentation of Affiliation.
    **
    **     You may not represent yourself as Golden Code Development Corporation or FWD.
    **
    **     You may not represent yourself for publicity purposes as associated with
    **     Golden Code Development Corporation, FWD, or any author or contributor to
    **     the covered work, without written authorization.
    **
    **   3. No Misrepresentation of Source or Origin.
    **
    **     You may not represent the covered work as solely your work.  All modified
    **     versions of the covered work must be marked in a reasonable way to make it
    **     clear that the modified work is not originating from Golden Code Development
    **     Corporation or FWD.  All modified versions must contain the notices of
    **     attribution required in this license.
    */

    package com.goldencode.p2j.persist.trigger;

    import java.util.*;
    import com.goldencode.p2j.persist.*;
    import com.goldencode.p2j.persist.Record;
    import com.goldencode.p2j.persist.orm.*;
    import com.goldencode.p2j.security.*;
    import com.goldencode.p2j.util.*;
    import org.apache.commons.lang3.tuple.*;

    /**
     * A helper class used by {@link RecordBuffer} to help manage information about database triggers.
     * This includes determining whether triggers of various types are registered for the buffer at
     * any given block, and tracking information about the execution and nesting of triggers.
     * <p>
     * Although the {@link DatabaseTriggerManager} manages much of the same information, it does so in
     * the context of determining whether or not to fire a given trigger, when such an event has been
     * requested. However, <code>RecordBuffer</code> must do some (in some cases non-trivial) setup
     * work in order to fire a trigger. This class provides a fast way to determine whether this work
     * is necessary (i.e., a trigger is registered in the current block), or can be avoided.
     * <p>
     * In order to properly prevent illegal nesting of trigger execution, this class provides a
     * mechanism to track which triggers currently are executing for the associated buffer. The
     * <code>RecordBuffer</code> class still does the "heavy lifting" of enforcing the rules, but the
     * data about trigger execution is managed here. This includes maintaining a stack of trigger
     * types currently executing, the names of fields currently involved in assign trigger execution,
     * and the names of fields whose assign triggers are currently deferred until the end of a batch
     * assign bracket.
     * <p>
     * Write triggers must be able to pass a DMO to the trigger procedure or block, which represents
     * a snapshot of the current record before the first field update was made. This snapshot is
     * created by <code>RecordBuffer</code>, but stored here for use when the trigger is fired. The
     * block depth at which that snapshot was created is stored as well, so that the snapshot can be
     * deleted in the event the block at that level is undone/rolled back.
     */
    public final class TriggerTracker
    {
       /** Constant indicating trigger enable status has not been checked */
       private static final int UNCHECKED = 0x0000;

       /** Database trigger manager */
       private final DatabaseTriggerManager manager;

       /** Bit field indicating enable and check state for all trigger types for current scope */
       private int state = UNCHECKED;

       /** The context's scope transitions count at which this object's state was last reset */
       private long lastScopeTransition = -1L;

       /** Class of record buffer using this helper */
       private final Class<? extends Buffer> bufferClass;

       /** Buffer manager for the current context */
       private final BufferManager bufferManager;

       /** DMO representing old buffer state for a write trigger */
       private Record oldDMO;

       /** Block level at which old DMO data was copied (when first assignment took place) */
       private int oldDMODepth;

       /** Map of property names to enable states for assign triggers */
       private Map<String, Boolean> assignTriggers = null;

       /** Stack of names of fields for which assign triggers currently are executing */
       private Deque<String> fieldStack = null;

       /** The list of names and old value pairs of fields assigned in batch pending assign trigger events. */
       private LinkedList<Pair<String, BaseDataType>> pendingBatchFields = null;

       /** The number of buffers connected to this tracker. */
       private int bufferCounter = 0;

       /** Context-local set of trackers mapped by the DMO.*/
       private static final ContextLocal<HashMap<Object, TriggerTracker>> ctxMap =
             new ContextLocal<HashMap<Object, TriggerTracker>>()
             {
                protected HashMap<Object, TriggerTracker> initialValue()
                {
                   return new HashMap<>();
                }
             };

       /**
        * Get the trigger map for this context.
        *
        * @return   See above.
        */
       public static Map<Object, TriggerTracker> getTriggerMap()
       {
          return ctxMap.get();
       }

       /**
        * Obtains a tracker. If a tracker for this {@code dmo} exists it is returned. Otherwise, one is created
        * using the other parameters. Each time this method is invoked the {@code bufferCounter} is incremented
        * for the returned tracker. Use {@code releaseTracker()} to allow the data structure to be cleaned.
        *
        * @param   triggerMap
        *          The trigger map for this context.
        * @param   dmo
        *          The DMO that is to be tracked.
        * @param   bufferClass
        *          The DMO's buffer class.
        * @param   bm
        *          The DMO's buffer manager.
        * @param   manager
        *          The database trigger manager.
        *
        * @return  A {@code TriggerTracker} to be used in a buffer for the specified DMO.
        */
       public static TriggerTracker getTracker(Map<Object, TriggerTracker> triggerMap,
                                               Object dmo,
                                               Class<? extends Buffer> bufferClass,
                                               BufferManager bm,
                                               DatabaseTriggerManager manager)
       {
          if (dmo == null)
          {
             return null;
          }

          TriggerTracker tracker = triggerMap.get(dmo);
          if (tracker == null)
          {
             tracker = new TriggerTracker(bufferClass, bm, manager);
             triggerMap.put(dmo, tracker);
          }

          ++ tracker.bufferCounter;
          return tracker;
       }

       /**
        * Releases the connection with a tracker. If this is the last buffer which released the tracker it is
        * removed and garbage collected later.
        *
        * @param   triggerMap
        *          The trigger map for this context.
        * @param   dmo
        *          The tracked DMO.
        */
       public static void releaseTracker(Map<Object, TriggerTracker> triggerMap, Object dmo)
       {
          if (dmo == null)
          {
             return; // no-op
          }

          TriggerTracker tracker = triggerMap.get(dmo);
          if (tracker == null)
          {
             return; // imbalance! Maybe message something?
          }

          if (--tracker.bufferCounter == 0)
          {
             triggerMap.remove(dmo);
          }
       }

       /**
        * Private constructor. Use {@code getTracker()} factory method to obtain a tracker specific for a DMO.
        *
        * @param   bufferClass
        *          Class of record buffer using this helper.
        * @param   bufferManager
        *          Buffer manager for the current context.
        * @param   manager
        *          The database trigger manager.
        */
       private TriggerTracker(Class<? extends Buffer> bufferClass,
                              BufferManager bufferManager,
                              DatabaseTriggerManager manager)
       {
          this.manager = manager;
          this.bufferClass = bufferClass;
          this.bufferManager = bufferManager;
          resetOldDMO();
       }

       /**
        * Indicate whether a trigger of the given event type is enabled for the associated buffer
        * (and property, in the case of an assign trigger), in the current scope. Caches information
        * to minimize lookups in the trigger manager.
        *
        * @param   det
        *          Database event type.
        * @param   property
        *          Property name, in the case of an assign trigger (not used for other trigger types
        *          and may be {@code null}.
        * @param   database
        *          The database where the buffer reside.
        *
        * @return  {@code true} if a trigger of the given type is enabled, else {@code false}.
        */
       public boolean isTriggerEnabled(DatabaseEventType det, String property, String database)
       {
          int enabledMask = det.getMask();
          int checkedMask = enabledMask << 8;

          // reset the enable and check state for all trigger types for the current scope, if it is a
          // different scope than the last time we reset this state
          long scopeTransitions = bufferManager.getScopeTransitions();
          if (lastScopeTransition != scopeTransitions)
          {
             lastScopeTransition = scopeTransitions;
             state = UNCHECKED;
          }

          // assign triggers require per-property handling, but can be disabled en masse
          if (DatabaseEventType.ASSIGN.equals(det))
          {
             if (!areAssignTriggersEnabled())
             {
                return false;
             }

             Boolean enabled = assignTriggers.get(property);
             if (enabled == null)
             {
                enabled = manager.isAssignTrigger(bufferClass, property, database);
                assignTriggers.put(property, enabled);
             }

             return enabled;
          }

          // all other trigger types
          if ((state & checkedMask) == UNCHECKED)
          {
             // query state
             if (manager.isTriggerEnabled(det, bufferClass, database))
             {
                state |= enabledMask;
             }

             // remember that state has been checked in this scope
             state |= checkedMask;
          }

          return ((state & enabledMask) == enabledMask);
       }

       /**
        * Check if there are any assign triggers up for this buffer.
        *
        * @param   dmoMeta
        *          The metadata object containing the names of properties for which assign triggers should be
        *          checked.
        * @param   database
        *          The database where the buffer reside. If {@code null} try to autodetect using the buffer DMO.
        *
        * @return  {@code true} if there is any enabled assign trigger.
        */
       public boolean hasAnyAssignTrigger(DmoMeta dmoMeta, String database)
       {
          if (!areAssignTriggersEnabled())
          {
             return false;
          }

          Iterator<Property> iter = dmoMeta.getFields(false);
          while (iter.hasNext())
          {
             String prop = iter.next().name;
             Boolean enabled = assignTriggers.get(prop);
             if (enabled == null)
             {
                enabled = manager.isAssignTrigger(bufferClass, prop, database);
                assignTriggers.put(prop, enabled);
             }

             if (enabled)
             {
                return true;
             }
          }

          return false;
       }

       /**
        * Push a trigger type onto the stack of trigger types currently executing for the record from
        * the associated record. This should be done just before the trigger of this type is fired.
        *
        * @param   eventType
        *          Event type of the trigger about to be fired.
        * @param   id
        *          The id (rowid) of the record.
        *
        * @return  {@code true} if the operation succeeded and {@code false} if the trigger is already
        *          in progress for respective record or {@code eventType == ASSIGN}.
        */
       public boolean pushEvent(DatabaseEventType eventType, Long id)
       {
          Deque<DatabaseEventType> activeTriggersSet =
             manager.getExecutingTriggerStack(bufferClass, id, true);

          // if there is already a trigger in progress, false will be returned
          if (activeTriggersSet == null || activeTriggersSet.contains(eventType))
          {
             return false;
          }

          activeTriggersSet.push(eventType);

          return true;
       }

       /**
        * Pop the most recent trigger type executed from the stack of trigger types currently
        * executing for the record in the associated record. This should be done unconditionally
        * (i.e., in a {@code finally}, immediately after the trigger is fired.
        *
        * @param   id
        *          The id (rowid) of the record.
        *
        * @return  The trigger type popped from the top of the stack. If the method returns
        *          {@code null} then there is a programming error (unbalanced push/pop operations).
        */
       public DatabaseEventType popEvent(Long id)
       {
          Deque<DatabaseEventType> activeTriggersSet =
                manager.getExecutingTriggerStack(bufferClass, id, false);

          if (activeTriggersSet == null || activeTriggersSet.isEmpty())
          {
             return null;
          }

          DatabaseEventType ret = activeTriggersSet.pop();

          // do the cleanup before returning
          if (activeTriggersSet.isEmpty())
          {
             manager.removeExecutingTriggerStack(bufferClass, id);
          }

          return ret;
       }

       /**
        * Peek the most recent trigger type executed from the stack of trigger types currently
        * executing for the record from the associated buffer. This is safe to call if there is no
        * trigger currently executing (will return {@code null}).
        *
        * @param   id
        *          The id (rowid) of the record.
        *
        * @return  The trigger type most recently executed, or {@code null} if none.
        */
       public DatabaseEventType peekEvent(Long id)
       {
          Deque<DatabaseEventType> activeTriggersSet =
                manager.getExecutingTriggerStack(bufferClass, id, false);

          if (activeTriggersSet == null || activeTriggersSet.isEmpty())
          {
             return null;
          }

          return activeTriggersSet.peek();
       }

       /**
        * Determine whether any trigger of the specified type currently is executing, in order to
        * allow the enforcement of recursion limitations for certain combinations of trigger types.
        *
        * @param   eventType
        *          Trigger type to test.
        *
        * @return  {@code true} if a trigger of the current type is executing for the record from
        *          the associated buffer (at any level), else {@code false}.
        */
       public boolean isExecuting(DatabaseEventType eventType, Long id)
       {
          Deque<DatabaseEventType> activeTriggersSet =
                manager.getExecutingTriggerStack(bufferClass, id, false);

          if (activeTriggersSet == null || activeTriggersSet.isEmpty())
          {
             return false;
          }

          return activeTriggersSet.contains(eventType);
       }

       /**
        * Push a field (DMO property) name for an assign trigger about to be executed, onto the stack
        * of field names of assign triggers currently executing for the associated record buffer.
        *
        * @param   field
        *          DMO property name associated with an assign trigger.
        */
       public void pushField(String field)
       {
          if (fieldStack == null)
          {
             fieldStack = new ArrayDeque<>();
          }
          fieldStack.push(field);
       }

       /**
        * Pop the field (DMO property) name of the assign trigger most recently fired from the stack
        * of field names of assign triggers currently executing for the associated record buffer.
        * This should be done unconditionally (i.e., in a <code>finally</code> block), immediately
        * after the trigger is fired.
        *
        * @return  The DMO property name popped from the top of the stack.
        *
        * @throws  NoSuchElementException
        *          if the stack is empty (indicates a programming error).
        */
       public String popField()
       {
          return fieldStack.pop();
       }

       /**
        * Determine whether the specified field (DMO property) name represents an assign trigger which
        * currently is executing for the associated buffer, in order to allow the enforcement of
        * recursion limitations.
        *
        * @param   field
        *          DMO property name associated with an assign trigger.
        *
        * @return  <code>true</code> if an assign trigger for the given field is executing for the
        *          associated record buffer (at any level), else <code>false</code>.
        */
       public boolean isFieldInTrigger(String field)
       {
          return (fieldStack != null && fieldStack.contains(field));
       }

       /**
        * Determine whether the specified field (DMO property) name represents the assign trigger most
        * recently fired for the associated buffer, in order to allow the enforcement of recursion
        * limitations.
        *
        * @param   field
        *          DMO property name associated with an assign trigger.
        *
        * @return  <code>true</code> if an assign trigger for the given field is the most recently
        *          fired assign trigger for the associated record buffer, else <code>false</code>.
        */
       public boolean isFieldCurrentTrigger(String field)
       {
          if (fieldStack == null)
          {
             return false;
          }

          String top = fieldStack.peek();

          return field.equals(top);
       }

       /**
        * Add a field (DMO property) name to the set of fields whose assign triggers must be deferred
        * until the end of the current batch assign mode.
        *
        * @param   field
        *          DMO property name whose assign trigger is pending.
        * @param   oldVal
        *          The value of the property before the assign operation.
        */
       public void addPendingBatchField(String field, BaseDataType oldVal)
       {
          if (pendingBatchFields == null)
          {
             pendingBatchFields = new LinkedList<>();
          }

          pendingBatchFields.add(Pair.of(field, oldVal));
       }

       /**
        * Get the set of field (DMO property) names whose assign triggers were deferred until the end
        * of the current batch assign mode.
        *
        * @return  Set of DMO property names whose assign triggers are pending, or <code>null</code>,
        *          if none.
        */
       public LinkedList<Pair<String, BaseDataType>> getPendingBatchFields()
       {
          return pendingBatchFields;
       }

       /**
        * Clear the set of field (DMO property) names whose assign triggers were deferred until the
        * end of the current batch assign mode.
        */
       public void clearPendingBatchFields()
       {
          pendingBatchFields = null;
       }

       /**
        * Get a DMO representing a snapshot of the old buffer state for a write trigger.
        *
        * @return  Old DMO, may be <code>null</code>.
        */
       public Record getOldDMO()
       {
          return oldDMO;
       }

       /**
        * Set a DMO representing a snapshot of the old buffer state for a write trigger.
        *
        * @param   oldDMO
        *          Old DMO; must not be <code>null</code>.
        * @param   blockLevel
        *          Block level at which <code>oldDMO</code> was copied from current record.
        *
        * @throws  NullPointerException
        *          if <code>oldDMO</code> is <code>null</code>.
        */
       public void setOldDMO(Record oldDMO, int blockLevel)
       {
          if (oldDMO == null)
          {
             throw new NullPointerException("oldDMO must not be null");
          }

          this.oldDMO = oldDMO;
          this.oldDMODepth = blockLevel;
       }

       /**
        * Clear the snapshot of the most recently captured old buffer state for a write trigger and
        * forget the block level at which it was captured. This is done once the write trigger is
        * fired, or if the field assignments which caused the snapshot to be taken are rolled back.
        */
       public void resetOldDMO()
       {
          oldDMO = null;
          oldDMODepth = -1;
       }

       /**
        * If we are tracking an old DMO snapshot for a write trigger, and its current block or an
        * enclosing block is committed, track it at the depth of the current block's parent.
        *
        * @param   blockDepth
        *          Current block depth of the buffer at the time of commit.
        */
       public void commit(int blockDepth)
       {
          if (blockDepth <= oldDMODepth)
          {
             oldDMODepth = blockDepth - 1;
          }
       }

       /**
        * If we are tracking an old DMO snapshot for a write trigger, and the block at which it is
        * being tracked is undone, reset the snapshot state.
        *
        * @param   blockDepth
        *          Current block depth, which is compared with the block depth at which the snapshot
        *          (if any) is being tracked.
        */
       public void rollback(int blockDepth)
       {
          if (blockDepth == oldDMODepth)
          {
             resetOldDMO();
          }
       }

       /**
        * Check if the assign triggers are enabled, as they can be bulk disabled.
        *
        * @return  {@code true} if they aren't bulk disabled.
        */
       private boolean areAssignTriggersEnabled()
       {
          int enabledMask = DatabaseEventType.ASSIGN.getMask();
          int checkedMask = enabledMask << 8;

          if ((state & checkedMask) == UNCHECKED)
          {
             if (manager.areAssignTriggersEnabled(bufferClass))
             {
                // assign triggers enabled, but we don't yet know which are registered
                state |= enabledMask;
                assignTriggers = new HashMap<>();
             }
             else
             {
                assignTriggers = null;
             }

             // remember we checked enable status for this block
             state |= checkedMask;
          }

          return (state & enabledMask) == enabledMask;
       }
    }