DatabaseTriggerManager.java

/*
** Module   : DatabaseTriggerManager.java
** Abstract : Database trigger manager.
**
** Copyright (c) 2009-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- --JPRM-- -----------------------------------Description-----------------------------------
** 001 SVL 20091019   @44152 Created initial version. Supports assign and delete schema triggers.
** 002 CA  20100527   @44879 Added support for CREATE and "no record is 
**                           available triggers". The CREATE, DELETE and 
**                           ASSIGN triggers will execute on undo too, but the
**                           passed DMO will not be the buffer proxy; instead,
**                           it will be the actual record instance.
** 003 SVL 20130213          Added (de)registerDatabaseTrigger and disable*Triggers functions.
** 004 OM  20131107          Rewritten entire class.
** 005 OM  20140107          Removed openReadOnlyScope, added error message for FIND attempts on
**                           read-only buffers created by WRITE events.
** 006 OM  20140711          Rewrote the initialization. The trigger list is read from xml file 
**                           instead of scanning for SchemaTrigger annotation.
** 007 CA  20140729          Fixed problem when invoking a trigger: the caller info (i.e. 
**                           trigger's THIS-PROCEDURE) must be pushed/popped, the same way as 
**                           UI triggers are invoked.
** 008 ECF 20140730          Applied fix similar to 007 to fireAssignTrigger. Added methods to
**                           test the availability and enable status of triggers at the current
**                           block level. Changed logging level to reduce log noise.
** 009 OM  20160428          Added support for multi-project scenario.
** 010 GES 20161006          Updated javadoc to clarify the behavior for a method.
** 011 OM  20180307          Added support for TriggerTracker context local awareness of other
**                           executing trigger for same record.
** 012 SBI 20180518          Renamed file-system/pkgroot into legacy-system/pkgroot.
** 013 CA  20190326          Implemented ProcedureHelper and TransactionHelper instead of direct 
**                           usage of the static API.  This allows the elimination of context  
**                           local usage in ProcedureManager and TransactionManager.
** 014 AIL 20190722          Removed initialization method. Replaced schemaTrigger map and 
**                           st TriggerBlockLayer with getSchemaTrigger and 
**                           getSchemaReplicationTrigger. 
**                           Trigger searching is now made at the runtime.
** 015 ECF 20190725          Mark the read-only "old" buffer created for a write trigger as a
**                           "fake" buffer, so we do not process it like an active buffer.
** 016 AIL 20190805          Replaced getSchemaTrigger WARNINGS into proper errors. Reworked 
**                           cases in which errors arise.
**         20190821          Added support for assign triggers that do not have a reference
**                           to a buffer, basically declared with NEW VALUE.
** 017 CA  20190908          When checking a trigger's table, the comparison must be 
**                           case-insensitive.
** 018 CA  20191119          Track the trigger class, to be able to register any buffer to its 
**                           source definition class.
** 019 ECF 20200906          New ORM implementation.
** 020 ECF 20200916          Use DmoMeta to get schema name instead of DatabaseManager.getSchemaByInterface.
**     CA  20200927          Use IdentityHashMap instead of plain map when the key is a Class.
**     OM  20210512          trigger() return false if the trigger procedure vetoed the event.
**                           Dropped vestigial code related to schema-triggers.xml.
**     OM  20210603          Implemented vetoing mechanism for triggers.
**     OM  20220506          Cleanup execution trigger stack.
**     TW  20220803          Error-status:error must be set for return error in method, while return error
**                           in old-style ABL function must remain the same. Refs: #6567.
**     TW  20220817          Error-status:error must be set for return error in static method.
**     CA  20220901          Refactored scope notification support: ScopeableFactory was removed, and the 
**                           registration is now specific to each type of scopeable.  For each case, the block
**                           will be registered for scope support (for that particular scopeable) only when
**                           the scopeable is 'active' (i.e. unnamed streams or accumulators are used).  This
**                           allows a lazy registration of scopeables, to avoid the unnecessary overhead of
**                           processing all the scopeables for each and every block.
**     TJD 20220504          Java 11 compatibility minor changes
**     CA  20221206          Fixed session triggers created in persistent programs: these are saved and the 
**                           lookup is done in a LIFO order (top of stack down). 
**     CA  20230110          Cache the helper for ProcedureManager, ObjectOps and others (as needed), to
**                           reduce context-local lookup.
** 021 SVL 20230317          Pause registration of off-end queries while we're in a database trigger.
** 022 GBB 20230512          Logging methods replaced by CentralLogger/ConversionStatus.
** 023 OM  20230915          Added support for generic session triggers.
** 024 CA  20231026          A persistent procedure with a pending delete is no longer persistent, so 
**                           scopeDeleted needs to be executed.
** 025 OM  20231125          Redesign generic session triggers.
** 026 AD  20240125          Added support for the 4GL parameter '-trig' when searching for trigger classes.
** 027 DDF 20240423          Modified how the error handling for a missing trigger is done.
**     DDF 20240520          isTriggerEnabled() should not rely on getSchemaTrigger() because it should
**                           only check if it exists in the TableMapper (LegacyTriggerInfo).
** 028 TJD 20240123          Java 17 compatibility updates
** 029 EAB 20240611          Moved a block of code in DmoMetadataManager (the getDmoIface method) to avoid
**                           duplication. 
** 030 AL2 20240628          Open scope of buffer for generic triggers to force its clear-up.
**     AL2 20240701          Reworked solution to open scope only if not already opened by the trigger.
**     AL2 20240701          Slow down logging generic trigger warnings to avoid log spam.
*/

/*
** 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 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 com.goldencode.p2j.util.logging.*;
import java.lang.reflect.*;
import java.util.*;
import java.util.logging.Level;

/**
 * This class handles the management of trigger system for a database. For session triggers, it maintains a
 * stack of scopes (a layer of triggers for each subroutine block).
 * <br>
 * An additional independent virtual level for generic triggers (which are executed first, non-vetoable) is
 * composed of the {@code genericXXXXTriggers} maps. These can be {@code null} if no such triggers were
 * registered.
 * <br>
 * There is a last layer formed by the schema triggers. If defined, they are executed last, and only if not
 * overridden by the session trigger. They are not stored here but the code to be executed is dynamically
 * computed based on annotation declared in DMO and current PROPATH.
 * <p> 
 * One instance of this class is associated with a user context and, because this class implements
 * {@link Scopeable} it is initialized in {@code StandardServer.invoke()}, before the
 * topmost "startup" scope is pushed.
 * The {@code DatabaseTriggerManager} is notified when the execution enters and leaves
 * subroutine blocks, so it can do the management of session trigger layers.
 */
