ForeignNuller.java
/*
** Module : ForeignNuller.java
** Abstract : Helper object which cleans up orphaned foreign key joins
**
** Copyright (c) 2004-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- --JPRM-- -----------------------------------Description-----------------------------------
** 001 ECF 20061105 @31092 Created initial version. Helper object which
** cleans up orphaned foreign key joins when a
** referenced record is deleted.
** 002 ECF 20061128 @31450 Fixed NPE. Return empty iterator from
** localDMOs() if foreign DMO's local getter
** method returns null.
** 003 ECF 20070326 @32604 Force a pending flush from breakLinkage().
** Required to ensure foreign key update is
** flushed to the database before a delete of
** the foreign referent record. Otherwise, a
** foreign key constraint violation would occur.
** 004 ECF 20070412 @32979 Retrofit to use RecordLockContext for record
** locking.
** 005 ECF 20070416 @33023 Integrated user interrupt handling.
** 006 ECF 20070704 @34359 Considerable rewrite to fix a latent defect.
** The old implementation relied upon the
** inverse association from referent DMO to
** refering DMO(s) to be up to date in real
** time. However, this logic was removed from
** RecordBuffer and the AssociationSyncher
** hierarchy long ago as too expensive. It is
** thus necessary to execute a database query to
** collect the set of refering DMOs. This is the
** basis of the new implementation.
** 007 ECF 20070706 @34413 Minor optimization. Expose instances() method
** as package private so that the caller can
** cache instances of this class and pass them
** the static breakLinkages() method as needed,
** rather than looking them up at each call to
** breakLinkages().
** 008 ECF 20070716 @34545 Fixed regression. Context must be specific to
** each ForeignNuller instance, but it was
** static.
** 009 ECF 20070911 @35287 Replaced database name string with Database
** information object. Required for non-local
** database connections.
** 010 ECF 20080425 @38390 Adjust for foreign key runtime flag. Static
** method instances() returns an empty list in
** the event foreign key support is disabled.
** 011 CA 20080815 @39459 Support API change in DBUtils.
** 012 GES 20090421 @41829 Matched utility class package change.
** 013 ECF 20131029 Import change.
** 014 SVL 20140210 Use HQLExpression instead of HQL string.
** 015 ECF 20190620 EmptyIterator API change.
** 016 ECF 20200906 New ORM implementation.
** 017 OM 20200924 Small optimization.
** ECF 20210506 Use RecordBuffer.getDmoInfo() getter instead of direct field access to
** prevent NPE in proxy case.
** OM 20221103 New class names for FQLPreprocessor, FQLExpression, FQLBundle, and FQLCache.
** 018 SR 20230510 Matched persistence.list() new signature.
** 019 OM 20241128 Multi-tenant runtime support: selected the proper persistence context, eventually
** based on [sharedDb] parameter.
*/
/*
** 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;
import java.lang.reflect.*;
import java.util.*;
import com.goldencode.p2j.persist.lock.*;
import com.goldencode.p2j.persist.orm.*;
import com.goldencode.p2j.security.*;
import com.goldencode.util.*;
/**
* A class whose primary purpose is to null out foreign key references to a
* DMO from related records, when the DMO is about to be deleted. A single,
* static method provides the primary access point for this class: {@link
* #breakLinkages} -- nulls out all foreign key references to a given DMO.
* <p>
* The nullification of these foreign references is necessary to prevent
* database errors caused by foreign key constraints when a record is deleted
* without first deleting dependent records related to the primary record by
* a foreign key. This may happen for one of two reasons:
* <ul>
* <li>the deletes occur out of order, such that the referent record is
* deleted before its dependent records; or
* <li>the dependent records are never deleted at all; perhaps because the
* application requires that they survive the delete of the referent
* record, for subsequent reporting or other purposes.
* </ul>
* <p>
* Both of the above situations is possible and natural in Progress, due to
* the lack of database-level, referential integrity enforced by foreign key
* constraints. In a converted application running in the P2J environment,
* the introduction of foreign key relations between tables necessitates the
* special delete-time handling provided by this class.
* <p>
* Construction of an instance of this class is expensive, since it involves
* multiple method lookups using reflection. For this reason, instances of
* this class are cached across client contexts. Context-local state for
* each instance is managed within an instance of an inner class.
* <p>
* The actual breaking of linkages is also expensive in that it requires a
* database query to find all records which refer to a particular record by
* foreign key. However, the query uses the refering record's foreign key
* column, which is indexed, to make this lookup as fast as possible.
* Nevertheless, a tight loop of many deletes requiring this service will be
* quite expensive. An alternative to this expense would be to use the
* inverse association between the referent record and all of its refering
* records. This would require only a method call at linkage breakage time to
* collect refering DMOs. However, for this option to work, the DMO graph
* which holds set of associated records (one-to-many) or a single associated
* record (one-to-one) would have to be kept up to date in real time. This
* would require the record buffer to update these inverse associations each
* time a legacy foreign key property in the referent DMO is updated. This
* would probably add a more pervasive (and noticable) cost to the overall
* system. So,the lazy approach of looking up refering records at referent
* delete time was chosen instead.
*
* @see RecordBuffer#delete
*/
final class ForeignNuller
{
/** Cache of nuller objects, keyed by RelationInfo instances */
private static final Map<RelationInfo, ForeignNuller> cache = new IdentityHashMap<>();
/** Context local list of states managed by this object */
private final ContextLocal<Context> context = new ContextLocal<Context>()
{
protected Context initialValue() { return (new Context()); }
};
/** Persistence service object which performs record locking/unlocking */
private final Persistence persistence;
/** Name of table for which a lock must be acquired */
private final String lockTarget;
/** HQL statement used to retrieve refering DMOs given a referent */
private final String hql;
/** Method of foreign DMO which sets refering (local) DMO */
private final Method localSetter;
/** Method of local DMO which gets referent (foreign) DMO */
private final Method foreignGetter;
/** Method of local DMO which sets referent (foreign) DMO */
private final Method foreignSetter;
/** Is relation uniquely constrained at the local table? */
private final boolean unique;
/** In case of multi-tenant environment: is the buffer shared or private? */
private final boolean sharedDb;
/**
* Private constructor which is only accessed by the {@link #instances}
* factory method.
*
* @param buffer
* RecordBuffer which defines the persistence service object and
* database for this object.
* @param info
* Descriptor of the relation between the local and foreign DMOs.
*
* @throws PersistenceException
* if a method cannot be found using reflection.
*/
private ForeignNuller(RecordBuffer buffer, RelationInfo info)
throws PersistenceException
{
this.persistence = buffer.getPersistence();
this.sharedDb = !buffer.getDmoInfo().multiTenant;
// generate HQL to retrieve DMOs which refer to referent via foreign keys.
hql = generateHQL(info);
// determine whether local table is uniquely constrained.
unique = info.isUnique();
// store name of table for which a lock must be acquired before nulling out foreign reference.
Class<? extends Record> localClass = info.getLocalClass();
lockTarget = DatabaseManager.getBackingTableName(localClass);
try
{
Class<?> foreignIface = info.getForeignInterface();
Class<?> localIface = info.getLocalInterface();
// Store local setter method of foreign DMO.
StringBuilder buf = new StringBuilder();
buf.append("set");
buf.append(info.getLocalInterfaceName());
buf.append(unique ? "Record" : "Set");
String name = buf.toString();
localSetter = foreignIface.getDeclaredMethod(name, (unique ? localIface : Set.class));
// Store foreign getter method of local DMO.
buf.setLength(0);
buf.append("get");
buf.append(info.getForeignInterfaceName());
buf.append("Record");
name = buf.toString();
foreignGetter = localIface.getDeclaredMethod(name);
// Store foreign setter method of local DMO.
buf.setLength(0);
buf.append("set");
buf.append(info.getForeignInterfaceName());
buf.append("Record");
name = buf.toString();
foreignSetter = localIface.getDeclaredMethod(name, foreignIface);
}
catch (NoSuchMethodException exc)
{
throw new PersistenceException("Error initializing foreign key nuller", exc);
}
}
/**
* Retrieve a list of <code>ForeignNuller</code> instances, one for each
* inverse relation between the DMO represented by the business interface
* <code>dmoIface</code> and another DMO which references this record via
* a foreign key. Instances are first retrieved from a cross-context
* cache, if present in the cache. Otherwise, they are lazily created and
* stored in the cache.
*
* @param buffer
* Record buffer which holds the DMO representing a database
* record which is about to be deleted, which may be referenced
* via foreign key by other records.
*
* @return List of <code>ForeignNuller</code> instances. May be empty,
* but will not be <code>null</code>.
*
* @throws PersistenceException
* if there is an error creating a <code>ForeignNuller</code>
* instance.
*/
static List<ForeignNuller> instances(RecordBuffer buffer)
throws PersistenceException
{
if (!Persistence.isForeignKeysEnabled())
{
return Collections.emptyList();
}
ArrayList<ForeignNuller> instances = new ArrayList<>();
Iterator<RelationInfo> iter = buffer.getDmoInfo().inverseRelations();
while (iter.hasNext())
{
RelationInfo info = iter.next();
synchronized (cache)
{
ForeignNuller nuller = cache.get(info);
if (nuller == null)
{
nuller = new ForeignNuller(buffer, info);
cache.put(info, nuller);
}
instances.add(nuller);
}
}
instances.trimToSize();
return instances;
}
/**
* Set to <code>null</code> all foreign references to <code>referent</code>
* from related records. If there are no foreign key references to
* <code>referent</code>, this method returns immediately.
* <p>
* All records which are related to <code>referent</code> are collected,
* locked exclusively, and updated to null out their foreign reference back
* to the record represented by <code>referent</code>. Locks on these
* methods are restored to their previous state when this operation is
* complete.
* <p>
* No change is made unless <em>all</em> refering records can be locked.
* This method will attempt to obtain the locks repeatedly, until either
* all locks are obtained, or the user aborts locking.
*
* @param instances
* <code>ForeignNuller</code> instances which will be used to
* break foreign key references to <code>referent</code>.
* @param referent
* DMO representing a database record which is about to be
* deleted, which may be referenced via foreign key by other
* records.
*
* @throws PersistenceException
* if any reflection error occurs getting dependent records or
* setting foreign references;
* if there was an error locating the refering DMOs.
*/
static void breakLinkages(List<ForeignNuller> instances, Record referent)
throws PersistenceException
{
Iterator<ForeignNuller> iter;
ForeignNuller nuller = null;
outer:
while (true)
{
try
{
// Acquire exclusive locks for all local DMOs.
iter = instances.iterator();
while (iter.hasNext())
{
nuller = iter.next();
if (!nuller.acquireLocalLocks(referent))
{
continue outer;
}
}
// Break the foreign key reference from each local DMO to the
// foreign record.
iter = instances.iterator();
while (iter.hasNext())
{
nuller = iter.next();
nuller.breakLinkages(referent);
}
break outer;
}
catch (IllegalAccessException exc)
{
throw new PersistenceException(
"Error invoking method while breaking foreign reference",
exc);
}
catch (InvocationTargetException exc)
{
DBUtils.handleException(nuller.persistence.getDatabase(Persistence.PRIVATE_CTX), exc);
throw new PersistenceException(
"Error invoking method while breaking foreign reference",
exc);
}
finally
{
// Reset locks to their previous states.
iter = instances.iterator();
while (iter.hasNext())
{
nuller = iter.next();
nuller.resetLocalLocks();
}
}
}
}
/**
* Generate the HQL statement which will be used for the resolution of
* refering records to referent record (via foreign key). Uses the
* information stored in the relation descriptor to compose the query.
*
* @param info
* Relation descriptor.
*
* @return HQL query statement.
*/
private static String generateHQL(RelationInfo info)
{
String locAlias = info.getLocalAlias();
String fgnAlias = info.getForeignAlias();
String locImpl = info.getLocalClassName();
FQLExpression buf = new FQLExpression("from ");
buf.append(locImpl);
buf.append(" as ");
buf.append(locAlias);
buf.append(" where ");
buf.append(locAlias);
buf.append(".");
buf.append(fgnAlias);
buf.append("Record = ", true);
return buf.toFinalExpression();
}
/**
* Acquire an exclusive record lock for each DMO which references
* <code>referent</code> by a foreign key. Each local DMO which so
* references <code>referent</code> is retrieved by invoking a getter
* method for that record (in a one-to-one relation) or for a collection
* of records (in a one-to-many relation).
* <p>
* A context-local list of state objects is stored for this
* <code>ForeignNuller</code> object (because it can be used across client
* contexts). One {@link ForeignNuller.State} object is added to this
* list for each local DMO successfully locked. This context local list
* will be removed when the current scope is closed.
*
* @param referent
* DMO referenced by one or more refering DMOs via foreign key.
*
* @return <code>true</code> if all required locks were acquired properly;
* else <code>false</code>.
*
* @throws PersistenceException
* if there was an error locating the refering DMOs.
* @throws IllegalAccessException
* if the referent DMO's getter method for local DMO(s) is
* inaccessible.
* @throws InvocationTargetException
* if the referent DMO's getter method for local DMO(s) throws an
* exception.
*/
private boolean acquireLocalLocks(Record referent)
throws PersistenceException,
IllegalAccessException,
InvocationTargetException
{
Context ctx = context.get();
int expectedSize = 0;
Iterator<Record> iter = referers(referent, ctx);
while (iter.hasNext())
{
Record referer = iter.next();
expectedSize++;
acquireLock(referer, referent, ctx);
}
return (expectedSize == ctx.states.size());
}
/**
* Acquire an exclusive lock on <code>referer</code>, then optionally
* confirm that <code>referer</code> still has a foreign reference to
* <code>referent</code>. Create a new instance of the inner class
* {@link ForeignNuller.State}, which remembers the local DMO and the type
* of lock held before acquiring the exclusive lock, and add it to the
* <code>states</code> list.
*
* @param referer
* DMO to be locked exclusively.
* @param referent
* DMO which should be referenced by <code>referer</code> after
* it is locked.
* @param ctx
* Current context for this object. Contains a list to which a
* new <code>State</code> instance will be added after the locking
* and reference confirmation take place.
*
* @return <code>true</code> if the lock was acquired AND the confirmation
* of the referer to referent relationship was successful or
* unnecessary; <code>false</code> if the lock was acquired but
* the confirmation failed.
*
* @throws IllegalAccessException
* if the local DMO's getter method for the foreign DMO is
* inaccessible.
* @throws InvocationTargetException
* if the local DMO's getter method for the foreign DMO throws an
* exception.
*/
private boolean acquireLock(Record referer, Record referent, Context ctx)
throws IllegalAccessException,
InvocationTargetException
{
try
{
Long id = referer.primaryKey();
RecordIdentifier<String> ident = new RecordIdentifier<>(lockTarget, id);
LockType oldLockType = persistence.queryLock(ident, sharedDb);
if (!oldLockType.isExclusive())
{
// Acquire an exclusive lock temporarily.
RecordLockContext lockCtx = persistence.getRecordLockContext(sharedDb);
lockCtx.lock(ident, LockType.EXCLUSIVE);
}
else
{
// If record was locked coming into this method, there is no
// chance that the local DMO could not already reference the
// appropriate foreign DMO, so we can skip the subsequent
// confirmation check in this case.
ctx.states.add(new State(referer, oldLockType));
return true;
}
// Now that we've got the lock, check to make sure the locked, local
// record still refers back to the proper foreign DMO.
Record fgnDMO = (Record) foreignGetter.invoke(referer);
if (fgnDMO == null ||
referent == fgnDMO ||
referent.primaryKey().equals(fgnDMO.primaryKey()))
{
// Only add a new state to the list if the reference is still
// correct.
ctx.states.add(new State(referer, oldLockType));
return true;
}
}
catch (LockUnavailableException exc)
{
// Not thrown but Persistence.lock() method signature requires we
// catch or declare.
}
return false;
}
/**
* Reset all refering DMOs' record locks to their previous states (before
* an exclusive lock was acquired). This method relies on the presence of
* a context local list of <code>State</code> objects which indicate what
* the pre-lock status was. If in a transaction, the lock may be
* downgraded if appropriate to the previous state, but it will not be
* released until the end of the transaction.
*/
private void resetLocalLocks()
{
Context ctx = context.get();
try
{
Iterator<State> iter = ctx.states.iterator();
while (iter.hasNext())
{
State next = iter.next();
if (!next.oldLockType.isExclusive())
{
// Downgrade (or remove) the temporary, exclusive lock.
Long id = next.referer.primaryKey();
RecordIdentifier<String> ident = new RecordIdentifier<>(lockTarget, id);
RecordLockContext lockCtx = persistence.getRecordLockContext(sharedDb);
lockCtx.lock(ident, next.oldLockType);
}
}
}
catch (LockUnavailableException exc)
{
// Not thrown but Persistence.lock() method signature requires we
// catch or declare.
}
finally
{
ctx.referers = null;
ctx.states.clear();
}
}
/**
* Update the foreign key references from all DMOs currently refering to
* <code>referent</code>, such that they will instead refer to
* <code>null</code>. At the same time, remove the inverse association
* from the referent record to the refering DMO, to maintain the correct
* state on the other end of the bidirectional association (if indeed there
* was such an inverse reference at all).
*
* @param referent
* DMO being deleted.
*
* @throws PersistenceException
* if there was an error locating the refering DMOs.
* @throws IllegalAccessException
* if the refering DMO's setter method for the referent DMO is
* inaccessible.
* @throws InvocationTargetException
* if the refering DMO's setter method for the referent DMO throws
* an exception.
*/
private void breakLinkages(Record referent)
throws PersistenceException,
IllegalAccessException,
InvocationTargetException
{
Iterator<Record> iter = referers(referent, context.get());
while (iter.hasNext())
{
Record referer = iter.next();
// Update the foreign key.
foreignSetter.invoke(referer);
iter.remove();
}
}
/**
* Compose a list of DMOs which refer to the <code>referent</code> DMO via
* foreign key. This list is cached within a context local state object
* for the life of the process which breaks these foreign key linkages, so
* that it does not need to be retrieved separately for each stage of the
* linkage break process (lock acquisition, DMO updates, lock reset). Only
* the first call to this method during this cycle triggers the actual
* database access; subsequent calls use the cached list.
*
* @param referent
* DMO to which zero or more refering DMOs have a foreign key
* link.
* @param ctx
* Context local state object which stores the list of found DMOs.
*
* @throws PersistenceException
* if an error occurs looking up the refering DMOs in the
* database.
*/
private void getReferers(Record referent, Context ctx)
throws PersistenceException
{
if (ctx.referers != null)
{
return;
}
Object[] args = new Object[] { referent };
List<Record> list = persistence.list(hql, args, 0, 0);
if (list == null)
{
list = Collections.emptyList();
}
ctx.referers = list;
}
/**
* Return an iterator on all of the local DMOs related to the given,
* foreign DMO. If the association is one-to-one in nature, the iterator
* will return only a single record. If the association is one-to-many in
* nature, the iterator will return multiple records. The optional,
* remove operation will invoke the appropriate method to remove the
* inverse reference from the foreign DMO.
*
* @param referent
* DMO on the inverse end of the foreign key association.
* @param ctx
* Current context for this object, which maintains the list of
* found referers.
*
* @return Iterator on all DMOs which refer to <code>referent</code> via
* foreign key.
*
* @throws PersistenceException
* if there was any error locating the refering DMOs.
*/
private Iterator<Record> referers(final Record referent, final Context ctx)
throws PersistenceException
{
getReferers(referent, ctx);
if (ctx.referers.isEmpty())
{
return EmptyIterator.get();
}
if (unique)
{
// An iterator on a single record.
return new Iterator<Record>()
{
Record dmo = ctx.referers.get(0);
public boolean hasNext()
{
return (dmo != null);
}
public Record next()
{
if (dmo == null)
{
throw new NoSuchElementException();
}
Record p = dmo;
dmo = null;
return p;
}
public void remove()
{
try
{
// Dereference the local DMO from the foreign DMO.
localSetter.invoke(referent, new Object[] { null });
}
catch (Exception exc)
{
DBUtils.handleException(persistence.getDatabase(Persistence.PRIVATE_CTX), exc);
throw new RuntimeException(exc);
}
}
};
}
else
{
// An iterator on a set of records.
return new Iterator<Record>()
{
private Iterator<Record> iter = ctx.referers.iterator();
public boolean hasNext()
{
return iter.hasNext();
}
public Record next()
{
return iter.next();
}
public void remove()
{
iter.remove();
// After last element is removed, dereference the collection.
if (!iter.hasNext())
{
try
{
localSetter.invoke(referent);
}
catch (Exception exc)
{
DBUtils.handleException(persistence.getDatabase(Persistence.PRIVATE_CTX), exc);
throw new RuntimeException(exc);
}
}
}
};
}
}
/**
* Context local state maintained for the enclosing class. Maintains a
* list of DMOs which refer to the DMO to which linkages must be broken,
* as well as a list of previous lock states for those DMOs.
*/
private static class Context
{
/** List of pre-linkage break lock states for referer DMOs */
private final List<State> states = new ArrayList<>();
/** Transient list of DMOs which refer to current referent DMO */
private List<Record> referers = null;
}
/**
* Object which stores a reference to a local DMO and its lock status
* before its foreign reference is changed. One or more instances of this
* class are stored while the enclosing class is changing the foreign key
* reference of a target DMO.
*/
private static class State
{
/** Local DMO whose foreign reference is being changed */
private Record referer = null;
/** Previous lock type of local DMO; must be reset after operation */
private LockType oldLockType = null;
/**
* Convenience constructor.
*
* @param referer
* Local DMO whose foreign reference is being changed.
* @param oldLockType
* Previous lock type of local DMO; must be reset after
* operation.
*/
State(Record referer, LockType oldLockType)
{
this.referer = referer;
this.oldLockType = oldLockType;
}
}
}