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;
   }
}