public class DatabaseTriggerManager
implements Scopeable
{
   /** Logger */
   private static final CentralLogger LOG = CentralLogger.get(DatabaseTriggerManager.class);
   
   /** Context local map of instances of this class, mapped by theirs database name . */
   private static final ContextLocal<DatabaseTriggerManager> context =
   new ContextLocal<DatabaseTriggerManager>()
   {
      protected DatabaseTriggerManager initialValue()
      {
         DatabaseTriggerManager local = new DatabaseTriggerManager();
         local.computeTriggerSearchPaths();
         if (local.trigPath.isPresent() && !local.isTrigAbsolutePath)
         {
            EnvironmentOps.addSourcePathListener((path) -> {
               local.triggerSearchPaths = null;
               local.proceduresClassMap.clear();
            });
         }
         
         return local;
      }
   };
   
   /** 
    * The number of times we attempted to write the log on generic trigger old-buffer 
    * not being handled properly 
    */
   private static int logCluteringProtection = 0;
   
   /** 
    * A rate at which we attempt to slow-down the logs on generic trigger old-buffer 
    * not being handled properly. This is increasing periodically to reduce the number of logs.
    */
   private static int logCluteringThreshold = 1;
   
   /** Helper to use the ProcedureManager without any context local lookups. */
   private final ProcedureManager.ProcedureHelper pm = ProcedureManager.getProcedureHelper();
   
   /** Helper to use the TM without any context local lookups. */
   private final TransactionManager.TransactionHelper tm = TransactionManager.getTransactionHelper();
   
   /** The stack of all layer of triggers handled by this manager. */
   private final Deque<TriggerBlockLayer> stack = new ArrayDeque<>();
   
   /** The mapping of registered triggers for a persistent procedure. */
   private final Map<Object, TriggerBlockLayer> persistentTriggers = new IdentityHashMap<>();
   
   /** The order of the {@link #persistentTriggers}. */
   private final Deque<Object> programs = new ArrayDeque<>();
   
   /** The stack of currently executing triggers in this context for each record id. */
   private final Map<Class<? extends Buffer>, 
                     HashMap<Long, Deque<DatabaseEventType>>> executing = new IdentityHashMap<>();
 
   /** 
    * The paths to search for the trigger procedures, either an absolute path given at startup,
    * or the relative trigger path concatenated to the propath.
    */
   private String[] triggerSearchPaths = null;

   /** The trig parameter path specified by the client, with absolute prefix removed if necessary. */
   private Optional<String> trigPath = null;
   
   /** Indicates whether the client trigger path is an absolute path or not. */
   private boolean isTrigAbsolutePath;
   
   /** Procedure name to java class map. */
   private final Map<String, Class<?>> proceduresClassMap = new HashMap<>();
   
   /** The data for FIND generic triggers mapped by database. */
   private Map<String, TriggerData> genericFindTriggers = null;
   
   /** The data for CREATE generic triggers mapped by database. */
   private Map<String, TriggerData> genericCreateTriggers = null;
   
   /** The data for WRITE generic triggers mapped by database. */
   private Map<String, TriggerData> genericWriteTriggers = null;
   
   /** The data for DELETE generic triggers mapped by database. */
   private Map<String, TriggerData> genericDeleteTriggers = null;
   
   /** The data for ASSIGN generic triggers mapped by database and then by property name. */
   private Map<String, Map<String, TriggerData>> genericAssignTriggers = null;
   
   /**
    * Private constructor. An instance of this class can be obtained only using {@code get()} method.
    * Registers this object with TransactionManager as a Scopeable instance.
    */
   private DatabaseTriggerManager()
   {
      if (LOG.isLoggable(Level.FINE))
      {
         LOG.log(Level.FINE, "DatabaseTriggerManager registered with TransactionManager.");
      }
   }
   /**
    * Compute the locations where the search for the trigger procedures will happen depending on
    * the trig path value. If the trig path is an absolute path, the search happens only in it.
    * If the trig path is relative, it is concatenated to the end of every propath path, so the
    * search is done in this modified propath.
    */
   private void computeTriggerSearchPaths()
   {
      if (triggerSearchPaths != null || (trigPath != null && !trigPath.isPresent()))
      {
         return;
      }
      
      if (trigPath == null)
      {
         String trigLocation = SessionUtils._startupParameters().getTriggerLocation();
         trigPath = Optional.ofNullable(trigLocation);
         if (!trigPath.isPresent())
         {
            return;
         }
      }
      
      String absolutePrefix = SourceNameMapper.getAbsolutePrefix(trigPath.get());
      isTrigAbsolutePath = absolutePrefix != null;
      // use the trig parameter path as an absolute path
      if (isTrigAbsolutePath)
      {
         trigPath = Optional.ofNullable(SourceNameMapper.removeAbsolutePrefix(trigPath.get(), absolutePrefix));
         if (!trigPath.isPresent())
         {
            return;
         }
         
         String token = Utils.getProjectToken();
         if (token != null)
         {
            triggerSearchPaths = new String[2];
            triggerSearchPaths[1] = token + SourceNameMapper.getFileSep() + trigPath;               
         }
         else
         {
            triggerSearchPaths = new String[1];
         }
         triggerSearchPaths[0] = trigPath.get();
      }
      else 
      {
         String[] propath = SourceNameMapper.getPropath();
         triggerSearchPaths = new String[propath.length + 1];
         triggerSearchPaths[0] = "." + SourceNameMapper.getFileSep() + trigPath;
         for (int i = 1; i < triggerSearchPaths.length; i++)
         {
            triggerSearchPaths[i] = propath[i - 1] + SourceNameMapper.getFileSep() + trigPath;
         }
      }
   }
   
   /**
    * Obtain the container that holds information for instantiating a schema trigger object
    * responsible for a {@code det} event. If such trigger does not exist, or the procedure
    * defined is invalid, a null object is returned.
    *
    * @param   det
    *          The database event to which the trigger responds to.
    * @param   bufferCls
    *          The class that defines the table for which the trigger applies to.
    * @param   property
    *          Optionally, for {@code ASSIGN} triggers the name of the property (field) for which the
    *          trigger was declared. Otherwise, it is ignored.
    *
    * @return  The container that holds information of how to create an instance of the specified
    *          trigger and how to apply it or null if such schema trigger has not been declared.
    */
   static TriggerData getSchemaTrigger(DatabaseEventType det,
                                       Class<? extends Buffer> bufferCls,
                                       String property)
   {   
      // search for the DMO interface of the bufferCls
      Class<? extends DataModelObject> dmoIface = DmoMetadataManager.getDmoIface(bufferCls);
      
      if (dmoIface == null)
      {
         // no suitable DMO interface was found for the given bufferCls
         return null;
      }
      
      // identify the trigger procedure meant to be executed
      String procedure = TableMapper.getLegacyTriggerProcedure(dmoIface, det.toString(), property);
      if (procedure == null)
      {
         // no procedure for the det trigger at the DMO's LegacyTrigger annotation 
         return null;
      }
      
      DatabaseTriggerManager local = get();
      Class<?> cls = local.proceduresClassMap.get(procedure);
  
      if (cls == null)
      {
         String javaClassName = null;
         
         if (SourceNameMapper.getAbsolutePrefix(procedure) != null)
         {
            javaClassName = SourceNameMapper.convertNameToClass(procedure, new String[]{"."});
         }
         
         if (javaClassName == null)
         {
            // in 4GL, even if -trig is specified, if the 'procedure' file path starts with one of these 
            // prefixes, then is 'like' an absolute path and -trig parameter is ignored in this case.
            // however, if the procedure path contains one of the prefixes but does not start with it,
            // -trig parameter will be used
            String[] prefixes = new String[] {"./", ".\\", "../", "..\\"};
            for (int i = 0; i < prefixes.length; i++)
            {
               if (procedure.startsWith(prefixes[i]))
               {
                  javaClassName = SourceNameMapper.convertNameToClass(procedure, new String[]{"."});
                  break;
               }
            }
         }
         
         if (javaClassName == null)
         {
            if (local.triggerSearchPaths == null)
            {
               local.computeTriggerSearchPaths();
            }
            
            if (local.triggerSearchPaths != null)
            {
               javaClassName = SourceNameMapper.convertNameToClass(procedure, local.triggerSearchPaths);
            }
            else 
            {
               javaClassName = SourceNameMapper.convertNameToClass(procedure, false);
            }        
         }
         
         if (javaClassName == null)
         {
            String pathNotFound = (local.trigPath == null || !local.trigPath.isPresent()) ? procedure :
               local.trigPath + SourceNameMapper.getFileSep() + procedure;  
            
            // there is no conversion for the file provided. 
            ErrorManager.showErrorAndAbend(293, 
                                           String.format("\"%s\" was not found", pathNotFound),
                                           true);
            return null;
         } 
         
         try
         {
            cls = Class.forName(javaClassName);
            local.proceduresClassMap.put(procedure, cls);
         }
         catch (ClassNotFoundException e)
         {
            // this should not happen
            // the SourceNameMapper.convertNameToClass returns a valid Java Class name
            if (LOG.isLoggable(Level.SEVERE))
            {
               LOG.log(Level.SEVERE, 
                  String.format("The name of the Java Class for %s is not valid", procedure));
            }
            return null;
         }
      }
      
      if (!(cls.getSuperclass().equals(DatabaseTrigger.class)))
      {
         // the target procedure is not a trigger procedure
         if (det.equals(DatabaseEventType.ASSIGN))
         {
            ErrorManager.recordOrThrowError(
                  3248,
                  String.format("Cannot run %s for a FIND, DELETE, or CREATE trigger", procedure),
                  false);
         }
         else
         {
            ErrorManager.recordOrThrowError(
                  3250,
                  String.format("Cannot run %s for an ASSIGN trigger", procedure),
                  false);
         }
         return null;
      }
      
      @SuppressWarnings({ "unchecked" /*, "rawtypes"*/ })
      Class<? extends DatabaseTrigger<Buffer>> triggerCls = (Class<? extends DatabaseTrigger<Buffer>>) cls;
      SchemaTrigger schemaTriggerIface = triggerCls.getAnnotation(SchemaTrigger.class);
      
      if (schemaTriggerIface == null)
      {
         // this should not happen; the conversion should annotate a DatabaseTrigger with SchemaTrigger
         if (LOG.isLoggable(Level.SEVERE))
         {
            LOG.log(Level.SEVERE,
                    "%s should be converted in a DatabaseTrigger with SchemaTrigger annotation",
                    procedure);
         }
         return null;
      }
      
      boolean hasNewBuffer = !schemaTriggerIface.buffer().equals(EmptyBuffer.class);
      
      String tableTrg = TableMapper.getLegacySchemaName(dmoIface);
      if (!schemaTriggerIface.table().equalsIgnoreCase(tableTrg) && hasNewBuffer)
      {
         // the schema trigger event is not for the same table as the trigger procedure
         ErrorManager.recordOrThrowError(
               3240,
               String.format("Trigger for table %s executing %s which is FOR %s table",
                             tableTrg,
                             procedure,
                             schemaTriggerIface.table()),
               false);
         return null;
      }
      
      // the event type should be almost the same: either both of them are FIND/CREATE/DELETE
      // triggers or both of them are ASSIGN triggers.
      if (!schemaTriggerIface.event().equals(DatabaseEventType.ASSIGN) && 
          det.equals(DatabaseEventType.ASSIGN))
      {
         ErrorManager.recordOrThrowError(
            new int[] {3245, 3250}, 
            new String[] {
               String.format("%s is a FIND, DELETE, or CREATE trigger procedure", procedure),
               String.format("Cannot run %s for an ASSIGN trigger", procedure)
            }, 
            false);
         return null;
      }
      
      if (schemaTriggerIface.event().equals(DatabaseEventType.ASSIGN) && 
          !det.equals(DatabaseEventType.ASSIGN))
      {
         ErrorManager.recordOrThrowError(
               new int[] {3247, 3248}, 
               new String[] {
                  String.format("%s is an ASSIGN trigger procedure", procedure),
                  String.format("Cannot run %s for a FIND, DELETE, or CREATE trigger", procedure)
               },
               false);
         return null;
      }
      
      boolean overrideable = TableMapper.isLegacyTriggerOverrideable(dmoIface, det.toString(), property);
      boolean hasOldBuffer = schemaTriggerIface.hasOldBuffer();
      return new TriggerData(triggerCls, null, overrideable, hasOldBuffer, hasNewBuffer, false);
   }
   
   /**
    * Obtain the container that hold information for instantiating a schema trigger object for 
    * the replication version of the {@code det} event. This call is only valid for events
    * that have replication equivalent. If these conditions are not met or no such replication 
    * trigger was defined, then a null object is returned.
    *
    * @param   det
    *          The database event whose replication equivalent trigger responds to.
    * @param   bufferCls
    *          The buffer class for the dmo that fired the trigger.
    *
    * @return  The container that holds information of how to create an instance of the
    *          replication schema trigger.
   */
   static TriggerData getReplicationSchemaTrigger(DatabaseEventType det, Class<? extends Buffer> bufferCls)
   {
      // check if the requested det has a replication event, otherwise return null
      switch (det)
      {
         case CREATE:
            det = DatabaseEventType.REPLICATION_CREATE;
            break;
         case DELETE:
            det = DatabaseEventType.REPLICATION_DELETE;
            break;
         case WRITE:
            det = DatabaseEventType.REPLICATION_WRITE;
            break;
         default:
            return null;
      }
      
      // now det should be the replication event we need to look for
      return getSchemaTrigger(det, bufferCls, null);
   }
   
   /**
    * Obtain the context-local DatabaseTriggerManager object.
    *
    * @return  the DatabaseTriggerManager for current context.
    */
   public static DatabaseTriggerManager get()
   {
      return context.get();
   }
   
   /**
    * Register a session database trigger within the current scope. Any previously
    * registered session trigger on current block is silently removed. This overloaded method is
    * always not overriding the schema trigger and is not declaring an {@code OLD [BUFFER]}.
    *
    * @param    event
    *           Database event type.
    * @param    bufferCls
    *           The class of the buffer to which the event is applied.
    * @param    triggerCls
    *           The class of the trigger.
    * @param    container
    *           The containing object (all triggers are implemented as non-static inner classes,
    *           so they must have a reference to the containing class).
    */
   public static void registerDatabaseTrigger(DatabaseEventType event,
                                              Class<? extends Buffer> bufferCls,
                                              Class<? extends DatabaseTrigger<? extends Buffer>> triggerCls,
                                              Object container)
   {
      registerDatabaseTrigger(event, bufferCls, triggerCls, container, false, false);
   }
   
   /**
    * Register a session database trigger within the current scope. Any previously
    * registered session trigger on current block is silently removed. This overloaded method
    * not declaring an {@code OLD [BUFFER]}.
    *
    * @param    event
    *           Database event type.
    * @param    bufferCls
    *           The class of the buffer to which the event is applied.
    * @param    triggerCls
    *           The class of the trigger.
    * @param    container
    *           The containing object (all triggers are implemented as non-static inner classes,
    *           so they must have a reference to the containing class).
    * @param    override
    *           {@code true} if {@code OVERRIDE} keyword is specified.
    */
   public static void registerDatabaseTrigger(DatabaseEventType event,
                                              Class<? extends Buffer> bufferCls,
                                              Class<? extends DatabaseTrigger<? extends Buffer>> triggerCls,
                                              Object container,
                                              boolean override)
   {
      registerDatabaseTrigger(event, bufferCls, triggerCls, container, override, false);
   }
   
   /**
    * Register a session database trigger within the current scope. Any previously
    * registered session trigger on current block is silently removed.
    *
    * @param   event
    *          Database event type.
    * @param   bufferCls
    *          The class of the buffer to which the event is applied.
    * @param   triggerCls
    *          The class of the trigger.
    * @param   container
    *          The containing object (all triggers are implemented as non-static inner classes,
    *          so they must have a reference to the containing class).
    * @param   override
    *          {@code true} if {@code OVERRIDE} keyword is specified.
    * @param   hasOldBuffer
    *          {@code true} if an {@code OLD [BUFFER]} keyword is specified.
    */
   public static void registerDatabaseTrigger(DatabaseEventType event,
                                              Class<? extends Buffer> bufferCls,
                                              Class<? extends DatabaseTrigger<? extends Buffer>> triggerCls,
                                              Object container,
                                              boolean override,
                                              boolean hasOldBuffer)
   {
      DatabaseTriggerManager manager = get();
      manager.tm.registerTopLevelScopeable(manager);
      
      if (manager.stack.isEmpty())
      {
         // sanity check failed
         return;
      }
      
      // check if this event is overrideable
      if (override && !isSchemaTriggerOverrideable(event, bufferCls))
      {
         ErrorManager.recordOrThrowError(3226,
            "Attempt to OVERRIDE a schema " + event.toString().toLowerCase() + " trigger");
         return;
      }
      
      TriggerBlockLayer tbl = manager.stack.peekFirst();
      tbl.setTrigger(triggerCls, event, bufferCls, null, container, override, hasOldBuffer);
   }
   
   /**
    * Register an {@code ASSIGN} session database trigger within the current scope. Any
    * previously registered session trigger on current block is silently removed. This overloaded
    * method is always not overriding the schema trigger and is not declaring an {@code OLD [VALUE]}.
    *
    * @param    event
    *           Database event type.
    * @param    bufferCls
    *           The class of the buffer to which the event is applied.
    * @param    triggerCls
    *           The class of the trigger.
    * @param    container
    *           The containing object (all triggers are implemented as non-static inner classes,
    *           so they must have a reference to the containing class).
    */
   public static void registerDatabaseTrigger(DatabaseEventType event, // always ASSIGN
                                              Class<? extends Buffer> bufferCls,
                                              String property,
                                              Class<? extends DatabaseTrigger<? extends Buffer>> triggerCls,
                                              Object container)
   {
      registerDatabaseTrigger(event, bufferCls, property, triggerCls, container, false, false);
   }
   
   /**
    * Register an {@code ASSIGN} session database trigger within the current scope. Any
    * previously registered session trigger on current block is silently removed. This overloaded
    * method is not declaring an {@code OLD [VALUE]}.
    *
    * @param    event
    *           Database event type.
    * @param    bufferCls
    *           The class of the buffer to which the event is applied.
    * @param    triggerCls
    *           The class of the trigger.
    * @param    container
    *           The containing object (all triggers are implemented as non-static inner classes,
    *           so they must have a reference to the containing class).
    * @param    override
    *           {@code true} if {@code OVERRIDE} keyword is specified.
    */
   public static void registerDatabaseTrigger(DatabaseEventType event, // always ASSIGN
                                              Class<? extends Buffer> bufferCls,
                                              String property,
                                              Class<? extends DatabaseTrigger<? extends Buffer>> triggerCls,
                                              Object container,
                                              boolean override)
   {
      registerDatabaseTrigger(event, bufferCls, property, triggerCls, container, override, false);
   }
   
   /**
    * Register an {@code ASSIGN} session database trigger within the current scope. Any previously
    * registered session trigger on current block is silently removed.
    *
    * @param    event
    *           Database event type.
    * @param    bufferCls
    *           The class of the buffer to which the event is applied.
    * @param    triggerCls
    *           The class of the trigger.
    * @param    container
    *           The containing object (all triggers are implemented as non-static inner classes,
    *           so they must have a reference to the containing class).
    * @param    override
    *           {@code true} if {@code OVERRIDE} keyword is specified.
    */
   public static void registerDatabaseTrigger(DatabaseEventType event, // always ASSIGN
                                              Class<? extends Buffer> bufferCls,
                                              String property,
                                              Class<? extends DatabaseTrigger<? extends Buffer>> triggerCls,
                                              Object container,
                                              boolean override,
                                              boolean hasOldValue)
   {
      // the first parameter is always DatabaseEventType.ASSIGN, so it can be dropped from the
      // list of parameters for this kind of event, but we keep it to have consistency
      // with invocation for other events.
      if (event != DatabaseEventType.ASSIGN)
      {
         throw new IllegalArgumentException("Use this form of register only for ASSIGN triggers.");
      }
      DatabaseTriggerManager manager = get();
      manager.tm.registerTopLevelScopeable(manager);
      
      if (manager.stack.isEmpty())
      {
         // sanity check failed
         return;
      }
      
      // check if this event is overrideable
      if (override && !isSchemaTriggerOverrideable(DatabaseEventType.ASSIGN, bufferCls))
      {
         ErrorManager.recordOrThrowError(3226, "Attempt to OVERRIDE a schema assign trigger");
         
         return;
      }
      
      TriggerBlockLayer tbl = manager.stack.peekFirst();
      tbl.setTrigger(triggerCls, event, bufferCls, property, container, override, hasOldValue);
   }
   
   /**
    * Unregister a session database trigger for the given event type and buffer class.
    * This is only called when a {@code ON Assign OF <table.field> REVERT}.
    *
    * @param   event
    *          Database event type.
    * @param   bufferCls
    *          The class of the buffer to which the event is applied.
    * @param   property
    *          The property that is deregistered from {@code ASSIGN} trigger.
    */
   public static void deregisterDatabaseTrigger(DatabaseEventType event,
                                                Class<? extends Buffer> bufferCls,
                                                String property)
   {
      // the first parameter is always DatabaseEventType.ASSIGN, so it can be dropped from the
      // list of parameters for this kind of event, but we keep it to have consistency
      // with invocation for other events.
      
      if (event != DatabaseEventType.ASSIGN)
      {
         throw new IllegalArgumentException("Use this form of register only for ASSIGN triggers.");
      }
      
      DatabaseTriggerManager manager = get();
      manager.tm.registerTopLevelScopeable(manager);
      
      if (manager.stack.isEmpty())
      {
         // sanity check failed
         return;
      }
      
      TriggerBlockLayer tbl = manager.stack.peekFirst();
      tbl.removeTrigger(event, bufferCls, property);
   }
   
   /**
    * Unregister a session database trigger for the given event type and buffer class.
    * This is only called when a {@code ON <event> OF <table> REVERT}.
    *
    * @param    event
    *           Database event type.
    * @param    bufferCls
    *           The class of the buffer to which the event is applied.
    */
   public static void deregisterDatabaseTrigger(DatabaseEventType event,
                                                Class<? extends Buffer> bufferCls)
   {
      // the first parameter is always DatabaseEventType.ASSIGN, so it can be dropped from the
      // list of parameters for this kind of event, but we keep it to have consistency
      // with invocation for other events.
      
      if (event == DatabaseEventType.ASSIGN)
      {
         throw new IllegalArgumentException("Not executing ASSIGN with this method.");
      }
      
      DatabaseTriggerManager manager = get();
      manager.tm.registerTopLevelScopeable(manager);
      
      if (manager.stack.isEmpty())
      {
         // sanity check failed
         return;
      }
      
      TriggerBlockLayer tbl = manager.stack.peekFirst();
      tbl.removeTrigger(event, bufferCls, null);
   }
   
   /**
    * Disables dump triggers ({@code FIND} events) for the current block and any block of a
    * callee from this block (function, internal/external procedure).
    *
    * @param   bufferCls
    *          The buffer for which to disable dump triggers.
    *
    * @return  {@code true} if the filter was successfully applied. If {@code false}
    *          is returned then the internal stack is broken because of bad block management.
    */
   public static boolean disableDumpTriggers(Class<? extends Buffer> bufferCls)
   {
      DatabaseTriggerManager manager = get();
      manager.tm.registerTopLevelScopeable(manager);
      
      if (manager.stack.isEmpty())
      {
         return false;
      }
      
      TriggerBlockLayer tbl = manager.stack.peekFirst();
      tbl.disableTriggers(bufferCls, true, false, false);
      return true;
   }
   
   /**
    * Disables load triggers ({@code CREATE},{@code ASSIGN}, {@code WRITE} events)
    * for the current block. The replication triggers are also disabled.
    *
    * @param   bufferCls
    *          The buffer for which to disable load triggers.
    *
    * @return  {@code true} if the filter was successfully applied. If {@code false}
    *          is returned then the internal stack is broken because of bad block management.
    */
   public static boolean disableLoadTriggers(Class<? extends Buffer> bufferCls)
   {
      return disableLoadTriggers(bufferCls, false);
   }
   
   /**
    * Disables load triggers ({@code CREATE}, {@code ASSIGN}, {@code WRITE} events) for the current block.
    *
    * @param   bufferCls
    *          The buffer for which to disable load triggers.
    * @param   allowReplication
    *          If {@code true} the replication triggers will not be disabled for this block.
    *
    * @return  {@code true} if the filter was successfully applied. If {@code false}
    *          is returned then the internal stack is broken because of bad block management.
    */
   public static boolean disableLoadTriggers(Class<? extends Buffer> bufferCls,
                                             boolean allowReplication)
   {
      DatabaseTriggerManager manager = get();
      manager.tm.registerTopLevelScopeable(manager);
      
      if (manager.stack.isEmpty())
      {
         return false;
      }
      
      TriggerBlockLayer tbl = manager.stack.peekFirst();
      tbl.disableTriggers(bufferCls, false, true, allowReplication);
      
      return true;
   }
   
   /**
    * Executes trigger suite for a base event (except for {@code ASSIGN} triggers). The suite includes the
    * session, simple schema and replication triggers that have been defined/assigned for the {@code det} base
    * event. The order in which they execute depends on {@code det}: session triggers are always executed
    * first and replication triggers last, except for the {@code FIND} triggers, where schema triggers
    * execute before session triggers and there is no replication trigger to execute.
    *
    * @param   det
    *          The trigger type to be executed.
    * @param   bufferCls
    *          The buffer class for the dmo that fired the trigger.
    * @param   newVal
    *          The buffer that fired the trigger.
    * @param   oldDmoValue
    *          When the (WRITE) trigger declares an 'old' buffer value, this is passed to trigger procedure as
    *          r/o buffer.
    * @param   database
    *          The database to apply to. In future revisions, this parameter might be replaced by a table
    *          pattern.
    *
    * @return  {@code true} if no trigger was executed, or they executed with success. The method will return
    *          {@code false} if the trigger vetoed the operation (by returning error).
    */
   public static boolean trigger(DatabaseEventType det,
                                 Class<? extends Buffer> bufferCls,
                                 Buffer newVal,
                                 Record oldDmoValue,
                                 String database)
   {
      // TODO: Persistence.load():2029: Object o = query.uniqueResult();
      //       can trigger an uncaught WRITE database event
      
      DatabaseTriggerManager manager = get();
      TriggerBlockLayer.EnableStatus triggerStatus = manager.getTriggerStatus(det, bufferCls);
      if (triggerStatus == TriggerBlockLayer.EnableStatus.DISABLED)
      {
         // triggers are disabled for this kind of event / table
         return true;
      }
      
      // look for generic trigger first and execute it, if found 
      TriggerData genericTrigger = manager.getGenericTrigger(det, bufferCls, database, null);
      if (genericTrigger != null)
      {
         boolean sessOldBuffer = oldDmoValue != null && genericTrigger.hasOldBuffer();
         Buffer oldBuffer = sessOldBuffer
               ? createWriteOldBuffer(genericTrigger.getTriggerClass(),
                                      bufferCls,
                                      oldDmoValue)
               : null;

         guardedExec(() -> fireTrigger(det, genericTrigger, newVal, oldBuffer));
         // NOTE: do not return false if this trigger fails
         if (oldBuffer != null && ((BufferImpl) oldBuffer).buffer().isBufferScopeLeaking())
         {
            RecordBuffer.openScope((DataModelObject) oldBuffer);
            
            logCluteringProtection++;
            if (logCluteringProtection % logCluteringThreshold == 0 && LOG.isLoggable(Level.WARNING))
            {
               String msg = "Old buffer lifecycle is not properly managed by the generic trigger: " +
                        genericTrigger.getTriggerClass() + 
                        ". Consider opening the scope of the old buffer inside the generic " +
                        "trigger to properly manage its lifecycle.";
               LOG.log(Level.WARNING, msg);
               
               if (logCluteringProtection == logCluteringThreshold * 10)
               {
                  logCluteringThreshold *= 10;
                  LOG.log(Level.WARNING, "To avoid log spam, 'generic trigger' logs will be slowed down!");
               }
            }
         }
      }
      
      if (triggerStatus == TriggerBlockLayer.EnableStatus.ENABLED)
      {
         // look for the triggers for this event / bufferCls
         TriggerData sessionTrigger = manager.peekTrigger(det, bufferCls, null);
         TriggerData schemaTrigger = getSchemaTrigger(det, bufferCls, null);
         
         boolean overrideSchemaTriggers = sessionTrigger != null && sessionTrigger.isOverride();
         
         // except for the FIND case, the session trigger executes first (if it ever does)
         if (sessionTrigger != null && det != DatabaseEventType.FIND)
         {
            // each trigger invocation is done using a different instance of the OLD, r/o buffer
            boolean sessOldBuffer = oldDmoValue != null && sessionTrigger.hasOldBuffer();
            Buffer oldBuffer = sessOldBuffer 
                                 ? createWriteOldBuffer(sessionTrigger.getTriggerClass(), 
                                                        bufferCls, 
                                                        oldDmoValue) 
                                 : null;
            if (!guardedExec(() -> fireTrigger(det, sessionTrigger, newVal, oldBuffer)))
            {
               return false;
            }
         }
         
         if (schemaTrigger != null && !overrideSchemaTriggers)
         {
            // each trigger invocation is done using a different instance of the OLD, r/o buffer
            boolean schOldBuffer = oldDmoValue != null && schemaTrigger.hasOldBuffer();
            Buffer oldBuffer = schOldBuffer 
                                 ? createWriteOldBuffer(schemaTrigger.getTriggerClass(),
                                                        bufferCls,
                                                        oldDmoValue)
                                 : null;
            if (!guardedExec(() -> fireTrigger(det, schemaTrigger, newVal, oldBuffer)))
            {
               return false;
            }
         }
         
         // in the FIND case, the session trigger executes second (if it ever does)
         if (sessionTrigger != null && det == DatabaseEventType.FIND)
         {
            // FIND triggers do not have an old buffer
            if (!guardedExec(() -> fireTrigger(det, sessionTrigger, newVal, null)))
            {
               return false;
            }
         }
      }
      
      // replication triggers ignores the overrideable attribute, they always trigger
      TriggerData replicationTrigger = getReplicationSchemaTrigger(det, bufferCls);
      DatabaseEventType rDet = getReplicationEvent(det);
      if (replicationTrigger != null && rDet != null)
      {
         boolean replOldBuffer = oldDmoValue != null && replicationTrigger.hasOldBuffer();
         Buffer oldBuffer = replOldBuffer 
                              ? createWriteOldBuffer(replicationTrigger.getTriggerClass(),
                                                     bufferCls, 
                                                     oldDmoValue) 
                              : null;
         
         return guardedExec(() -> fireTrigger(rDet, replicationTrigger, newVal, oldBuffer));
      }
      
      // everything is fine
      return true;
   }
   
   /**
    * Convert a base database event into the replication event. If they are already replication events, they
    * are returned. If they do not have a replication event associated, {@code null} is returned.
    *
    * @param   det
    *          The base trigger event.
    *
    * @return  The replication event of {@code det}. For {@code FIND} and {@code ASSIGN}, {@code null} is
    *          returned.
    */
   public static DatabaseEventType getReplicationEvent(DatabaseEventType det)
   {
      switch (det)
      {
         case CREATE:
         case REPLICATION_CREATE:
            return DatabaseEventType.REPLICATION_CREATE;
         case DELETE:
         case REPLICATION_DELETE:
            return DatabaseEventType.REPLICATION_DELETE;
         case WRITE:
         case REPLICATION_WRITE:
            return DatabaseEventType.REPLICATION_WRITE;
         default:
            return null;
      }
   }
   
   /**
    * Executes an ASSIGN trigger.
    *
    * @param   bufferCls
    *          The buffer class for the dmo that fired the trigger.
    * @param   newVal
    *          The buffer that contains the field that fired the trigger.
    * @param   oldValue
    *          When the ASSIGN trigger declares an 'old' value, this is passed to trigger
    *          procedure. The procedure code is allowed to modify this value without generating
    *          any error, however, they are lost as only a duplicate is passed on.
    * @param   ldb
    *          The database where the buffer reside.
    *
    * @return  {@code true} if no trigger was executed, or they executed with success. The method will return
    *          {@code false} if the trigger vetoed the operation (by returning error).
    */
   public static boolean triggerAssign(Class<? extends Buffer> bufferCls,
                                       Buffer newVal,
                                       String property,
                                       BaseDataType oldValue,
                                       String ldb)
   {
      DatabaseTriggerManager manager = get();
      if (manager.getTriggerStatus(DatabaseEventType.ASSIGN, bufferCls) !=
          TriggerBlockLayer.EnableStatus.ENABLED)
      {
         // triggers are disabled for this kind of event / buffer
         // there are no replication triggers for ASSIGN event
         return true;
      }
      
      // look for generic trigger first and execute it, if found 
      TriggerData genericTrigger = manager.getGenericTrigger(DatabaseEventType.ASSIGN, bufferCls, ldb, property);
      if (genericTrigger != null)
      {
         fireAssignTrigger(genericTrigger, newVal, property, oldValue.duplicate());
         // NOTE: do not return false if this trigger fails
      }
      
      // look for the trigger
      TriggerData sessionTrigger = manager.peekTrigger(DatabaseEventType.ASSIGN, bufferCls, property);
      
      // execute session trigger second
      if (sessionTrigger != null)
      {
         boolean res = fireAssignTrigger(sessionTrigger, newVal, property, oldValue.duplicate());
         
         if (!res)
         {
            return false;
         }
         if (sessionTrigger.isOverride())
         {
            // if session trigger overrides then we return now without executing schema trigger
            return true;
         }
      }
      
      // the schema trigger executes last
      TriggerData schemaTrigger = getSchemaTrigger(DatabaseEventType.ASSIGN, bufferCls, property);
      if (schemaTrigger != null)
      {
         return fireAssignTrigger(schemaTrigger, newVal, property, oldValue.duplicate());
         // if trigger object failed an exception have already been printed to console
      }
      
      return true; // no trigger fired
   }
   
   /**
    * Provides a notification that a new scope is about to be entered. The manager only acts on
    * top-level scope. Each time a new scope is entered a fresh new layer of triggers is added
    * to internal stack.
    * 
    * @param    block
    *           The explicit block definition which required this notification.
    */
   @Override
   public void scopeStart(BlockDefinition block)
   {
      // triggers get notifications at all top level blocks
      stack.addFirst(new TriggerBlockLayer()); // push();
   }
   
   /**
    * Provides a notification that an external scope is about to be deleted.
    */
   @Override
   public void scopeDeleted()
   {
      Object referent = pm.getProcessedProcedure();
      if (referent == null           || 
          pm._isPersistentAndNotPendingDelete(referent) || 
          (referent == pm._thisProcedure() && !tm.isExternalBlock()))
      {
         // if no referent or procedure is still persistent, do not clean up yet
         return;
      }
      
      programs.remove(referent);
      persistentTriggers.remove(referent);
   }
   
   /**
    * Get the {@link ScopeId} for the instance.
    * 
    * @return   {@link ScopeId#DATABASE_TRIGGER_MANAGER}.
    */
   @Override
   public ScopeId getScopeId()
   {
      return ScopeId.DATABASE_TRIGGER_MANAGER;
   }
   
   /**
    * Provides a notification that a scope is about to be exited. The manager only acts on
    * top-level scope. Each time a scope is exited the last layer of triggers is removed from internal stack.
    */
   @Override
   public void scopeFinished()
   {
      TriggerBlockLayer tbl = null;
      
      // triggers get notifications at all top level blocks
      if (stack.isEmpty())
      {
         LOG.log(Level.WARNING, "Invalid scope finished.");
      }
      else
      {
         tbl = stack.removeFirst(); // pop();
      }
      
      if (tbl != null)
      {
         Object referent = pm._thisProcedure();
         if (pm._isPersistentAndNotPendingDelete(referent) && tm.isExternalBlock())
         {
            programs.push(referent);
            persistentTriggers.put(referent, tbl);
         }
      }
   }
   
   /**
    * Register a generic trigger for a specific event. When such trigger is registered, it will be fired for
    * all events of that type, of all tables from specified database before any other configured session or
    * schema trigger. The execution of the latter two is unaffected of the presence or execution of the
    * generic trigger.
    *
    * @param   eventName
    *          The event as case-insensitive string.
    * @param   database
    *          The database to apply to. In future revisions, this parameter might be replaced by a table
    *          pattern.
    * @param   triggerClassName
    *          The trigger class which implements the method for handling the respective event in a generic
    *          manner.
    *
    * @return  {@code null} if the operation is successful and a short description of the problem otherwise.
    */
   String registerGenericTrigger(String eventName, String database, String triggerClassName)
   {
      if (eventName == null || database == null || triggerClassName == null)
      {
         // never throw NullPointerException, maybe log the cause
         return "Null parameter (" + 
                (eventName == null ? "eventName" : (database == null ? "database" : "triggerClassName")) + 
                ").";
      }
      
      DatabaseEventType event;
      switch (eventName.toUpperCase())
      {
         case "CREATE": event = DatabaseEventType.CREATE; break;
         case "DELETE": event = DatabaseEventType.DELETE; break;
         case "FIND":   event = DatabaseEventType.FIND;   break;
         case "WRITE":  event = DatabaseEventType.WRITE;  break;
         default: return "Event name unrecognized (" + eventName + ").";
      }
      
      Class<? extends DatabaseTrigger<Buffer>> cls;
      try
      {
         Class<?> triggerClass = Class.forName(triggerClassName);
         
         if (!GenericDatabaseTrigger.class.isAssignableFrom(triggerClass))
         {
            return "Invalid class for '" + triggerClassName + 
                   "'. It must extend 'com.goldencode.p2j.util.GenericDatabaseTrigger'.";
         }
         cls = (Class<? extends DatabaseTrigger<Buffer>>) triggerClass;
      }
      catch (ClassNotFoundException e)
      {
         return "Class '" + triggerClassName + "' is not found / accessible. Please add it to classpath.";
      }
      
      Map<String, TriggerData> genericMap;
      switch (event)
      {
         case CREATE:
            if (genericCreateTriggers == null)
            {
               genericCreateTriggers = new HashMap<>();
            }
            genericMap = genericCreateTriggers;
            break;
         
         case DELETE:
            if (genericDeleteTriggers == null)
            {
               genericDeleteTriggers = new HashMap<>();
            }
            genericMap = genericDeleteTriggers;
            break;
         
         case FIND:
            if (genericFindTriggers == null)
            {
               genericFindTriggers = new HashMap<>();
            }
            genericMap = genericFindTriggers;
            break;
         
         case WRITE:
            if (genericWriteTriggers == null)
            {
               genericWriteTriggers = new HashMap<>();
            }
            genericMap = genericWriteTriggers;
            break;
         
         default:
            return "Invalid event.";
      }
      
      genericMap.put(database, new TriggerData(cls, null, false, true, true, true));
      
      return null; // all OK
   }
   
   /**
    * Unregister a previously set generic trigger given the event type and database.
    * This is the equivalent of {@code ON <event> OF <table> REVERT} but for generic triggers.
    *
    * @param   eventName
    *          The event as case-insensitive string.
    * @param   database
    *          The database to apply to. In future revisions, this parameter might be replaced by a table
    *          pattern.
    *
    * @return  {@code null} if the operation is successful and a short description of the problem otherwise.
    */
   String deregisterGenericTrigger(String eventName, String database)
   {
      if (eventName == null || database == null)
      {
         // never throw NullPointerException, maybe log the cause
         return "Null parameter (" + (eventName == null ? "eventName" : "database") + ").";
      }
      
      DatabaseEventType event;
      switch (eventName.toUpperCase())
      {
         case "CREATE": event = DatabaseEventType.CREATE; break;
         case "DELETE": event = DatabaseEventType.DELETE; break;
         case "FIND":   event = DatabaseEventType.FIND;   break;
         case "WRITE":  event = DatabaseEventType.WRITE;  break;
         default: return "Event name unrecognized (" + eventName + ").";
      }
      
      removeGenericTrigger(event, database, null);
      return null;
   }   
   
   /**
    * Register a generic trigger for a ASSIGN event. When such trigger is registered, it will be fired for all
    * ASSIGN events, of all tables from specified database BEFORE any other configured session or schema
    * triggers. The execution of the latter two is unaffected of the presence or execution of the generic
    * trigger.
    *
    * @param   eventName
    *          The event as case-insensitive string.
    * @param   database
    *          The database to apply to. In future revisions, this parameter might be replaced by a table
    *          pattern.
    * @param   triggerClassName
    *          The trigger class which implements the method for handling the respective event in a generic
    *          manner.
    * @param   property
    *          The <strong>property</strong> name of the affected field.
    *
    * @return  {@code null} if the operation is successful and a short description of the problem otherwise.
    */
   String registerGenericTrigger(String eventName,
                                 String database,
                                 String triggerClassName,
                                 String property)
   {
      if (eventName == null || database == null || triggerClassName == null)
      {
         // never throw NullPointerException, maybe log the cause
         return "Null parameter (" +
                (eventName == null ? "eventName" : (database == null ? "database" : "triggerClassName")) +
                ").";
      }
      
      if (!"ASSIGN".equalsIgnoreCase(eventName))
      {
         return "This variant of the method only handles ASSIGN triggers."; 
      }
      
      Class<? extends DatabaseTrigger<Buffer>> cls;
      try
      {
         Class<?> triggerClass = Class.forName(triggerClassName);
         
         if (!GenericDatabaseTrigger.class.isAssignableFrom(triggerClass))
         {
            return "Invalid class for '" + triggerClassName +
                   "'. It must extend 'com.goldencode.p2j.util.GenericDatabaseTrigger'.";
         }
         cls = (Class<? extends DatabaseTrigger<Buffer>>) triggerClass;
      }
      catch (ClassNotFoundException e)
      {
         return "Class '" + triggerClassName + "' is not found / accessible. Please add it to classpath.";
      }
      
      if (genericAssignTriggers == null)
      {
         genericAssignTriggers = new HashMap<>();
      }
      Map<String, TriggerData> propMap = genericAssignTriggers.computeIfAbsent(database, k -> new HashMap<>());
      
      propMap.put(property, new TriggerData(cls, null, false, true, true, true));
      return null;
   }
   
   /**
    * Unregister a session database trigger for the given event type and buffer class.
    * This is the equivalent of {@code ON Assign OF <table> REVERT} but for generic triggers.
    *
    * @param   database
    *          The database to apply to. In future revisions, this parameter might be replaced by a table
    *          pattern.
    * @param   property
    *          The property that is deregistered from {@code ASSIGN} trigger.
    *
    * @return  {@code null} if the operation is successful and a short description of the problem otherwise.
    */
   String deregisterGenericAssignTrigger(String database, String property)
   {
      removeGenericTrigger(DatabaseEventType.ASSIGN, database, property);
      return null;
   }
   
   /**
    * Tests if a schema trigger is overrideable. In case the trigger is declared as overrideable,
    * {@code true} is returned. Otherwise, 3223 code is {@code recordOrThrowError()}.
    * If no error is acting {@code false} will be returned.
    *
    * @param   event
    *          The database event of the trigger to be tested.
    * @param   bufferCls
    *          The class buf for the table the trigger is defined.
    *
    * @return  {@code true} if respective trigger can be overridden by a session trigger.
    */
   private static boolean isSchemaTriggerOverrideable(DatabaseEventType event,
                                                      Class<? extends Buffer> bufferCls)
   {
      // check if this event is overrideable
      TriggerData schemaTrigger = getSchemaTrigger(event, bufferCls, null);
      if (schemaTrigger != null && !schemaTrigger.isOverride())
      {
         // detect table name
         String tableName = "?";
         for (Class<?> next : bufferCls.getInterfaces())
         {
            if (DataModelObject.class.isAssignableFrom(next))
            {
               tableName = next.getSimpleName();
               break;
            }
         }
         
         String msg = String.format("Attempt to OVERRIDE a schema %s trigger for %s", event, tableName);
         // this is a non overrideable schema trigger:
         ErrorManager.recordOrThrowError(3223, msg);
         return false;
      }
      return true;
   }
   
   /**
    * Create a trigger object instance based on information from a {@code TriggerData} container.
    *
    * @param   triggerData
    *          The container that holds information about the trigger to be built.
    *
    * @return  An instance of the trigger as configured in {@code triggerData}.
    */
   @SuppressWarnings("unchecked")
   private static DatabaseTrigger<Buffer> instantiateTrigger(TriggerData triggerData) {
      try
      {
         if (triggerData.getTriggerContainer() == null)
         {
            // if the container is null then this is (probably) a schema trigger
            // being a top-level public class, it does not need a container for constructing
            return triggerData.getTriggerClass().getDeclaredConstructor().newInstance();
         }
         
         Class<?>[] ctorParmlist  = new Class[] { triggerData.getTriggerContainer().getClass() };
         Constructor<? extends DatabaseTrigger> ctor =
               triggerData.getTriggerClass().getConstructor(ctorParmlist);
         return ctor.newInstance(triggerData.getTriggerContainer());
      }
      catch (InstantiationException | IllegalAccessException |
            NoSuchMethodException | InvocationTargetException e)
      {
         LOG.severe("", e);
         // could not instantiate the trigger class
         return null;
      }
   }
   
   /**
    * Extract only the DMO schema-specific interface (i.e., exclude the Buffer interface)
    *
    * @param   def
    *          The source-definition class for the trigger.
    * @param   bufferCls
    *          Interface which defines DMO's public API plus all {@link Buffer} methods.
    * @param   oldDmoValue
    *          The read-only value.
    *
    * @return  A temporary read-only buffer that can be used as the OLD 'parameter' in a WRITE trigger.
    */
   private static Buffer createWriteOldBuffer(Class<?> def,
                                              Class<? extends Buffer> bufferCls,
                                              Record oldDmoValue)
   {
      Class<? extends DataModelObject> dmoIface = null;
      Class<?>[] superIfaces = bufferCls.getInterfaces();
      for (Class<?> next : superIfaces)
      {
         if (DataModelObject.class.isAssignableFrom(next))
         {
            dmoIface = (Class<? extends DataModelObject>) next;
            break;
         }
      }
      
      if (dmoIface == null)
      {
         LOG.log(Level.WARNING, "Could not create old buffer for WRITE trigger.");
         return null;
      }
      
      String schema = DmoMetadataManager.getDmoInfo(dmoIface).getSchema();
      return RecordBuffer.defineReadOnly(def, bufferCls, schema, oldDmoValue, true);
   }
   
   /**
    * Instantiates an object of the ASSIGN trigger and fires it.
    *
    * @param   triggerData
    *          Information about how the trigger will be built.
    * @param   newRecord
    *          The buffer that contains the field that fired the trigger.
    * @param   property
    *          Optionally, for ASSIGN triggers the name of the property (field) for which the
    *          trigger was declared. Otherwise, it is ignored.
    * @param   oldValue
    *          The old value of the field. May be null if the trigger did not declare it.
    *
    * @return  {@code true} if no trigger was executed, or they executed with success. The method will return
    *          {@code false} if the trigger vetoed the operation (by returning error).
    */
   private static boolean fireAssignTrigger(TriggerData triggerData,
                                            Buffer newRecord,
                                            String property,
                                            BaseDataType oldValue)
   {
      DatabaseTrigger<Buffer> trigger = instantiateTrigger(triggerData);
      if (trigger == null)
      {
         return true;
      }
      
      boolean ret = true;
      DatabaseTriggerManager dtm = get();
      try
      {
         Class<?> def = triggerData.getTriggerClass();
         Object target = triggerData.getTriggerContainer();
         String iename = "";
         String pname = dtm.pm.getRelativeName(target);
         
         dtm.pm.pushCalleeInfo(def, false, target, target, iename, pname, false, false);
         
         // the trigger is not related to any buffer
         // this should happen only for assign triggers which are defined with NEW VALUE
         if (!triggerData.hasNewBuffer())
         {
            if (property == null)
            {
               // this should not happen; the TriggerData should always know the property
               if (LOG.isLoggable(Level.SEVERE))
               {
                  LOG.log(Level.SEVERE, 
                          String.format(
                                "The property of the assign trigger couldn't be identified for %s.",
                                triggerData.getTriggerClass().toString()));
               }
               return false;
            }
            FieldReference fieldRef = new FieldReference((DataModelObject)newRecord, property);
            Class<? extends BaseDataType> type = (Class<? extends BaseDataType>) fieldRef.getType();
            Undoable field = OutputParameter.wrap((DataModelObject) newRecord, property, type, true);
            
            ErrorManager.ErrorHelper eh = ErrorManager.getErrorHelper();
            boolean wm = eh.isWarningMode();
            if (!wm)
            {
               // this is mostly for APPEND mode, the other ones will not encounter issues here
               eh.setWarningMode(true);
            }
            
            if (!guardedExec(() -> {
               if (triggerData.isGeneric())
               {
                  GenericDatabaseTrigger gdt = (GenericDatabaseTrigger) trigger;
                  Property prop = ((BufferImpl) newRecord).buffer().getDmoInfo().getFieldInfo(property);
                  gdt.assign(newRecord, oldValue, new character(prop.legacy));
               }
               else if (triggerData.hasOldBuffer())
               {
                  trigger.assign(field, oldValue);
               }
               else
               {
                  trigger.assign(field);
               }
            }))
            {
               return false;
            }
         }
         else
         {
            if (!guardedExec(() -> {
               if (triggerData.isGeneric())
               {
                  GenericDatabaseTrigger gdt = (GenericDatabaseTrigger) trigger;
                  Property prop = ((BufferImpl) newRecord).buffer().getDmoInfo().getFieldInfo(property);
                  gdt.assign(newRecord, oldValue, new character(prop.legacy));
               }
               else if (triggerData.hasOldBuffer())
               {
                  trigger.assign(newRecord, oldValue);
               }
               else
               {
                  trigger.assign(newRecord);
               }
            })) {
               return false;
            }
         }
      }
      finally
      {
         dtm.pm.popCalleeInfo();
      }
      
      // if trigger object failed an exception have already been printed to console
      return ret;
   }
   
   /**
    * Instantiates a trigger object associated with the specified database event type and fires it.
    *
    * @param   det
    *          The database event type to be fired.
    * @param   triggerData
    *          Information about how the trigger will be built.
    * @param   buffer
    *          The buffer that fired the trigger.
    * @param   oldValue
    *          The old value of the buffer. May be {@code null} if the trigger did not declare it.
    */
   private static void fireTrigger(DatabaseEventType det,
                                   TriggerData triggerData,
                                   Buffer buffer,
                                   Buffer oldValue)
   {
      final DatabaseTrigger<Buffer> trigger = instantiateTrigger(triggerData);
      if (trigger == null)
      {
         // if trigger object failed an exception have already been printed to console
         return;
      }
      
      DatabaseTriggerManager dtm = get();
      try
      {
         TransactionManager.pauseOffEndRegistration();
         Class<?> def = triggerData.getTriggerClass();
         Object target = triggerData.getTriggerContainer();
         String iename = "";
         String pname = dtm.pm.getRelativeName(target);
         
         dtm.pm.pushCalleeInfo(def, false, target, target, iename, pname, false, false);

         switch (det)
         {
            case ASSIGN:
               throw new IllegalArgumentException("Not executing ASSIGN with this method.");
               // break;
            case CREATE:
               trigger.create(buffer);
               break;
            case DELETE:
               trigger.delete(buffer);
               break;
            case FIND:
               trigger.find(buffer);
               break;
            case WRITE:
               if (triggerData.isGeneric() || triggerData.hasOldBuffer() /* && oldValue != null ? */)
               {
                  trigger.write(buffer, oldValue);
               }
               else
               {
                  trigger.write(buffer);
               }
               break;
            case REPLICATION_CREATE:
               trigger.replicationCreate(buffer);
               break;
            case REPLICATION_DELETE:
               trigger.replicationDelete(buffer);
               break;
            case REPLICATION_WRITE:
               if (triggerData.hasOldBuffer() /* && oldValue != null ? */)
               {
                  trigger.replicationWrite(buffer, oldValue);
               }
               else
               {
                  trigger.replicationWrite(buffer);
               }
               break;
         }
      }
      finally
      {
         dtm.pm.popCalleeInfo();
         TransactionManager.resumeOffEndRegistration();
      }
   }

   /**
    * Handle trigger- related errors. The error is displayed in status bar then the execution of
    * the procedure is abruptly interrupted because all trigger errors are fatal errors.
    *
    * @param   errId
    *          The id of the error.
    * @param   det
    *          The database event that caused the error.
    * @param   tableOrBufferName
    *          The name of the table (or buffer in some cases) that caused the error
    * @param   fieldName
    *          In the case of {@code ASSIGN} trigger the field that caused the error.
    */
   public static void handleError(int errId,
                                  DatabaseEventType det,
                                  String tableOrBufferName,
                                  String fieldName)
   {
      String msg = null;
      boolean displayOnly = false;
      
      switch (errId)
      {
         case 3168:
            msg = "Attempt to CREATE a " + tableOrBufferName + "record in " + det + "-TRIGGER " +
                  ProcedureManager.lastProcedure() + " while a trigger is executing";
            break;
         
         case 3169:
            msg = "Attempt to DELETE a " + tableOrBufferName + "record in " + det + "-TRIGGER " +
                  ProcedureManager.lastProcedure() + " while a trigger is executing";
            break;
         
         case 2873:
            assert (det == DatabaseEventType.FIND);
            msg = "Cannot change cursor of buffer " + tableOrBufferName +
                  "when executing a trigger";
            displayOnly = true;
            break;
         
         case 2872:
            assert (det == DatabaseEventType.WRITE);
            msg = "Attempt to FIND old trigger buffer during WRITE trigger for " +
                  tableOrBufferName;
            break;
         
         case 1003:
            assert (det == DatabaseEventType.ASSIGN);
            msg = "Cycle in procedure calls detected in 'ASSIGN-TRIGGER " +
                  ProcedureManager.lastProcedure() + "' while executing ASSIGN trigger for " +
                  tableOrBufferName + "." + fieldName;
            break;
            
         case 2665:
            // this is handled in RecordBuffer
            msg = "Attempt to update a Read-Only buffer for table " + tableOrBufferName;
            break;
         
         case 3129:
            // TODO: I found this error when (assign) trigger is fired after buffer RELEASE (if this is possible ?)
            assert (det == DatabaseEventType.ASSIGN);
            int[] errCodes = {91, 3129};
            String[] errMsgs = {"** No " + tableOrBufferName + " record is available",
                                "Could not extract value from " + tableOrBufferName +
                                      " (rpos 5) for an ASSIGN trigger" };
            ErrorManager.recordOrThrowError(errCodes, errMsgs, false, true);
            return;
      }
      
      if (displayOnly)
      {
         ErrorManager.displayError(errId, msg);
      }
      else
      {
         ErrorManager.showErrorAndAbend(errId, msg);
      }
   }
   
   /**
    * Indicate whether any trigger (session or schema) of the specified type is registered for the
    * given buffer class at the current block level.
    * 
    * @param   det
    *          Trigger type.
    * @param   bufferCls
    *          Buffer class.
    * @param   database
    *          The database where the buffer reside. If {@code null} try to autodetect using the buffer DMO.
    * 
    * @return  {@code true} if a registered trigger is found, else {@code false}.
    */
   boolean isTriggerEnabled(DatabaseEventType det, Class<? extends Buffer> bufferCls, String database)
   {
      TriggerData genericTrigger = getGenericTrigger(det, bufferCls, database, null);
      if (genericTrigger != null)
      {
         return true;
      }
      
      TriggerBlockLayer.EnableStatus triggerStatus = getTriggerStatus(det, bufferCls);
      if (triggerStatus == TriggerBlockLayer.EnableStatus.ENABLED)
      {
         if (peekTrigger(det, bufferCls, null) != null)
         {
            return true;
         }
         
         // search for the DMO interface of the bufferCls
         Class<? extends DataModelObject> dmoIface = null;
         Class<?>[] superIfaces = bufferCls.getInterfaces();
         int len = superIfaces.length;
         for (int i = 0; i < len; i++)
         {
            Class<?> next = superIfaces[i];
            if (DataModelObject.class.isAssignableFrom(next))
            {
               dmoIface = (Class<? extends DataModelObject>) next;
               break;
            }
         }
         
         if (dmoIface == null)
         {
            // no suitable DMO interface was found for the given bufferCls
            return false;
         }
         
         // identify the trigger procedure meant to be executed
         String procedure = TableMapper.getLegacyTriggerProcedure(dmoIface, det.toString(), null);
         if (procedure == null)
         {
            // no procedure for the det trigger at the DMO's LegacyTrigger annotation 
            return false;
         }
         
         return true;
      }
      
      return false;
   }
   
   /**
    * Indicate whether assign triggers are enabled for the given buffer class at this block level.
    * 
    * @param   bufferCls
    *          Buffer class.
    * 
    * @return  See above. 
    */
   boolean areAssignTriggersEnabled(Class<? extends Buffer> bufferCls)
   {
      return getTriggerStatus(DatabaseEventType.ASSIGN, bufferCls) == TriggerBlockLayer.EnableStatus.ENABLED;
   }
   
   /**
    * Indicate whether an assign trigger (session or schema) is registered for the given buffer
    * class and DMO property at the current block level.
    * 
    * @param   bufferCls
    *          Buffer class.
    * @param   property
    *          DMO property name.
    * @param   database
    *          The database where the buffer reside. If {@code null} try to autodetect using the buffer DMO.
    * 
    * @return  {@code true} if a registered trigger is found, else {@code false}.
    */
   boolean isAssignTrigger(Class<? extends Buffer> bufferCls, String property, String database)
   {
      return getGenericTrigger(DatabaseEventType.ASSIGN, bufferCls, database, property) != null ||
             peekTrigger(DatabaseEventType.ASSIGN, bufferCls, property) != null                 ||
             getSchemaTrigger(DatabaseEventType.ASSIGN, bufferCls, property) != null;
   }
   
   /**
    * Get the set of triggers that are currently executed for a specified record.
    *
    * @param   bufferClass
    *          The dmo buffer interface.
    * @param   rowId
    *          The id of the record.
    * @param   create
    *          If the specified stack does not exist, make sure it is created.
    *
    * @return  The stack of execution triggers. May be {@code null} if stack does not exist and
    *          creation of a new one was not requested.
    */
   Deque<DatabaseEventType> getExecutingTriggerStack(Class<? extends Buffer> bufferClass,
                                                     Long rowId,
                                                     boolean create)
   {
      HashMap<Long, Deque<DatabaseEventType>> dmoClassMap = executing.get(bufferClass);
      if (dmoClassMap == null)
      {
         if (create)
         {
            // the map for this kind of dmo has not been found, we need to create it now
            dmoClassMap = new HashMap<>();
            executing.put(bufferClass, dmoClassMap);
         }
         else
         {
            // no executing triggers for this kind of DMO and no requested to create one
            return null;
         }
      }
      
      Deque<DatabaseEventType> stack = dmoClassMap.get(rowId);
      if (stack == null && create)
      {
         // if there is no stack of executing triggers for this id, create it now
         stack = new LinkedList<>();
         dmoClassMap.put(rowId, stack);
      }
      
      return stack;
   }
   
   /**
    * Cleanup. Removes a trigger stack for a specified record. The stack must exist and be empty,
    * otherwise there the pop/push are not balanced.
    *
    * @param   bufferClass
    *          The dmo buffer interface.
    * @param   rowId
    *          The id of the record.
    */
   void removeExecutingTriggerStack(Class<? extends Buffer> bufferClass, Long rowId)
   {
      HashMap<Long, Deque<DatabaseEventType>> dmoClassMap = executing.get(bufferClass);
      if (dmoClassMap == null)
      {
         // unexpected, might indicate a programming error (unbalanced pop/push) 
         return;
      }
      
      Deque<DatabaseEventType> toRemove = dmoClassMap.remove(rowId);
      
      assert toRemove != null && toRemove.isEmpty();
   }
   
   /**
    * Looks into the stack of layers and check if a trigger is enabled for a database event.
    * If the trigger was not disabled at all, {@code EnableStatus.ENABLED} is returned. If
    * the trigger was disabled, the {@code EnableStatus.DISABLED} is returned unless the
    * events has a replication pair, and it was not disable using {@code ALLOW-REPLICATION},
    * when {@code EnableStatus.REPLICATION_ONLY} is returned.
    * <p>
    * FIND/ASSIGN/CREATE/WRITE events won't return ENABLED here, they will return either DISABLED
    * or UNKNOWN. Thus, we check the entire stack until DISABLED is found and if DISABLED is never
    * returned, then we are implicitly ENABLED.
    *
    * @param   det
    *          The database (base) event.
    * @param   bufferCls
    *          The buffer.
    *
    * @return  the status of the trigger on the buffer.
    */
   private TriggerBlockLayer.EnableStatus getTriggerStatus(DatabaseEventType det,
                                                           Class<? extends Buffer> bufferCls)
   {
      for (TriggerBlockLayer tbl : stack)
      {
         TriggerBlockLayer.EnableStatus status = tbl.getTriggerStatus(det, bufferCls);
         
         if (status != TriggerBlockLayer.EnableStatus.UNKNOWN)
         {
            return status;
         }
      }
      
      // if no disable-triggers were found in the whole stack then the trigger is enabled
      return TriggerBlockLayer.EnableStatus.ENABLED;
   }
   
   /**
    * Implementation of the trigger search algorithm in the stack of triggers. Each layer of the
    * stack is inspected for {@code det} type of trigger associated with {@code buff}.
    * If found it is returned, otherwise the search continue with the next level from the stack.
    * If no such trigger can be found, {@code null} is returned.
    * This method takes into account if respective trigger event was disabled and, in this case,
    * a special singleton object is returned.
    *
    * @param   det
    *          The database trigger type.
    * @param   bufferCls
    *          The buffer that raised the trigger event.
    * @param   property
    *          In the case of ASSIGN trigger, the name of the property that fired the trigger
    *
    * @return  The top-level instance of the trigger data of the {@code det} type associated
    *          with {@code buff}.
    */
   private TriggerData peekTrigger(DatabaseEventType det, Class<? extends Buffer> bufferCls, String property)
   {
      for (TriggerBlockLayer blockLayer : stack)
      // the proper order is guaranteed by deque.iterator() method
      {
         TriggerData triggerData = blockLayer.getTrigger(det, bufferCls, property);
         if (triggerData == null)
         {
            // no trigger found at this level, search deeper
            continue;
         }
         
         // if disabledTrigger was returned either a trigger was found or not, it does not
         // matter as long as nothing will be executed (disabledTrigger methods are empty and
         // also isOverride() return true so schema trigger is also disabled).
         // if a normal trigger is found it will be executed along with schema trigger
         return triggerData;
      }
      
      // if not found, look into the global session triggers, from the top of stack down
      Iterator<Object> iter = programs.iterator();
      while (iter.hasNext())
      {
         Object referent = iter.next();
         TriggerBlockLayer blockLayer = persistentTriggers.get(referent);
         if (blockLayer != null)
         {
            TriggerData triggerData = blockLayer.getTrigger(det, bufferCls, property);
            if (triggerData != null)
            {
               return triggerData;
            }
         }
      }
      
      // search failed, no trigger of type det was defined for buff
      return null;
   }
   
   
   /**
    * Removes a generic trigger from the associated map.
    *
    * @param   det
    *          The event type of the trigger to be reverted
    * @param   database
    *          The database to apply to. In future revisions, this parameter might be replaced by a table
    *          pattern.
    * @param   property
    *          The property that is unregistered from ASSIGN trigger, otherwise ignored.
    */
   private void removeGenericTrigger(DatabaseEventType det, String database, String property)
   {
      switch (det)
      {
         case ASSIGN:
            if (genericAssignTriggers == null)
            {
               return;
            }
            
            Map<String, TriggerData> triggersMap = genericAssignTriggers.get(database);
            if (triggersMap != null)
            {
               triggersMap.remove(property);
               // if (triggersMap.isEmpty()) genericAssignTriggers.remove(database);
               // if (genericAssignTriggers.isEmpty()) genericAssignTriggers = null;
            }
            break;
         
         case CREATE:
            if (genericCreateTriggers != null)
            {
               genericCreateTriggers.remove(database);
            }
            break;
         
         case DELETE:
            if (genericDeleteTriggers != null)
            {
               genericDeleteTriggers.remove(database);
            }
            break;
         
         case FIND:
            if (genericFindTriggers != null)
            {
               genericFindTriggers.remove(database);
            }
            break;
         
         case WRITE:
            if (genericWriteTriggers != null)
            {
               genericWriteTriggers.remove(database);
            }
            break;
      }
   }
   
   /**
    * Look for a specific generic trigger and return it if one was registered. 
    *
    * @param   det
    *          The trigger type to be executed.
    * @param   bufferCls
    *          The buffer class for the dmo that fired the trigger. Currently, unused. It will be used when
    *          the buffer pattern will be added to generic trigger registration. 
    * @param   database
    *          The database to apply to. In future revisions, this parameter might be replaced by a table
    *          pattern.
    * @param   property
    *          The modified property, in case of ASSIGN triggers.
    *
    * @return  the trigger data necessary for firing the specified trigger, or {@code null} if none was found.
    */
   private TriggerData getGenericTrigger(DatabaseEventType det,
                                         Class<? extends Buffer> bufferCls,
                                         String database,
                                         String property)
   {
      if (det == DatabaseEventType.ASSIGN)
      {
         if (genericAssignTriggers != null)
         {
            Map<String, TriggerData> fieldMap = genericAssignTriggers.get(database);
            if (fieldMap != null)
            {
               return fieldMap.get(property);
            }
         }
         return null;
      }
      
      // for the other triggers:
      Map<String, TriggerData> genericMap = null;
      switch (det)
      {
         case CREATE: genericMap = genericCreateTriggers; break;
         case DELETE: genericMap = genericDeleteTriggers; break;
         case FIND:   genericMap = genericFindTriggers;   break;
         case WRITE:  genericMap = genericWriteTriggers;  break;
      }
      if (genericMap != null)
      {
         return genericMap.get(database);
      }
      
      return null;
   }
   
   /**
    * Execute a piece of code, checking whether it raised an ERROR. In the event this happens, {@code false}
    * is returned if already in silent mode, otherwise the exception will propagate to the caller.
    *
    * @param   code
    *          The code to be executed.
    *
    * @return  {@code true} if the code executed normally, and {@code false} if it raised an error while in
    *          silent mode.
    * 
    * @throws  ErrorConditionException
    *          if an error condition was raised while not in silent mode
    */
   private static boolean guardedExec(Runnable code)
   {
      // if not in silent mode, just execute the code. If an error condition is raised it will propagate
      // back to caller and the return value will be ignored.
      if (!ErrorManager.isSilent())
      {
         code.run();
         return true;
      }
      
      if (ErrorManager.isPendingError())
      {
         return false; // nothing to do, already in error mode
      }
      
      // at this moment we are in silent mode, with no pending error. We can run the code knowingly that there
      // will be no exceptions thrown because of error conditions but, if one happens, the 'pending' error
      // flag was set
      code.run();
      return !ErrorManager.isPendingError();
   }
}