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