AssociationSyncher.java

/*
** Module   : AssociationSyncher.java
** Abstract : Synchronizes records across foreign key associations
**
** Copyright (c) 2004-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- --JPRM-- -----------------------------------Description-----------------------------------
** 001 ECF 20061113   @31136 Created initial version. Abstract base class
**                           for classes which synchronize records across
**                           foreign key associations.
** 002 ECF 20061114   @31145 Disabled prohibitively expensive operations.
**                           This effectively removes all synchronization
**                           except setting the foreign DMO in a local DMO
**                           when a legacy key property in that local DMO
**                           is changed.
** 003 ECF 20070416   @33018 Integrated user interrupt handling.
** 004 ECF 20070704   @34357 Closed session flush loophole. A pending flush
**                           is now forced with the ChangeBroker after an
**                           association is updated.
** 005 ECF 20070911   @35282 Replaced database name string with Database
**                           information object. Required for non-local
**                           database connections.
** 006 ECF 20071219   @36576 Changed acquireLock() signature. Allow lock
**                           type to be specified.
** 007 CA  20080815   @39450 Support API change in DBUtils.
** 008 GES 20090421   @41823 Matched utility class package change.
** 009 ECF 20131029          Import change, minor housekeeping.
** 010 ECF 20160202          Replaced Apache commons logging with J2SE logging.
** 011 ECF 20200906          New ORM implementation.
** 012 GBB 20230512          Logging methods replaced by CentralLogger/ConversionStatus.
** 013 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 java.util.logging.*;
import com.goldencode.util.*;
import com.goldencode.p2j.persist.lock.*;
import com.goldencode.p2j.persist.orm.*;
import com.goldencode.p2j.util.logging.CentralLogger;

/**
 * The abstract base class for subclasses which synchronize either end of a
 * foreign key association, based upon legacy key properties being changed in
 * the record on the opposite end of the association.  In the pre-conversion
 * application, there are no formal, foreign keys.  Such associations between
 * records are defined less stringently;  the association between two records
 * is determined dynamically at runtime.  With the introduction of formal
 * foreign keys into the schema, it is necessary to mimic this very late
 * binding association by dynamically updating foreign keys as the values of
 * legacy key columns change.  In the P2J environment, a DMO joined to
 * another DMO by a foreign key contains a reference to that DMO (and
 * associated getter and setter methods).  It is these references which are
 * updated by this class and its subclasses.
 */
abstract class AssociationSyncher
{
   /** Logger */
   private static final CentralLogger LOG = CentralLogger.get(AssociationSyncher.class.getName());
   
   /** Object which abstracts methods to assist in managing the foreign DMO */
   protected final Helper helper;
   
   /** Method used to set foreign DMO into local DMO */
   protected final Method foreignSetter;
   
   /** Name of table for which temporary record locks must be acquired */
   protected final String lockTable;
   
   /** Record buffer being monitored for property changes */
   protected final RecordBuffer buffer;
   
   /** Set of temporary locks which are acquired to make updates */
   private final Set<TempLock> tempLocks = new HashSet<>();
   
   /**
    * Constructor.
    *
    * @param   buffer
    *          Record buffer containing the DMO whose property change(s)
    *          will trigger synchronization attempts.
    * @param   info
    *          Foreign relation descriptor object.
    * @param   lockClass
    *          DMO class for which temporary record locks must be acquired.
    */
   AssociationSyncher(RecordBuffer buffer, RelationInfo info, Class<? extends Record> lockClass)
   {
      this.buffer = buffer;
      
      lockTable = DatabaseManager.getBackingTableName(lockClass);
      
      buffer.addLegacyForeignKeys(info.getForeignProperties());
      
      if (info.isUnique())
      {
         helper = new OneToOneHelper(info);
      }
      else
      {
         helper = new OneToManyHelper(info);
      }
      
      String ifaceName = info.getForeignInterfaceName();
      String method = null;
      try
      {
         // store foreign record setter method
         Class<?> localIface = info.getLocalInterface();
         method = "set" + ifaceName + "Record";
         Class<?>[] sig = new Class<?>[] { info.getForeignInterface() };
         foreignSetter = localIface.getDeclaredMethod(method, sig);
      }
      catch (NoSuchMethodException exc)
      {
         throw new IllegalArgumentException(
           "Invalid DMO class:  missing foreign setter [" +
           info.getLocalInterfaceName() +
           "." +
           method +
           "]");
      }
   }
   
