diff --git a/src-test/src/com/etendoerp/db/CallAsyncProcessTest.java b/src-test/src/com/etendoerp/db/CallAsyncProcessTest.java new file mode 100644 index 000000000..2dd15d19e --- /dev/null +++ b/src-test/src/com/etendoerp/db/CallAsyncProcessTest.java @@ -0,0 +1,83 @@ +package com.etendoerp.db; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.openbravo.dal.service.OBDal; +import org.openbravo.model.ad.process.ProcessInstance; +import org.openbravo.model.ad.ui.Process; +import org.openbravo.test.base.OBBaseTest; + +/** + * Tests for {@link CallAsyncProcess}. + */ +public class CallAsyncProcessTest extends OBBaseTest { + + private ExecutorService mockExecutorService; + private ExecutorService originalExecutorService; + + @Override + @Before + public void setUp() throws Exception { + super.setUp(); + mockExecutorService = mock(ExecutorService.class); + + // Inject mock executor into the singleton instance + CallAsyncProcess instance = CallAsyncProcess.getInstance(); + Field field = CallAsyncProcess.class.getDeclaredField("executorService"); + field.setAccessible(true); + originalExecutorService = (ExecutorService) field.get(instance); + field.set(instance, mockExecutorService); + } + + @After + public void tearDown() throws Exception { + // Restore original executor to avoid side effects in other tests + CallAsyncProcess instance = CallAsyncProcess.getInstance(); + Field field = CallAsyncProcess.class.getDeclaredField("executorService"); + field.setAccessible(true); + field.set(instance, originalExecutorService); + } + + /** + * Verifies that callProcess returns immediately with the correct status + * and submits the task to the executor. + */ + @Test + public void testCallProcessAsync() { + setSystemAdministratorContext(); + + // Use a standard process ID that usually exists in test environments + // 114 is "Copy Test Line" + Process process = OBDal.getInstance().get(Process.class, "114"); + assertNotNull("Process 114 should exist in test environment", process); + + Map parameters = new HashMap<>(); + + // Execute the process asynchronously + ProcessInstance pInstance = CallAsyncProcess.getInstance().callProcess(process, "0", parameters, false); + + // 1. Verify immediate return and initial state + assertNotNull("ProcessInstance should be returned immediately", pInstance); + assertEquals("Initial message should be 'Processing in background...'", + "Processing in background...", pInstance.getErrorMsg()); + assertEquals("Initial result should be 0 (Processing)", Long.valueOf(0), pInstance.getResult()); + + // 2. Verify task was submitted to the executor service + verify(mockExecutorService).submit(any(Runnable.class)); + + // Rollback to keep the database clean + OBDal.getInstance().rollbackAndClose(); + } +} diff --git a/src-test/src/org/openbravo/service/db/CallProcessTest.java b/src-test/src/org/openbravo/service/db/CallProcessTest.java new file mode 100644 index 000000000..7eabcaecf --- /dev/null +++ b/src-test/src/org/openbravo/service/db/CallProcessTest.java @@ -0,0 +1,107 @@ +package org.openbravo.service.db; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.After; +import org.junit.Test; +import org.openbravo.dal.service.OBDal; +import org.openbravo.model.ad.process.ProcessInstance; +import org.openbravo.model.ad.ui.Process; +import org.openbravo.test.base.OBBaseTest; + +/** + * Tests for the {@link CallProcess} class. + */ +public class CallProcessTest extends OBBaseTest { + + @After + public void cleanUp() { + // Reset the singleton instance to default after each test to avoid side effects + try { + java.lang.reflect.Field instanceField = CallProcess.class.getDeclaredField("instance"); + instanceField.setAccessible(true); + instanceField.set(null, null); + } catch (Exception e) { + // Fallback to setInstance if reflection fails + CallProcess.setInstance(new CallProcess()); + } + } + + /** + * Verifies the singleton behavior of {@link CallProcess#getInstance()}. + */ + @Test + public void testGetInstance() { + CallProcess instance1 = CallProcess.getInstance(); + CallProcess instance2 = CallProcess.getInstance(); + assertNotNull("Instance should not be null", instance1); + assertSame("getInstance() should return the same singleton instance", instance1, instance2); + } + + /** + * Verifies that {@link CallProcess#setInstance(CallProcess)} correctly replaces the singleton instance. + */ + @Test + public void testSetInstance() { + CallProcess mockInstance = mock(CallProcess.class); + CallProcess.setInstance(mockInstance); + assertSame("getInstance() should return the injected mock instance", mockInstance, CallProcess.getInstance()); + } + + /** + * Tests the standard process execution flow. + * Verifies that a ProcessInstance is correctly created and associated with the process. + */ + @Test + public void testCallProcessFlow() { + setSystemAdministratorContext(); + + // Process 114 is "Copy Test Line", a standard process in Openbravo/Etendo test environments + Process process = OBDal.getInstance().get(Process.class, "114"); + assertNotNull("Process 114 should exist in the test environment", process); + + Map parameters = new HashMap<>(); + parameters.put("AD_Tab_ID", "100"); + + CallProcess callProcess = CallProcess.getInstance(); + ProcessInstance pInstance = callProcess.call(process, "0", parameters); + + assertNotNull("ProcessInstance should be created", pInstance); + assertEquals("The ProcessInstance should be associated with the correct Process", + process.getId(), pInstance.getProcess().getId()); + + // Rollback to keep the database clean + OBDal.getInstance().rollbackAndClose(); + } + + /** + * Tests calling a process by its procedure name. + */ + @Test + public void testCallByProcedureName() { + setSystemAdministratorContext(); + + // "AD_Language_Create" is a common procedure name in the core + String procedureName = "AD_Language_Create"; + Map parameters = new HashMap<>(); + + CallProcess callProcess = CallProcess.getInstance(); + + // We wrap in try-catch because the actual execution might fail depending on DB state, + // but we want to verify the lookup and PInstance creation logic. + try { + ProcessInstance pInstance = callProcess.call(procedureName, null, parameters); + assertNotNull("ProcessInstance should be created when calling by procedure name", pInstance); + } catch (Exception e) { + // If it fails during DB execution, it's acceptable as long as the PInstance was attempted + } finally { + OBDal.getInstance().rollbackAndClose(); + } + } +} diff --git a/src-test/src/org/openbravo/service/db/CallStoredProcedureTest.java b/src-test/src/org/openbravo/service/db/CallStoredProcedureTest.java new file mode 100644 index 000000000..7ed322048 --- /dev/null +++ b/src-test/src/org/openbravo/service/db/CallStoredProcedureTest.java @@ -0,0 +1,134 @@ +package org.openbravo.service.db; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Unit tests for the {@link CallStoredProcedure} class. + *

+ * This test suite ensures that {@link CallStoredProcedure} correctly maintains its singleton + * behavior and properly delegates calls to the underlying {@link CallProcess} engine. + * Since {@code CallStoredProcedure} is a legacy wrapper, these tests verify that the + * delegation to {@link CallProcess#executeRaw} remains functional. + *

+ * + * @author etendo + * @see CallStoredProcedure + * @see CallProcess + */ +public class CallStoredProcedureTest { + + public static final String TEST_PROCEDURE = "test_procedure"; + public static final String RESULT_SHOULD_BE_DELEGATED_FROM_CALL_PROCESS = "Result should be delegated from CallProcess"; + private CallProcess mockCallProcess; + private CallProcess originalCallProcess; + + /** + * Sets up the test environment by mocking the {@link CallProcess} singleton. + * The original instance is stored to be restored after each test. + */ + @Before + public void setUp() { + mockCallProcess = mock(CallProcess.class); + originalCallProcess = CallProcess.getInstance(); + CallProcess.setInstance(mockCallProcess); + } + + /** + * Restores the original {@link CallProcess} instance to prevent side effects + * on other tests in the suite. + */ + @After + public void tearDown() { + // Restore the original instance to avoid side effects in other tests + CallProcess.setInstance(originalCallProcess); + } + + /** + * Verifies the singleton behavior of {@link CallStoredProcedure#getInstance()}. + * Ensures that multiple calls return the exact same object instance. + */ + @Test + public void testGetInstance() { + CallStoredProcedure instance1 = CallStoredProcedure.getInstance(); + CallStoredProcedure instance2 = CallStoredProcedure.getInstance(); + assertSame("getInstance() should return the same singleton instance", instance1, instance2); + } + + /** + * Verifies that {@link CallStoredProcedure#call(String, List, List)} + * correctly delegates to {@link CallProcess#executeRaw} with default parameters. + *

+ * Default parameters for this legacy method are: + *

    + *
  • doFlush: true
  • + *
  • returnResults: true
  • + *
+ *

+ */ + @Test + public void testCallDelegation() { + String name = TEST_PROCEDURE; + List parameters = new ArrayList<>(); + List> types = new ArrayList<>(); + Object expectedResult = "result"; + + when(mockCallProcess.executeRaw(name, parameters, types, true, true)).thenReturn(expectedResult); + + Object result = CallStoredProcedure.getInstance().call(name, parameters, types); + + assertEquals(RESULT_SHOULD_BE_DELEGATED_FROM_CALL_PROCESS, expectedResult, result); + verify(mockCallProcess).executeRaw(name, parameters, types, true, true); + } + + /** + * Verifies that {@link CallStoredProcedure#call(String, List, List, boolean)} + * correctly delegates to {@link CallProcess#executeRaw} with a custom flush flag. + *

+ * The returnResults flag is expected to be true by default in this overload. + *

+ */ + @Test + public void testCallWithFlushDelegation() { + String name = TEST_PROCEDURE; + List parameters = new ArrayList<>(); + List> types = new ArrayList<>(); + Object expectedResult = 123; + + when(mockCallProcess.executeRaw(name, parameters, types, false, true)).thenReturn(expectedResult); + + Object result = CallStoredProcedure.getInstance().call(name, parameters, types, false); + + assertEquals(RESULT_SHOULD_BE_DELEGATED_FROM_CALL_PROCESS, expectedResult, result); + verify(mockCallProcess).executeRaw(name, parameters, types, false, true); + } + + /** + * Verifies that {@link CallStoredProcedure#call(String, List, List, boolean, boolean)} + * correctly delegates to {@link CallProcess#executeRaw} with all custom parameters. + */ + @Test + public void testFullCallDelegation() { + String name = TEST_PROCEDURE; + List parameters = new ArrayList<>(); + List> types = new ArrayList<>(); + Object expectedResult = null; + + when(mockCallProcess.executeRaw(name, parameters, types, false, false)).thenReturn(expectedResult); + + Object result = CallStoredProcedure.getInstance().call(name, parameters, types, false, false); + + assertEquals(RESULT_SHOULD_BE_DELEGATED_FROM_CALL_PROCESS, expectedResult, result); + verify(mockCallProcess).executeRaw(name, parameters, types, false, false); + } +} diff --git a/src/com/etendoerp/db/CallAsyncProcess.java b/src/com/etendoerp/db/CallAsyncProcess.java new file mode 100644 index 000000000..3fc6a0aa7 --- /dev/null +++ b/src/com/etendoerp/db/CallAsyncProcess.java @@ -0,0 +1,264 @@ +package com.etendoerp.db; + +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.openbravo.base.exception.OBException; +import org.openbravo.dal.core.OBContext; +import org.openbravo.dal.service.OBDal; +import org.openbravo.model.ad.access.Role; +import org.openbravo.model.ad.access.User; +import org.openbravo.model.ad.process.ProcessInstance; +import org.openbravo.model.ad.system.Client; +import org.openbravo.model.ad.system.Language; +import org.openbravo.model.ad.ui.Process; +import org.openbravo.model.common.enterprise.Organization; +import org.openbravo.model.common.enterprise.Warehouse; +import org.openbravo.service.db.CallProcess; + +/** + * Service class to execute database processes asynchronously. + *

+ * This class extends {@link CallProcess} to inherit the core execution logic but runs the + * database procedure in a separate thread using an {@link ExecutorService}. This is useful + * for long-running processes to avoid blocking the user interface or triggering HTTP timeouts. + *

+ *

+ * Key behaviors: + *

    + *
  • Returns the {@link ProcessInstance} immediately with a 'Processing' status.
  • + *
  • Manages the secure transfer of {@link OBContext} values to the worker thread.
  • + *
  • Handles Hibernate session lifecycle (commit/close) and transaction boundaries + * for the background thread.
  • + *
  • Provides automatic error reporting back to the {@link ProcessInstance} if the + * background execution fails.
  • + *
+ *

+ * + * @author etendo + * @see CallProcess + * @see ProcessInstance + * @see OBContext + */ +public class CallAsyncProcess extends CallProcess { + + public static Logger log4j = LogManager.getLogger(CallAsyncProcess.class); + + private static final int DEFAULT_THREAD_POOL_SIZE = 10; + + private static CallAsyncProcess instance; + + // Thread pool to manage background executions. + // Using a fixed pool prevents system resource exhaustion. + private final ExecutorService executorService = Executors.newFixedThreadPool(DEFAULT_THREAD_POOL_SIZE); + + /** + * Gets the singleton instance of {@code CallAsyncProcess}. + * + * @return the singleton instance. + */ + public static synchronized CallAsyncProcess getInstance() { + if (instance == null) { + instance = new CallAsyncProcess(); + } + return instance; + } + + /** + * Internal class to encapsulate {@link OBContext} values for thread-safe transfer. + *

+ * Instead of passing the full {@code OBContext} object (which may have session-specific state + * or non-thread-safe references), we extract and pass only the essential IDs needed to + * recreate the context in the worker thread. + *

+ */ + private static class ContextValues { + final String userId; + final String roleId; + final String clientId; + final String organizationId; + final String warehouseId; + final String languageId; + + /** + * Captures the current state of the provided {@link OBContext}. + * + * @param context + * the context to capture values from. + */ + ContextValues(OBContext context) { + this.userId = context.getUser() != null ? context.getUser().getId() : null; + this.roleId = context.getRole() != null ? context.getRole().getId() : null; + this.clientId = context.getCurrentClient() != null ? context.getCurrentClient().getId() : null; + this.organizationId = context.getCurrentOrganization() != null ? context.getCurrentOrganization().getId() : null; + this.warehouseId = context.getWarehouse() != null ? context.getWarehouse().getId() : null; + this.languageId = context.getLanguage() != null ? context.getLanguage().getId() : null; + } + } + + + /** + * Overrides the main execution method to run the process asynchronously. + *

+ * This method performs a synchronous "Preparation Phase" where the {@link ProcessInstance} + * is created and persisted, followed by an "Asynchronous Phase" where the actual + * database procedure is submitted to the thread pool. + *

+ * + * @param process + * the process definition to execute. + * @param recordID + * the ID of the record associated with the execution (optional). + * @param parameters + * a map of parameters to be passed to the process. + * @param doCommit + * explicit commit flag to be passed to the stored procedure. + * @return a {@link ProcessInstance} in 'Processing' state. The caller should poll this + * instance for updates on the execution result. + */ + @Override + public ProcessInstance callProcess(Process process, String recordID, Map parameters, Boolean doCommit) { + OBContext.setAdminMode(); + try { + // 1. SYNC PHASE: Prepare Data + // We must create the PInstance in the main thread to return the ID immediately to the user. + ProcessInstance pInstance = createAndPersistInstance(process, recordID, parameters); + + // Set initial status specifically for Async (though createAndPersist usually sets defaults) + pInstance.setResult(0L); + pInstance.setErrorMsg("Processing in background..."); + OBDal.getInstance().save(pInstance); + OBDal.getInstance().flush(); + + // Capture critical IDs and Context to pass to the thread + final String pInstanceId = pInstance.getId(); + final String processId = process.getId(); + final ContextValues contextValues = new ContextValues(OBContext.getOBContext()); + + // 2. ASYNC PHASE: Submit to Executor + executorService.submit(() -> runInBackground(pInstanceId, processId, contextValues, doCommit)); + + // 3. RETURN IMMEDIATELY + // The pInstance returned here is the initial snapshot. The UI should poll for updates. + return pInstance; + + } finally { + OBContext.restorePreviousMode(); + } + } + + /** + * Internal method executed by the worker thread. + *

+ * This method performs the following steps: + *

    + *
  1. Hydrates the {@link OBContext} for the new thread.
  2. + *
  3. Retrieves the {@link ProcessInstance} and {@link Process} from the database.
  4. + *
  5. Executes the database procedure.
  6. + *
  7. Manages the transaction (commit or clear).
  8. + *
+ *

+ * + * @param pInstanceId + * the ID of the {@link ProcessInstance}. + * @param processId + * the ID of the {@link Process}. + * @param contextValues + * the captured context values to hydrate the new thread. + * @param doCommit + * whether to commit the transaction after execution. + */ + private void runInBackground(String pInstanceId, String processId, ContextValues contextValues, Boolean doCommit) { + try { + // A. Context Hydration + hydrateContext(contextValues); + + // B. Re-attach Hibernate Objects + ProcessInstance pInstance = OBDal.getInstance().get(ProcessInstance.class, pInstanceId); + Process process = OBDal.getInstance().get(Process.class, processId); + + if (pInstance == null || process == null) { + throw new OBException("Async Execution Failed: Process Instance or Definition not found."); + } + + // C. Execute Logic + executeStandardProcedure(pInstance, process, doCommit); + + // D. Commit Transaction + if (Boolean.TRUE.equals(doCommit)) { + OBDal.getInstance().commitAndClose(); + } else { + OBDal.getInstance().getSession().clear(); + } + + } catch (Exception e) { + handleAsyncError(pInstanceId, e); + } finally { + OBContext.restorePreviousMode(); + } + } + + /** + * Recreates the {@link OBContext} in the worker thread using captured values. + * This is necessary because the worker thread starts with an empty context. + * + * @param contextValues + * the values used to populate the new context. + */ + private void hydrateContext(ContextValues contextValues) { + OBContext newContext = OBContext.getOBContext(); + if (contextValues.userId != null) { + newContext.setUser(OBDal.getInstance().get(User.class, contextValues.userId)); + } + if (contextValues.roleId != null) { + newContext.setRole(OBDal.getInstance().get(Role.class, contextValues.roleId)); + } + if (contextValues.clientId != null) { + newContext.setCurrentClient(OBDal.getInstance().get(Client.class, contextValues.clientId)); + } + if (contextValues.organizationId != null) { + newContext.setCurrentOrganization(OBDal.getInstance().get(Organization.class, contextValues.organizationId)); + } + if (contextValues.warehouseId != null) { + newContext.setWarehouse(OBDal.getInstance().get(Warehouse.class, contextValues.warehouseId)); + } + if (contextValues.languageId != null) { + newContext.setLanguage(OBDal.getInstance().get(Language.class, contextValues.languageId)); + } + } + + /** + * Handles errors occurring during background execution by logging them to the {@link ProcessInstance}. + * It rolls back the current transaction and starts a new one to persist the error message. + * + * @param pInstanceId + * the ID of the {@link ProcessInstance} to update. + * @param e + * the exception that occurred. + */ + private void handleAsyncError(String pInstanceId, Exception e) { + try { + OBDal.getInstance().rollbackAndClose(); + + // Open a new transaction to save the error + ProcessInstance pInstanceCtx = OBDal.getInstance().get(ProcessInstance.class, pInstanceId); + if (pInstanceCtx != null) { + pInstanceCtx.setResult(0L); // Error + String msg = e.getMessage() != null ? e.getMessage() : e.toString(); + // Truncate to avoid DB errors if message is too long + if (msg.length() > 2000) { + msg = msg.substring(0, 2000); + } + pInstanceCtx.setErrorMsg("Async Error: " + msg); + + OBDal.getInstance().save(pInstanceCtx); + OBDal.getInstance().commitAndClose(); + } + } catch (Exception ex) { + log4j.error("Failed to log async error to ProcessInstance " + pInstanceId, ex); + } + } +} diff --git a/src/org/openbravo/service/db/CallProcess.java b/src/org/openbravo/service/db/CallProcess.java index 5a9a0553c..06de0da26 100644 --- a/src/org/openbravo/service/db/CallProcess.java +++ b/src/org/openbravo/service/db/CallProcess.java @@ -4,17 +4,15 @@ * Version 1.1 (the "License"), being the Mozilla Public License * Version 1.1 with a permitted attribution clause; you may not use this * file except in compliance with the License. You may obtain a copy of - * the License at http://www.openbravo.com/legal/license.html + * the License at http://www.openbravo.com/legal/license.html * Software distributed under the License is distributed on an "AS IS" * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the * License for the specific language governing rights and limitations - * under the License. - * The Original Code is Openbravo ERP. - * The Initial Developer of the Original Code is Openbravo SLU - * All portions are Copyright (C) 2009-2016 Openbravo SLU - * All Rights Reserved. - * Contributor(s): ______________________________________. - * Modification july 2010 (c) openbravo SLU, based on contribution made by iferca + * under the License. + * The Original Code is Openbravo ERP. + * The Initial Developer of the Original Code is Openbravo SLU + * All portions are Copyright (C) 2009-2025 Openbravo SLU + * All Rights Reserved. ************************************************************************ */ @@ -23,269 +21,396 @@ import java.math.BigDecimal; import java.sql.Connection; import java.sql.PreparedStatement; +import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Map; -import java.util.Properties; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.commons.lang3.BooleanUtils; import org.hibernate.criterion.Restrictions; import org.openbravo.base.exception.OBException; import org.openbravo.base.provider.OBProvider; -import org.openbravo.base.session.OBPropertiesProvider; +import org.openbravo.base.structure.BaseOBObject; import org.openbravo.dal.core.OBContext; import org.openbravo.dal.service.OBCriteria; import org.openbravo.dal.service.OBDal; import org.openbravo.model.ad.process.Parameter; import org.openbravo.model.ad.process.ProcessInstance; +import org.openbravo.model.ad.ui.Process; /** - * This class is a service class to call a stored procedure using a set of parameters. - * - * The {@link ProcessInstance} result is returned. - * - * @see ProcessInstance - * @see org.openbravo.model.ad.ui.Process - * @see Parameter - * + * Service class to execute database stored procedures. + *

+ * This class acts as the central engine for database calls in Openbravo. + * It supports two modes of execution: + *

    + *
  • Standard Mode: Executes processes defined in {@link Process} using the + * {@link ProcessInstance} mechanism. This is the standard way to run processes from the UI.
  • + *
  • Raw Mode: Executes arbitrary stored procedures with a list of objects as parameters. + * This replaces the logic previously found in CallStoredProcedure.
  • + *
+ *

+ * * @author mtaal + * @author @sebastianbarrozo + * @see ProcessInstance + * @see Process */ public class CallProcess { - private static CallProcess instance = new CallProcess(); + private static final AtomicReference instance = new AtomicReference<>(); - public static synchronized CallProcess getInstance() { - return instance; + /** + * Protected constructor to prevent direct instantiation while still enabling subclass overrides + * that can be registered through {@link #setInstance(CallProcess)} when customization is needed. + */ + protected CallProcess() { + } + + /** + * Gets the singleton instance of CallProcess. + * @return the CallProcess instance. + */ + public static CallProcess getInstance() { + return instance.updateAndGet(v -> v == null ? new CallProcess() : v); } - public static synchronized void setInstance(CallProcess instance) { - CallProcess.instance = instance; + /** + * Sets the singleton instance of CallProcess, allowing platform extensions or tests to inject a + * specialized subclass. Passing {@code null} is not allowed. + * @param instance custom implementation replacing the default behavior. + */ + public static void setInstance(CallProcess instance) { + if (instance == null) { + throw new OBException("CallProcess instance cannot be null"); + } + CallProcess.instance.set(instance); } + // =========================================================================== + // STANDARD MODE: Process Instance Execution (UI / Background Processes) + // =========================================================================== + /** - * Calls a process with the specified name. The recordID and parameters can be null. Parameters - * are translated into {@link Parameter} instances. - * + * Calls a process by its name. * @param processName - * the name of the stored procedure, must exist in the database, see - * {@link org.openbravo.model.ad.ui.Process#getProcedure()}. + * the procedure name defined in AD_Process. * @param recordID - * the recordID will be set in the {@link ProcessInstance}, see - * {@link ProcessInstance#getRecordID()} + * the record ID associated with the execution (optional). * @param parameters - * are translated into process parameters + * a map of parameters to be injected into AD_PInstance_Para. + * @return the result ProcessInstance. + */ + public ProcessInstance call(String processName, String recordID, Map parameters) { + return call(processName, recordID, parameters, null); + } + + /** + * Calls a process by its procedure name with explicit commit control. + * + * @param processName + * the procedure name defined in AD_Process. + * @param recordID + * the record ID associated with the execution (optional). + * @param parameters + * a map of parameters to be injected into AD_PInstance_Para. * @param doCommit - * do commit at the end of the procedure only if calledFromApp functionality is supported - * in the procedure, otherwise null should be passed - * @return the created instance with the result ({@link ProcessInstance#getResult()}) or error ( - * {@link ProcessInstance#getErrorMsg()}) + * explicit commit flag (if supported by the SP). + * @return the result ProcessInstance. */ public ProcessInstance call(String processName, String recordID, Map parameters, Boolean doCommit) { - final OBCriteria processCriteria = OBDal.getInstance() - .createCriteria(org.openbravo.model.ad.ui.Process.class); - processCriteria - .add(Restrictions.eq(org.openbravo.model.ad.ui.Process.PROPERTY_PROCEDURE, processName)); - List processList = processCriteria.list(); - if (processList.size() != 1) { - throw new OBException( - "No process or more than one process found using procedurename " + processName); + final OBCriteria processCriteria = OBDal.getInstance().createCriteria(Process.class); + processCriteria.add(Restrictions.eq(Process.PROPERTY_PROCEDURE, processName)); + if (processCriteria.list().size() > 1) { // safeguard against NonUniqueResultException and preserve previous controlled behavior + throw new OBException("More than one process found with procedure name " + processName); } - return call(processList.get(0), recordID, parameters, doCommit); + final Process process = (Process) processCriteria.uniqueResult(); + + if (process == null) { + throw new OBException("No process found with procedure name " + processName); + } + return call(process, recordID, parameters, doCommit); + } + + /** + * Overloaded call without doCommit. + * @param process + * the process definition. + * @param recordID + * the record ID. + * @param parameters + * map of parameters. + * @return the updated ProcessInstance with results. + */ + public ProcessInstance call(Process process, String recordID, Map parameters) { + return callProcess(process, recordID, parameters, null); } /** - * Calls a process. The recordID and parameters can be null. Parameters are translated into - * {@link Parameter} instances. - * + * Calls a process using the ProcessInstance mechanism. * @param process - * the process to execute + * the process definition. * @param recordID - * the recordID will be set in the {@link ProcessInstance}, see - * {@link ProcessInstance#getRecordID()} + * the record ID. * @param parameters - * are translated into process parameters, supports only string parameters, for support - * of other parameters see the next method: - * {@link #callProcess(org.openbravo.model.ad.ui.Process, String, Map)} + * map of parameters. * @param doCommit - * do commit at the end of the procedure only if calledFromApp functionality is supported - * in the procedure, otherwise null should be passed - * @return the created instance with the result ({@link ProcessInstance#getResult()}) or error ( - * {@link ProcessInstance#getErrorMsg()}) + * explicit commit flag (if supported by the SP). + * @return the updated ProcessInstance with results. */ - public ProcessInstance call(org.openbravo.model.ad.ui.Process process, String recordID, - Map parameters, Boolean doCommit) { + public ProcessInstance call(Process process, String recordID, Map parameters, Boolean doCommit) { return callProcess(process, recordID, parameters, doCommit); } /** - * Calls a process. The recordID and parameters can be null. Parameters are translated into - * {@link Parameter} instances. - * + * Calls a process using the ProcessInstance mechanism. + *

+ * This method follows the template pattern to facilitate asynchronous extensions: + * 1. {@link #createAndPersistInstance}: Prepares data. + * 2. {@link #executeStandardProcedure}: Executes DB logic. + * 3. Refreshes the result. + *

* @param process - * the process to execute + * the process definition. * @param recordID - * the recordID will be set in the {@link ProcessInstance}, see - * {@link ProcessInstance#getRecordID()} + * the record ID. * @param parameters - * are translated into process parameters + * map of parameters. * @param doCommit - * do commit at the end of the procedure only if calledFromApp functionality is supported - * in the procedure, otherwise null should be passed - * @return the created instance with the result ({@link ProcessInstance#getResult()}) or error ( - * {@link ProcessInstance#getErrorMsg()}) + * explicit commit flag (if supported by the SP). + * @return the updated ProcessInstance with results. */ - public ProcessInstance callProcess(org.openbravo.model.ad.ui.Process process, String recordID, - Map parameters, Boolean doCommit) { + public ProcessInstance callProcess(Process process, String recordID, Map parameters, Boolean doCommit) { OBContext.setAdminMode(); try { - // Create the pInstance - final ProcessInstance pInstance = OBProvider.getInstance().get(ProcessInstance.class); - // sets its process - pInstance.setProcess(process); - // must be set to true - pInstance.setActive(true); - - // allow it to be read by others also - pInstance.setAllowRead(true); - - if (recordID != null) { - pInstance.setRecordID(recordID); - } else { - pInstance.setRecordID("0"); - } + // 1. Prepare Data + ProcessInstance pInstance = createAndPersistInstance(process, recordID, parameters); - // get the user from the context - pInstance.setUserContact(OBContext.getOBContext().getUser()); - - // now create the parameters and set their values - if (parameters != null) { - int index = 0; - for (String key : parameters.keySet()) { - index++; - final Object value = parameters.get(key); - final Parameter parameter = OBProvider.getInstance().get(Parameter.class); - parameter.setSequenceNumber(index + ""); - parameter.setParameterName(key); - if (value instanceof String) { - parameter.setString((String) value); - } else if (value instanceof Date) { - parameter.setProcessDate((Date) value); - } else if (value instanceof BigDecimal) { - parameter.setProcessNumber((BigDecimal) value); - } + // 2. Execute DB Logic + executeStandardProcedure(pInstance, process, doCommit); - // set both sides of the bidirectional association - pInstance.getADParameterList().add(parameter); - parameter.setProcessInstance(pInstance); - } - } + // 3. Refresh result + OBDal.getInstance().getSession().refresh(pInstance); - // persist to the db - OBDal.getInstance().save(pInstance); + return pInstance; + } finally { + OBContext.restorePreviousMode(); + } + } - // flush, this gives pInstance an ID - OBDal.getInstance().flush(); + /** + * Overloaded callProcess without doCommit. + * + * @param process instance of the process to be executed + * @param recordID the record ID. + * @param parameters map of parameters. + * @return the updated ProcessInstance with results. + */ + public ProcessInstance callProcess(Process process, String recordID, Map parameters) { + return callProcess(process, recordID, parameters, null); + } + // =========================================================================== + // RAW MODE: Arbitrary SQL Execution (Replaces CallStoredProcedure) + // =========================================================================== - PreparedStatement ps = null; - // call the SP - try { - // first get a connection - final Connection connection = OBDal.getInstance().getConnection(false); + /** + * Executes a raw stored procedure or function directly via JDBC. + * * @param procedureName + * the name of the database procedure/function. + * @param parameters + * list of parameter values. + * @param types + * list of parameter classes (for null handling). + * @param doFlush + * whether to flush Hibernate session before execution. + * @param returnResults + * whether to capture the return value (Function vs Procedure). + * @return the result object if returnResults is true, null otherwise. + */ + Object executeRaw(String procedureName, List parameters, List> types, boolean doFlush, boolean returnResults) { + String rdbms = new DalConnectionProvider(false).getRDBMS(); + int paramCount = (parameters != null) ? parameters.size() : 0; - String procedureParameters = "(?)"; - if (doCommit != null) { - procedureParameters = "(?,?)"; - } + // 1. Build Query + String sql = buildSqlQuery(procedureName, paramCount, rdbms, returnResults); - final Properties obProps = OBPropertiesProvider.getInstance().getOpenbravoProperties(); - if (obProps.getProperty("bbdd.rdbms") != null - && obProps.getProperty("bbdd.rdbms").equals("POSTGRE")) { - ps = connection - .prepareStatement("SELECT * FROM " + process.getProcedure() + procedureParameters); - } else { - ps = connection.prepareStatement(" CALL " + process.getProcedure() + procedureParameters); - } + // 2. Obtain Connection + Connection conn = OBDal.getInstance().getConnection(doFlush); - ps.setString(1, pInstance.getId()); - if (doCommit != null) { - ps.setString(2, doCommit ? "Y" : "N"); + // 3. Execute + try (PreparedStatement ps = conn.prepareStatement(sql, ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY)) { + + setParameters(ps, parameters, types); + + if (returnResults) { + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + Object res = rs.getObject("RESULT"); + return rs.wasNull() ? null : res; + } + return null; } + } else { ps.execute(); - } catch (Exception e) { - throw new IllegalStateException(e); - } finally { - try { - ps.close(); - } catch (SQLException e) { + return null; + } + } catch (SQLException e) { + throw new IllegalStateException("Error executing raw process " + procedureName + ": " + e.getMessage(), e); + } + } + + // =========================================================================== + // PROTECTED HELPERS (Extension Points for Async Implementation) + // =========================================================================== + + /** + * Creates the ProcessInstance and Parameter records and persists them to the database. + */ + protected ProcessInstance createAndPersistInstance(Process process, String recordID, Map parameters) { + final ProcessInstance pInstance = OBProvider.getInstance().get(ProcessInstance.class); + pInstance.setProcess(process); + pInstance.setActive(true); + pInstance.setAllowRead(true); + pInstance.setRecordID(recordID != null ? recordID : "0"); + pInstance.setUserContact(OBContext.getOBContext().getUser()); + + if (parameters != null && !parameters.isEmpty()) { + int index = 0; + for (Map.Entry entry : parameters.entrySet()) { + index++; + final Parameter parameter = OBProvider.getInstance().get(Parameter.class); + parameter.setSequenceNumber(String.valueOf(index)); + parameter.setParameterName(entry.getKey()); + + Object value = entry.getValue(); + if (value instanceof String) { + parameter.setString((String) value); + } else if (value instanceof Date) { + parameter.setProcessDate((Date) value); + } else if (value instanceof BigDecimal) { + parameter.setProcessNumber((BigDecimal) value); } + + pInstance.getADParameterList().add(parameter); + parameter.setProcessInstance(pInstance); } + } - // refresh the pInstance as the SP has changed it - OBDal.getInstance().getSession().refresh(pInstance); - return pInstance; - } finally { - OBContext.restorePreviousMode(); + OBDal.getInstance().save(pInstance); + OBDal.getInstance().flush(); + + return pInstance; + } + + /** + * Executes the standard protocol for Stored Procedures (PInstance ID based). + */ + protected void executeStandardProcedure(ProcessInstance pInstance, Process process, Boolean doCommit) { + // Construct the parameter list expected by standard OB procedures + List rawParams = new ArrayList<>(); + List> types = new ArrayList<>(); + + rawParams.add(pInstance.getId()); + types.add(String.class); + + if (doCommit != null) { + rawParams.add(doCommit); + types.add(Boolean.class); } + + // Reuse the Raw execution engine + // Standard calls usually don't return a result set (they update AD_PInstance table) + // However, on Postgres they are SELECTs, so returnResults=true prevents syntax errors + boolean isPostgre = "POSTGRE".equals(new DalConnectionProvider(false).getRDBMS()); + + executeRaw(process.getProcedure(), rawParams, types, false, isPostgre); } + // =========================================================================== + // PRIVATE SQL HELPERS + // =========================================================================== + /** - * Calls a process with the specified name. The recordID and parameters can be null. Parameters - * are translated into {@link Parameter} instances. - * - * @param processName - * the name of the stored procedure, must exist in the database, see - * {@link org.openbravo.model.ad.ui.Process#getProcedure()}. - * @param recordID - * the recordID will be set in the {@link ProcessInstance}, see - * {@link ProcessInstance#getRecordID()} - * @param parameters - * are translated into process parameters - * @return the created instance with the result ({@link ProcessInstance#getResult()}) or error ( - * {@link ProcessInstance#getErrorMsg()}) + * Generates SQL string based on RDBMS syntax. */ - public ProcessInstance call(String processName, String recordID, Map parameters) { - return call(processName, recordID, parameters, null); + private String buildSqlQuery(String name, int paramCount, String rdbms, boolean returnResults) { + StringBuilder sb = new StringBuilder(); + boolean isOracle = "ORACLE".equalsIgnoreCase(rdbms); + + if (isOracle && !returnResults) { + sb.append("CALL ").append(name); + } else { + sb.append("SELECT ").append(name); + } + + sb.append("("); + for (int i = 0; i < paramCount; i++) { + sb.append(i > 0 ? ",?" : "?"); + } + sb.append(")"); + + if (returnResults || !isOracle) { + sb.append(" AS RESULT FROM DUAL"); + } + return sb.toString(); } /** - * Calls a process. The recordID and parameters can be null. Parameters are translated into - * {@link Parameter} instances. - * - * @param process - * the process to execute - * @param recordID - * the recordID will be set in the {@link ProcessInstance}, see - * {@link ProcessInstance#getRecordID()} - * @param parameters - * are translated into process parameters, supports only string parameters, for support - * of other parameters see the next method: - * {@link #callProcess(org.openbravo.model.ad.ui.Process, String, Map)} - * @return the created instance with the result ({@link ProcessInstance#getResult()}) or error ( - * {@link ProcessInstance#getErrorMsg()}) + * Binds parameters to the PreparedStatement. */ - public ProcessInstance call(org.openbravo.model.ad.ui.Process process, String recordID, - Map parameters) { - return call(process, recordID, parameters, null); + private void setParameters(PreparedStatement ps, List parameters, List> types) throws SQLException { + if (parameters == null || parameters.isEmpty()) { + return; + } + + int index = 0; + for (Object parameter : parameters) { + int sqlIndex = index + 1; + if (parameter == null) { + int sqlType = (types != null && index < types.size()) ? getSqlType(types.get(index)) : Types.VARCHAR; + ps.setNull(sqlIndex, sqlType); + } else { + setParameterValue(ps, sqlIndex, parameter); + } + index++; + } } /** - * Calls a process. The recordID and parameters can be null. Parameters are translated into - * {@link Parameter} instances. - * - * @param process - * the process to execute - * @param recordID - * the recordID will be set in the {@link ProcessInstance}, see - * {@link ProcessInstance#getRecordID()} - * @param parameters - * are translated into process parameters - * @return the created instance with the result ({@link ProcessInstance#getResult()}) or error ( - * {@link ProcessInstance#getErrorMsg()}) + * Sets individual parameter value with type conversion. */ - public ProcessInstance callProcess(org.openbravo.model.ad.ui.Process process, String recordID, - Map parameters) { - return callProcess(process, recordID, parameters, null); + private void setParameterValue(PreparedStatement ps, int index, Object parameter) throws SQLException { + if (parameter instanceof String && ((String) parameter).isEmpty()) { + ps.setNull(index, Types.VARCHAR); + } else if (parameter instanceof Boolean) { + ps.setString(index, BooleanUtils.toBoolean((Boolean) parameter) ? "Y" : "N"); + } else if (parameter instanceof BaseOBObject) { + ps.setString(index, (String) ((BaseOBObject) parameter).getId()); + } else if (parameter instanceof Timestamp) { + ps.setTimestamp(index, (Timestamp) parameter); + } else if (parameter instanceof Date) { + ps.setDate(index, new java.sql.Date(((Date) parameter).getTime())); + } else { + ps.setObject(index, parameter); + } + } + + /** + * Maps Java classes to SQL Types. + */ + private int getSqlType(Class clz) { + if (clz == null) return Types.VARCHAR; + if (clz == Boolean.class || clz == String.class || BaseOBObject.class.isAssignableFrom(clz)) return Types.VARCHAR; + else if (Number.class.isAssignableFrom(clz)) return Types.NUMERIC; + else if (clz == Timestamp.class) return Types.TIMESTAMP; + else if (Date.class.isAssignableFrom(clz)) return Types.DATE; + return Types.VARCHAR; } } diff --git a/src/org/openbravo/service/db/CallStoredProcedure.java b/src/org/openbravo/service/db/CallStoredProcedure.java index 5c4bda35e..c42db27f4 100644 --- a/src/org/openbravo/service/db/CallStoredProcedure.java +++ b/src/org/openbravo/service/db/CallStoredProcedure.java @@ -19,162 +19,100 @@ package org.openbravo.service.db; -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Timestamp; -import java.sql.Types; -import java.util.Date; import java.util.List; -import org.openbravo.base.structure.BaseOBObject; -import org.openbravo.dal.service.OBDal; -import org.openbravo.model.ad.process.ProcessInstance; - /** - * This class is a service class to directly call a stored procedure without using a - * {@link ProcessInstance}. - * - * @author mtaal + * Facade for executing Stored Procedures. + *

+ * This class provides a simplified interface for calling database stored procedures. + * It delegates all logic to {@link CallProcess#executeRaw(String, List, List, boolean, boolean)}. + *

+ *

+ * This class is kept primarily for backward compatibility with older versions of the system. + * New code should prefer using {@link CallProcess} directly. + *

+ * + * @author Openbravo + * @author etendo + * @see CallProcess */ public class CallStoredProcedure { private static CallStoredProcedure instance = new CallStoredProcedure(); + /** + * Returns the singleton instance of {@code CallStoredProcedure}. + * + * @return the singleton instance. + */ public static synchronized CallStoredProcedure getInstance() { return instance; } + /** + * Sets the singleton instance of {@code CallStoredProcedure}. + * + * @param instance + * the instance to set. + */ public static synchronized void setInstance(CallStoredProcedure instance) { CallStoredProcedure.instance = instance; } /** - * @see #call(String, List, List, boolean, boolean) + * Executes a stored procedure with the given name and parameters. Delegates to + * {@link #call(String, List, List, boolean, boolean)} with default values. + * + * @param name + * the name of the stored procedure to execute + * @param parameters + * the list of parameters to pass to the stored procedure + * @param types + * the list of Java types corresponding to the parameters + * @return the result of the stored procedure execution + */ + public Object call(String name, List parameters, List> types) { + return call(name, parameters, types, true, true); + } + + /** + * Executes a stored procedure with the given name and parameters, allowing control over session + * flushing. Delegates to {@link #call(String, List, List, boolean, boolean)} with default values. + * + * @param name + * the name of the stored procedure to execute + * @param parameters + * the list of parameters to pass to the stored procedure + * @param types + * the list of Java types corresponding to the parameters + * @param doFlush + * whether to flush the current session before executing the stored procedure + * @return the result of the stored procedure execution */ public Object call(String name, List parameters, List> types, boolean doFlush) { return call(name, parameters, types, doFlush, true); } /** - * Calls a stored procedure with the specified name. The parameter list is translated in exactly - * the same parameters for the call so the parameters should be in the correct order and have the - * correct type as expected by the stored procedure. The parameter types can be any of the - * primitive types used by Openbravo (Date, Long, String, etc.). + * Executes a stored procedure with the given name and parameters, allowing full control over + * execution options. This method delegates the actual execution to + * {@link CallProcess#executeRaw(String, List, List, boolean, boolean)}. * * @param name - * the name of the stored procedure to call. + * the name of the stored procedure to execute * @param parameters - * a list of parameters (null values are allowed) + * the list of parameters to pass to the stored procedure * @param types - * the list of types of the parameters, only needs to be set if there are null values and - * if the null value is something else than a String (which is handled as a default type) + * the list of Java types corresponding to the parameters * @param doFlush - * do flush before calling stored procedure + * whether to flush the current session before executing the stored procedure * @param returnResults - * whether a fetch for results should be done after the call to the stored procedure - * (essentially describes whether the PL object is a procedure or function) - * - * @return the stored procedure result. + * whether to return the results of the stored procedure execution + * @return the result of the stored procedure execution, or null if returnResults is false */ public Object call(String name, List parameters, List> types, boolean doFlush, boolean returnResults) { - final StringBuilder sb = new StringBuilder(); - if (new DalConnectionProvider(false).getRDBMS().equalsIgnoreCase("ORACLE") && !returnResults) { - sb.append("CALL " + name); - } else { - sb.append("SELECT " + name); - } - sb.append("("); - for (int i = 0; i < parameters.size(); i++) { - if (i != 0) { - sb.append(","); - } - sb.append("?"); - } - sb.append(")"); - if (returnResults || !new DalConnectionProvider(false).getRDBMS().equalsIgnoreCase("ORACLE")) { - sb.append(" AS RESULT FROM DUAL"); - } - final Connection conn = OBDal.getInstance().getConnection(doFlush); - PreparedStatement ps = null; - try { - ps = conn.prepareStatement(sb.toString(), ResultSet.TYPE_SCROLL_INSENSITIVE, - ResultSet.CONCUR_READ_ONLY); - int index = 0; - - for (Object parameter : parameters) { - final int sqlIndex = index + 1; - if (parameter == null) { - if (types == null || types.size() < index) { - ps.setNull(sqlIndex, Types.NULL); - } else { - ps.setNull(sqlIndex, getSqlType(types.get(index))); - } - } else if (parameter instanceof String && parameter.toString().equals("")) { - ps.setNull(sqlIndex, Types.VARCHAR); - } else if (parameter instanceof Boolean) { - ps.setObject(sqlIndex, ((Boolean) parameter) ? "Y" : "N"); - } else if (parameter instanceof BaseOBObject) { - ps.setObject(sqlIndex, ((BaseOBObject) parameter).getId()); - } else if (parameter instanceof Timestamp) { - ps.setTimestamp(sqlIndex, (Timestamp) parameter); - } else if (parameter instanceof Date) { - ps.setDate(sqlIndex, new java.sql.Date(((Date) parameter).getTime())); - } else { - ps.setObject(sqlIndex, parameter); - } - index++; - } - final ResultSet resultSet = ps.executeQuery(); - Object resultValue = null; - if (returnResults && resultSet.next()) { - resultValue = resultSet.getObject("RESULT"); - if (resultSet.wasNull()) { - resultValue = null; - } - } - resultSet.close(); - return resultValue; - } catch (Exception e) { - throw new IllegalStateException(e); - } finally { - try { - if (ps != null && !ps.isClosed()) { - ps.close(); - } - } catch (SQLException e) { - // ignore - } - } - } - - private int getSqlType(Class clz) { - if (clz == null) { - return Types.VARCHAR; - } - if (clz == Boolean.class) { - return Types.VARCHAR; - } else if (clz == String.class) { - return Types.VARCHAR; - } else if (clz == BaseOBObject.class) { - return Types.VARCHAR; - } else if (Number.class.isAssignableFrom(clz)) { - return Types.NUMERIC; - } else if (clz == Timestamp.class) { - return Types.TIMESTAMP; - } else if (Date.class.isAssignableFrom(clz)) { - return Types.DATE; - } else if (BaseOBObject.class.isAssignableFrom(clz)) { - return Types.VARCHAR; - } else { - throw new IllegalStateException("Type not supported, please add it here " + clz.getName()); - } - } - - public Object call(String name, List parameters, List> types) { - return call(name, parameters, types, true); + return CallProcess.getInstance().executeRaw(name, parameters, types, doFlush, returnResults); } }