   /**
    * Perform record synchronization.  This method is optimized to avoid the
    * overhead of performing a database query if not absolutely necessary.
    *
    * @param   dirtyProperties
    *          The set of properties which have been modified, thereby
    *          triggering the synchronization request.
    *
    * @throws  PersistenceException
    *          if any error occurs resolving the foreign record.
    */
   public final void attemptSynch(Set<String> dirtyProperties)
   throws PersistenceException
   {
      if (buffer.getCurrentRecord() == null)
      {
         return;
      }
      
      Set<String> properties = getLegacyKeys();
      Iterator<String> iter = dirtyProperties.iterator();
      while (iter.hasNext())
      {
         // If any legacy key property has been updated, a synch is needed.
         if (properties.contains(iter.next()))
         {
            doSynch();
            
            break;
         }
      }
   }
   
   /**
    * Retrieve the set of property names which represent the columns which
    * comprise the legacy key joining two records.  Changes to these
    * properties trigger synchronization of the two ends of a foreign key
    * association.  A set is returned because legacy keys are often
    * composites of multiple columns.
    *
    * @return  Set of property names comprising the legacy key.
    */
   protected abstract Set<String> getLegacyKeys();
   
   /**
    * Subclasses which need to remove any existing association linkages
    * before the new association is applied must implement this method.
    *
    * @param   newLocalDMOs
    *          Set of zero or more local DMOs which are about to be updated
    *          with the new association.
    * @param   newForeignDMO
    *          Foreign DMO which is about to be updated with the new
    *          association.
    *
    * @throws  IllegalAccessException
    *          if an access error occurs while invoking a method using
    *          reflection.
    * @throws  InvocationTargetException
    *          if a method invoked using reflection throws an exception.
    */
   protected abstract void preSynch(Set<Record> newLocalDMOs, Record newForeignDMO)
   throws IllegalAccessException,
          InvocationTargetException;
   
   /**
    * Retrieve the set of DMOs which comprise the local end of the foreign
    * association.  In cases where the foreign end of the association is
    * driving the synchronization, these will represent the "new" local
    * records which are resolved, based upon the new property values in one
    * of the foreign record's legacy keys.
    * <p>
    * The implementing class should obtain an exclusive lock on each record
    * returned, using the {@link #acquireLock} method to ensure it is
    * properly released or downgraded upon completion of synchronization.
    * This is not necessary for records already exclusively locked.
    * <p>
    * If the association is one-to-one, the set will contain zero or one
    * records.
    * <p>
    * If the association is one-to-many, the set may contain zero or more
    * records.
    *
    * @return  Set of newly resolved local DMOs.  The set may be empty, but
    *          it will not be <code>null</code>.
    *
    * @throws  PersistenceException
    *          if there are any errors retrieving DMOs from the database.
    */
   protected abstract Set<Record> retrieveLocalDMOs()
   throws PersistenceException;
   
   /**
    * Retrieve the DMO, if any, at the foreign end of the association.  In
    * cases where the local end of the association is driving the
    * synchronization, this will represent the "new" foreign record which is
    * resolved, based upon the new property values in one of the local
    * record's legacy keys.
    * <p>
    * The implementing class should obtain an exclusive lock on the record
    * returned, using the {@link #acquireLock} method to ensure it is
    * properly released or downgraded upon completion of synchronization.
    * This is not necessary if the record is already exclusively locked.
    *
    * @return  Foreign DMO, or <code>null</code> if none was available.
    *
    * @throws  PersistenceException
    *          if there are any errors retrieving the DMO from the database.
    */
   protected abstract Record retrieveForeignDMO()
   throws PersistenceException;
   
   /**
    * Acquire a temporary lock of the specified type for the given primary key
    * ID.  The table in which the record with the specified ID resides was
    * stored during construction of this object.  The lock is released when it
    * is no longer needed.
    *
    * @param   id
    *          ID of the record to lock.
    * @param   lockType
    *          Lock type to acquire for the record.
    * 
    * @throws  LockUnavailableException
    *          if a NO-WAIT lock type variant is requested, but the lock
    *          cannot be acquired immediately.
    */
   protected final void acquireLock(Long id, LockType lockType)
   throws LockUnavailableException
   {
      boolean sharedDb = !buffer.getDmoInfo().multiTenant;
      TempLock lock = new TempLock(buffer.getPersistence(), lockType, lockTable, id, sharedDb);
      tempLocks.add(lock);
   }
   
   /**
    * Perform foreign association synchronization.  This will update the
    * appropriate DMO references on both ends of a bidirectional, foreign key
    * association.
    *
    * @throws  PersistenceException
    *          if any error occurs resolving records.
    */
   private void doSynch()
   throws PersistenceException
   {
      try
      {
         Set<Record> localDMOs = retrieveLocalDMOs();
         Record foreignDMO = retrieveForeignDMO();
         
//       preSynch(localDMOs, foreignDMO);
         
         // Update the local DMO(s) referenced by the foreign end of the association.
//       helper.replace(foreignDMO, localDMOs);
         
         // Update the foreign DMO referenced by the local end(s) of the association.
//       ChangeBroker broker = ChangeBroker.get();
//       Persistence persistence = buffer.getPersistence();
         Iterator<Record> iter = localDMOs.iterator();
         while (iter.hasNext())
         {
            Record next = iter.next();
            
            // Update the foreign record in the local DMO.
            foreignSetter.invoke(next, foreignDMO);
            
            // Force a pending flush for the updated DMO type.
//          Class<? extends Record> dmoClass = next.getClass();
//          broker.forcePendingFlush(persistence, dmoClass);
         }
      }
      catch (IllegalAccessException exc)
      {
         // Programming error.
         throw new RuntimeException(exc);
      }
      catch (InvocationTargetException exc)
      {
         DBUtils.handleException(buffer.getDatabase(), exc);
         
         // Programming error.
         throw new RuntimeException(exc);
      }
      catch (PersistenceException exc)
      {
         if (LOG.isLoggable(Level.SEVERE))
         {
            LOG.log(Level.SEVERE,
                    buffer.describe() + ":  Error synchronizing foreign association",
                    exc);
         }
         exc.fillInStackTrace();
         throw exc;
      }
      finally
      {
         // Release temporary locks.
         Iterator<TempLock> iter = tempLocks.iterator();
         while (iter.hasNext())
         {
            TempLock lock = iter.next();
            lock.reset();
         }
         
         tempLocks.clear();
      }
   }
   
   /**
    * An interface which abstracts a helper API for managing a foreign DMO.
    * The abstraction hides the implementation differences between DMOs which
    * participate in a one-to-one association versus those that participate
    * in a one-to-many (many-to-one) association.
    */
   protected interface Helper
   {
      /**
       * Retrieve the set of local DMOs stored in a foreign DMO for this
       * association.
       *
       * @param   foreignDMO
       *          Foreign DMO from which to extract the set of local DMOs.
       *
       * @return  Set of local DMOs referenced by the given foreign DMO.  The
       *          set may be empty, but it will not be <code>null</code>.
       *
       * @throws  IllegalAccessException
       *          if an access error occurs while invoking a method using
       *          reflection.
       * @throws  InvocationTargetException
       *          if a method invoked using reflection throws an exception.
       */
      public abstract Set<Record> getLocalDMOs(Record foreignDMO)
      throws IllegalAccessException,
             InvocationTargetException;
      
      /**
       * Replace the set of local DMOs stored in a foreign DMO for this
       * association.
       *
       * @param   foreignDMO
       *          Foreign DMO which stores a reference to local DMOs.
       * @param   localDMOs
       *          The set of local DMOs which must replace the existing set.
       *
       * @throws  IllegalAccessException
       *          if an access error occurs while invoking a method using
       *          reflection.
       * @throws  InvocationTargetException
       *          if a method invoked using reflection throws an exception.
       */
      public abstract void replace(Record foreignDMO, Set<Record> localDMOs)
      throws IllegalAccessException,
             InvocationTargetException;
      
      /**
       * Remove the given local DMO from the set of local DMOs stored in a
       * foreign DMO for this association.
       *
       * @param   foreignDMO
       *          Foreign DMO which stores a reference to local DMOs.
       * @param   localDMO
       *          The local DMO to be removed from the existing set.
       *
       * @throws  IllegalAccessException
       *          if an access error occurs while invoking a method using
       *          reflection.
       * @throws  InvocationTargetException
       *          if a method invoked using reflection throws an exception.
       */
      public abstract void remove(Record foreignDMO, Record localDMO)
      throws IllegalAccessException,
             InvocationTargetException;
   }
   
   /**
    * An implementation of the <code>Helper</code> interface which assumes
    * that there is no more than one record on either end of the association.
    * This has implications for the methods used to retrieve and update the
    * foreign record's reference to the local end of the association.
    */
   protected final class OneToOneHelper
   implements Helper
   {
      /** Method used to get local DMO from foreign DMO */
      private final Method getter;
      
      /** Method used to set local DMO into foreign DMO */
      private final Method setter;
      
      /**
       * Constructor.  Looks up reflective methods used to get and set the
       * foreign DMO's reference to a local DMO.
       *
       * @param   info
       *          Object which contains information regarding the nature of
       *          the one-to-one, foreign association.
       */
      OneToOneHelper(RelationInfo info)
      {
         String method = null;
         String ifaceName = info.getLocalInterfaceName();
         try
         {
            Class<? extends DataModelObject> foreignIface = info.getForeignInterface();
            
            // store inverse (i.e., local) record getter method
            StringBuilder buf = new StringBuilder("get");
            buf.append(ifaceName);
            buf.append("Record");
            method = buf.toString();
            getter = foreignIface.getDeclaredMethod(method, (Class<?>[]) null);
            
            // store inverse (i.e., local) record setter method
            buf.setLength(0);
            buf.append("set");
            buf.append(ifaceName);
            buf.append("Record");
            method = buf.toString();
            Class<?>[] sig = new Class[] { info.getLocalInterface() };
            setter = foreignIface.getDeclaredMethod(method, sig);
         }
         catch (NoSuchMethodException exc)
         {
            throw new IllegalArgumentException(
              "Invalid DMO class:  missing inverse getter or setter [" +
              info.getForeignInterfaceName() +
              "." +
              method +
              "]");
         }
      }
      
      /**
       * Retrieve the set of local DMOs stored in a foreign DMO for this
       * association.  Since this helper implementation manages a one-to-one
       * association, the set will never contain more than one record.
       *
       * @param   foreignDMO
       *          Foreign DMO from which to extract the set of local DMOs.
       *
       * @return  Set of local DMOs referenced by the given foreign DMO.  The
       *          set may be empty, but it will not be <code>null</code>.
       *
       * @throws  IllegalAccessException
       *          if an access error occurs while invoking a method using
       *          reflection.
       * @throws  InvocationTargetException
       *          if a method invoked using reflection throws an exception.
       */
      public Set<Record> getLocalDMOs(Record foreignDMO)
      throws IllegalAccessException,
             InvocationTargetException
      {
         Record dmo = (Record) getter.invoke(foreignDMO, (Object[]) null);
         
         return (dmo == null) ? new EmptySet<>() : Collections.singleton(dmo);
      }
      
      /**
       * Replace the local DMO stored in a foreign DMO for this association
       * with the contents of the given set.
       *
       * @param   foreignDMO
       *          Foreign DMO which stores a reference to local DMOs.
       * @param   localDMOs
       *          The set of local DMOs which must replace the existing set.
       *          Since this helper implementation manages a one-to-one
       *          association, this set must not have more than one element.
       *          However, it may be empty, in which case the local DMO
       *          reference in the foreign DMO will be nulled out.
       *
       * @throws  IllegalAccessException
       *          if an access error occurs while invoking a method using
       *          reflection.
       * @throws  InvocationTargetException
       *          if a method invoked using reflection throws an exception.
       * @throws  IllegalArgumentException
       *          if <code>localDMOs</code> contains more than one element.
       */
      public void replace(Record foreignDMO, Set<Record> localDMOs)
      throws IllegalAccessException,
             InvocationTargetException
      {
         if (foreignDMO == null)
         {
            return;
         }
         
         Record localDMO = null;
         Iterator<Record> iter = localDMOs.iterator();
         
         if (iter.hasNext())
         {
            localDMO = iter.next();
         }
         
         if (iter.hasNext())
         {
            throw new IllegalArgumentException(
               "Cannot set multiple DMOs using method " + setter.getName());
         }
         
         // update the local record in the foreign DMO.  Foreign keys are never constrained to be
         // not-null, so a null parameter is OK.
         setter.invoke(foreignDMO, localDMO);
      }
      
      /**
       * Remove the reference to the given local DMO from the given foreign
       * DMO for this association.  This implementation actually ignores the
       * <code>localDMO</code> argument and assumes the intent of the caller
       * is simply to remove the reference entirely.
       *
       * @param   foreignDMO
       *          Foreign DMO which stores a reference to local DMOs.
       * @param   localDMO
       *          The local DMO to be removed from the existing set.
       *
       * @throws  IllegalAccessException
       *          if an access error occurs while invoking a method using
       *          reflection.
       * @throws  InvocationTargetException
       *          if a method invoked using reflection throws an exception.
       */
      public void remove(Record foreignDMO, Record localDMO)
      throws IllegalAccessException,
             InvocationTargetException
      {
         // Create a set containing only a null reference and pass it to the
         // updateLocal method.  This makes a simplifying assumption that
         // localDMO is the current, local DMO set in the foreign record.
         Set<Record> localDMOs = Collections.singleton(null);
         replace(foreignDMO, localDMOs);
      }
   }
   
   /**
    * An implementation of the <code>Helper</code> interface which assumes
    * a one-to-many association from a foreign DMO to zero or more local
    * DMOs.  This has implications for the methods used to retrieve and
    * update the foreign record's reference to the local end of the
    * association.
    */
   protected final class OneToManyHelper
   implements Helper
   {
      /** Object containing information about the nature of the association */
      private final RelationInfo info;
      
      /** Method used to retrieve set of local DMOs from foreign DMO */
      private final Method getter;
      
      /** Method used to set local DMO set into foreign DMO */
      private Method setter = null;
      
      /**
       * Constructor.  Looks up reflective method used to get the set of
       * local DMOs referenced by the foreign end of the association.
       *
       * @param   info
       *          Object which contains information regarding the nature of
       *          the one-to-many, foreign association.
       */
      OneToManyHelper(RelationInfo info)
      {
         this.info = info;
         String method = null;
         String ifaceName = info.getLocalInterfaceName();
         try
         {
            // store inverse (i.e., local) records getter method
            Class<? extends DataModelObject> foreignIface = info.getForeignInterface();
            method = "get" + ifaceName + "Set";
            Class<?>[] sig = new Class[] {};
            getter = foreignIface.getDeclaredMethod(method, sig);
         }
         catch (NoSuchMethodException exc)
         {
            throw new IllegalArgumentException(
              "Invalid DMO class:  missing inverse getter [" +
              info.getForeignInterfaceName() +
              "." +
              method +
              "]");
         }
      }
      
      /**
       * Retrieve the set of local DMOs stored in a foreign DMO for this
       * association.
       *
       * @param   foreignDMO
       *          Foreign DMO from which to extract the set of local DMOs.
       *
       * @return  Set of local DMOs referenced by the given foreign DMO.  The
       *          set may be empty, but it will not be <code>null</code>.
       *
       * @throws  IllegalAccessException
       *          if an access error occurs while invoking a method using
       *          reflection.
       * @throws  InvocationTargetException
       *          if a method invoked using reflection throws an exception.
       */
      public Set<Record> getLocalDMOs(Record foreignDMO)
      throws IllegalAccessException,
             InvocationTargetException
      {
         Set<Record> set = (Set<Record>) getter.invoke(foreignDMO, (Object[]) null);
         
         return (set == null) ? new EmptySet<>() : set;
      }
      
      /**
       * Replace the set of local DMOs stored in a foreign DMO for this
       * association.
       *
       * @param   foreignDMO
       *          Foreign DMO which stores a reference to local DMOs.
       * @param   localDMOs
       *          The set of local DMOs which must replace the existing set.
       *
       * @throws  IllegalAccessException
       *          if an access error occurs while invoking a method using
       *          reflection.
       * @throws  InvocationTargetException
       *          if a method invoked using reflection throws an exception.
       */
      public void replace(Record foreignDMO, Set<Record> localDMOs)
      throws IllegalAccessException,
             InvocationTargetException
      {
         if (foreignDMO == null)
         {
            return;
         }
         
         // retrieve from the foreign DMO the current set of local DMOs which reference it
         Set<Record> set = (Set<Record>) getter.invoke(foreignDMO, (Object[]) null);
         
         if (set != null)
         {
            set.retainAll(localDMOs);
         }
         else
         {
            if (setter == null)
            {
               String ifaceName = info.getLocalInterfaceName();
               String method = null;
               try
               {
                  // store inverse (i.e., local) record setter method
                  Class<? extends DataModelObject> foreignIface = info.getForeignInterface();
                  method = "set" + ifaceName + "Set";
                  Class<?>[] sig = new Class[] { Set.class };
                  setter = foreignIface.getDeclaredMethod(method, sig);
               }
               catch (NoSuchMethodException exc)
               {
                  throw new IllegalArgumentException(
                    "Invalid DMO class:  missing inverse setter [" +
                    info.getForeignInterfaceName() +
                     "." +
                    method +
                   "]");
               }
            }
            
            setter.invoke(foreignDMO, localDMOs);
         }
      }
      
      /**
       * Remove the given local DMO from the set of local DMOs stored in a
       * foreign DMO for this association.
       *
       * @param   foreignDMO
       *          Foreign DMO which stores a reference to local DMOs.
       * @param   localDMO
       *          The local DMO to be removed from the existing set.
       *
       * @throws  IllegalAccessException
       *          if an access error occurs while invoking a method using
       *          reflection.
       * @throws  InvocationTargetException
       *          if a method invoked using reflection throws an exception.
       */
      public void remove(Record foreignDMO, Record localDMO)
      throws IllegalAccessException,
             InvocationTargetException
      {
         // retrieve from the foreign DMO the current set of local DMOs which reference it
         Set<Record> set = (Set<Record>) getter.invoke(foreignDMO, (Object[]) null);
         
         if (set != null)
         {
            set.remove(localDMO);
         }
      }
   }
}