This is an automated email from the ASF dual-hosted git repository.

ashishvijaywargiya pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git


The following commit(s) were added to refs/heads/trunk by this push:
     new ccd8f036d8 Groovy DSL Enhancements (#2010)
ccd8f036d8 is described below

commit ccd8f036d82769674e4776967f01325bb9a72066
Author: Ashish Vijaywargiya <[email protected]>
AuthorDate: Tue Sep 15 18:42:51 2026 +0530

    Groovy DSL Enhancements (#2010)
    
    Groovy DSL Enhancements - Here comes one more pass after so many years!!
    
    1) Added resource/key i18n overloads to success()/error()/failure() in
    the Groovy DSL, resolving the long-standing TODOs in GroovyBaseScript.
    
    2) Added a fluent write-side entity DSL: create(),
    update().where().set(), and delete().where(), matching the existing
    read-side select()/from()/where().
    
    3) Added require()/fail() control-flow sugar backed by a new
    ServiceErrorException, so a failed check unwinds past nested closures
    instead of only exiting them like return error(...) does.
    
    4) Added runAsyncService() and the runAsync service:, with: calling form
    for persisted (Job Scheduler backed) async service calls, mirroring
    run()/runService().
    
    5) Covered by new unit and integration tests in
    GroovyDslServiceEngineTests, all passing.
---
 framework/service/servicedef/services_test_se.xml  |  68 ++++++
 .../EntityDeleteBuilder.groovy}                    |  41 ++--
 .../service/engine/EntityUpdateBuilder.groovy      |  60 ++++++
 .../ofbiz/service/engine/GroovyBaseScript.groovy   |  92 ++++++--
 .../apache/ofbiz/service/test/TestServices.groovy  |  78 +++++++
 .../ofbiz/service/ServiceErrorException.java}      |  37 ++--
 .../service/test/GroovyDslServiceEngineTests.java  | 236 +++++++++++++++++++++
 7 files changed, 553 insertions(+), 59 deletions(-)

diff --git a/framework/service/servicedef/services_test_se.xml 
b/framework/service/servicedef/services_test_se.xml
index 463a97ab05..303bbd2d88 100644
--- a/framework/service/servicedef/services_test_se.xml
+++ b/framework/service/servicedef/services_test_se.xml
@@ -214,6 +214,74 @@ under the License.
              
location="component://service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy"
 invoke="testPingErrorWithDSLCall">
         <implements service="testGroovyPingSuccess"/>
     </service>
+    <service name="testGroovyPingSuccessWithAsyncDSLCall" engine="groovy"
+             
location="component://service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy"
 invoke="testPingSuccessWithAsyncDSLCall">
+        <implements service="testGroovyPingSuccess"/>
+        <attribute name="pong" mode="OUT" type="String" optional="true"/>
+    </service>
+    <service name="testGroovyPingSuccessWithAsyncMapDSLCall" engine="groovy"
+             
location="component://service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy"
 invoke="testPingSuccessWithAsyncMapDSLCall">
+        <implements service="testGroovyPingSuccess"/>
+        <attribute name="pong" mode="OUT" type="String" optional="true"/>
+    </service>
+
+    <!--Test resource/key i18n overloads on success()/error()/failure() -->
+    <service name="testGroovyPingSuccessWithI18n" engine="groovy" auth="false"
+             
location="component://service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy"
 invoke="testPingSuccessWithI18n">
+    </service>
+    <service name="testGroovyPingSuccessWithI18nContext" engine="groovy" 
auth="false"
+             
location="component://service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy"
 invoke="testPingSuccessWithI18nContext">
+        <attribute name="parameterName" mode="IN" type="String"/>
+        <attribute name="errorDetails" mode="IN" type="String"/>
+    </service>
+    <service name="testGroovyPingErrorWithI18n" engine="groovy" auth="false"
+             
location="component://service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy"
 invoke="testPingErrorWithI18n">
+    </service>
+    <service name="testGroovyPingErrorWithI18nContext" engine="groovy" 
auth="false"
+             
location="component://service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy"
 invoke="testPingErrorWithI18nContext">
+        <attribute name="parameterName" mode="IN" type="String"/>
+        <attribute name="errorDetails" mode="IN" type="String"/>
+    </service>
+    <service name="testGroovyPingFailureWithI18n" engine="groovy" auth="false"
+             
location="component://service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy"
 invoke="testPingFailureWithI18n">
+    </service>
+
+    <!--Test require()/fail() control-flow sugar -->
+    <service name="testGroovyFail" engine="groovy" auth="false"
+             
location="component://service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy"
 invoke="testFail">
+    </service>
+    <service name="testGroovyRequire" engine="groovy" auth="false"
+             
location="component://service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy"
 invoke="testRequire">
+        <attribute name="conditionMet" mode="IN" type="Boolean"/>
+    </service>
+    <service name="testGroovyFailFromNestedClosure" engine="groovy" 
auth="false"
+             
location="component://service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy"
 invoke="testFailFromNestedClosure">
+    </service>
+    <service name="testGroovyFailWithI18n" engine="groovy" auth="false"
+             
location="component://service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy"
 invoke="testFailWithI18n">
+    </service>
+    <service name="testGroovyRequireWithI18nContext" engine="groovy" 
auth="false"
+             
location="component://service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy"
 invoke="testRequireWithI18nContext">
+        <attribute name="parameterName" mode="IN" type="String"/>
+        <attribute name="errorDetails" mode="IN" type="String"/>
+    </service>
+
+    <!--Test write-side entity DSL: create()/update()/delete() -->
+    <service name="testGroovyEntityDslCreate" engine="groovy" auth="false"
+             
location="component://service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy"
 invoke="testEntityDslCreate">
+        <attribute name="testingId" mode="IN" type="String"/>
+        <attribute name="testingName" mode="IN" type="String" optional="true"/>
+    </service>
+    <service name="testGroovyEntityDslUpdate" engine="groovy" auth="false"
+             
location="component://service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy"
 invoke="testEntityDslUpdate">
+        <attribute name="testingId" mode="IN" type="String"/>
+        <attribute name="testingName" mode="IN" type="String" optional="true"/>
+    </service>
+    <service name="testGroovyEntityDslDelete" engine="groovy" auth="false"
+             
location="component://service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy"
 invoke="testEntityDslDelete">
+        <attribute name="testingId" mode="IN" type="String"/>
+        <attribute name="rowsRemoved" mode="OUT" type="Integer"/>
+    </service>
 
     <!--Test job tracker engine service -->
     <service name="TestTopLevelServiceThatPlansTrackedServices" 
engine="groovy-tracker" auth="true"
diff --git 
a/framework/service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy
 
b/framework/service/src/main/groovy/org/apache/ofbiz/service/engine/EntityDeleteBuilder.groovy
similarity index 52%
copy from 
framework/service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy
copy to 
framework/service/src/main/groovy/org/apache/ofbiz/service/engine/EntityDeleteBuilder.groovy
index 44fde00ea7..f8949ed522 100644
--- 
a/framework/service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy
+++ 
b/framework/service/src/main/groovy/org/apache/ofbiz/service/engine/EntityDeleteBuilder.groovy
@@ -15,31 +15,30 @@
  * KIND, either express or implied.  See the License for the
  * specific language governing permissions and limitations
  * under the License.
-*/
-package org.apache.ofbiz.service.test
+ 
*******************************************************************************/
+package org.apache.ofbiz.service.engine
 
-// Despite it's name, this class is not for test, it's only services
+import org.apache.ofbiz.entity.Delegator
+import org.apache.ofbiz.entity.GenericEntityException
 
-Map testPingSuccess() {
-    Map returnMap = success('Service result success')
-    if (parameters.ping) {
-        returnMap.pong = parameters.ping
-    }
-    return returnMap
-}
+/**
+ * Fluent write-side helper backing {@link GroovyBaseScript#delete(String)}.
+ *
+ * Usage: {@code delete('SomeEntity').where(fields)} — bulk-removes every 
matching record and
+ * returns the number of rows removed.
+ */
+class EntityDeleteBuilder {
+
+    private final Delegator delegator
+    private final String entityName
 
-Map testPingError() {
-    Map returnMap = error('Service result error')
-    if (parameters.ping) {
-        returnMap.pong = parameters.ping
+    EntityDeleteBuilder(Delegator delegator, String entityName) {
+        this.delegator = delegator
+        this.entityName = entityName
     }
-    return returnMap
-}
 
-Map testPingSuccessWithDSLCall() {
-    run service: 'testGroovyPingSuccess', with: parameters
-}
+    int where(Map<String, Object> fields) throws GenericEntityException {
+        return delegator.removeByAnd(entityName, fields)
+    }
 
-Map testPingErrorWithDSLCall() {
-    run service: 'testGroovyPingError', with: parameters
 }
diff --git 
a/framework/service/src/main/groovy/org/apache/ofbiz/service/engine/EntityUpdateBuilder.groovy
 
b/framework/service/src/main/groovy/org/apache/ofbiz/service/engine/EntityUpdateBuilder.groovy
new file mode 100644
index 0000000000..8edf04a474
--- /dev/null
+++ 
b/framework/service/src/main/groovy/org/apache/ofbiz/service/engine/EntityUpdateBuilder.groovy
@@ -0,0 +1,60 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ 
*******************************************************************************/
+package org.apache.ofbiz.service.engine
+
+import org.apache.ofbiz.entity.Delegator
+import org.apache.ofbiz.entity.GenericValue
+import org.apache.ofbiz.entity.util.EntityQuery
+import org.apache.ofbiz.service.ServiceErrorException
+
+/**
+ * Fluent write-side helper backing {@link GroovyBaseScript#update(String)}.
+ *
+ * Usage: {@code update('SomeEntity').where(pkFields).set(fieldsToChange)} — 
queries the record,
+ * merges the given fields into it, stores it and returns the updated {@link 
GenericValue}.
+ * Throws {@link ServiceErrorException} if no record matches {@code where()}, 
so a missing record
+ * fails fast instead of silently no-op'ing.
+ */
+class EntityUpdateBuilder {
+
+    private final Delegator delegator
+    private final String entityName
+    private Map<String, Object> whereFields
+
+    EntityUpdateBuilder(Delegator delegator, String entityName) {
+        this.delegator = delegator
+        this.entityName = entityName
+    }
+
+    EntityUpdateBuilder where(Map<String, Object> whereFields) {
+        this.whereFields = whereFields
+        return this
+    }
+
+    GenericValue set(Map<String, Object> fields) throws ServiceErrorException {
+        GenericValue existing = 
EntityQuery.use(delegator).from(entityName).where(whereFields).queryOne()
+        if (existing == null) {
+            throw new ServiceErrorException("No ${entityName} found matching 
${whereFields}" as String)
+        }
+        existing.putAll(fields)
+        existing.store()
+        return existing
+    }
+
+}
diff --git 
a/framework/service/src/main/groovy/org/apache/ofbiz/service/engine/GroovyBaseScript.groovy
 
b/framework/service/src/main/groovy/org/apache/ofbiz/service/engine/GroovyBaseScript.groovy
index 83ff751e47..4339b77515 100644
--- 
a/framework/service/src/main/groovy/org/apache/ofbiz/service/engine/GroovyBaseScript.groovy
+++ 
b/framework/service/src/main/groovy/org/apache/ofbiz/service/engine/GroovyBaseScript.groovy
@@ -20,16 +20,24 @@ package org.apache.ofbiz.service.engine
 
 import org.apache.ofbiz.base.util.Debug
 import org.apache.ofbiz.base.util.UtilProperties
+import org.apache.ofbiz.entity.Delegator
+import org.apache.ofbiz.entity.GenericEntityException
 import org.apache.ofbiz.entity.GenericValue
 import org.apache.ofbiz.entity.model.DynamicViewEntity
 import org.apache.ofbiz.entity.util.EntityQuery
 import org.apache.ofbiz.service.DispatchContext
 import org.apache.ofbiz.service.ExecutionServiceException
+import org.apache.ofbiz.service.GenericServiceException
 import org.apache.ofbiz.service.LocalDispatcher
 import org.apache.ofbiz.service.ModelService
+import org.apache.ofbiz.service.ServiceErrorException
 import org.apache.ofbiz.service.ServiceUtil
 
 // codenarc-disable AbstractClassWithoutAbstractMethod
+// This class is the DSL surface itself (see the 2014 cwiki doc), so it is 
expected to keep
+// accumulating small facade methods as new DSL verbs are added - 
MethodCount's default threshold
+// does not fit that shape.
+// codenarc-disable MethodCount
 abstract class GroovyBaseScript extends Script {
 
     static final String MODULE = GroovyBaseScript.getName()
@@ -40,17 +48,7 @@ abstract class GroovyBaseScript extends Script {
 
     Map runService(String serviceName, Map inputMap) throws 
ExecutionServiceException {
         LocalDispatcher dispatcher = binding.getVariable('dispatcher')
-        DispatchContext dctx = dispatcher.getDispatchContext()
-        inputMap.userLogin = inputMap.userLogin ?: 
this.binding.hasVariable('userLogin')
-                ? this.binding.getVariable('userLogin')
-                : this.binding.getVariable('parameters').userLogin
-        inputMap.timeZone = inputMap.timeZone ?: 
this.binding.hasVariable('timeZone')
-                ? this.binding.getVariable('timeZone')
-                : this.binding.getVariable('parameters').timeZone
-        inputMap.locale = inputMap.locale ?: this.binding.hasVariable('locale')
-                ? this.binding.getVariable('locale')
-                : this.binding.getVariable('parameters').locale
-        Map serviceContext = dctx.makeValidContext(serviceName, 
ModelService.IN_PARAM, inputMap)
+        Map serviceContext = buildServiceContext(dispatcher, serviceName, 
inputMap)
         Map result = dispatcher.runSync(serviceName, serviceContext)
         if (ServiceUtil.isError(result)) {
             throw new 
ExecutionServiceException(ServiceUtil.getErrorMessage(result))
@@ -58,10 +56,20 @@ abstract class GroovyBaseScript extends Script {
         return result
     }
 
+    void runAsyncService(String serviceName, Map inputMap) throws 
GenericServiceException {
+        LocalDispatcher dispatcher = binding.getVariable('dispatcher')
+        Map serviceContext = buildServiceContext(dispatcher, serviceName, 
inputMap)
+        dispatcher.runAsync(serviceName, serviceContext, true)   // persist = 
true
+    }
+
     Map run(Map args) throws ExecutionServiceException {
         return runService((String)args.get('service'), (Map)args.get('with', 
[:]))
     }
 
+    void runAsync(Map args) throws GenericServiceException {
+        runAsyncService((String)args.get('service'), (Map)args.get('with', 
[:]))
+    }
+
     Map makeValue(String entityName) throws ExecutionServiceException {
         return binding.getVariable('delegator').makeValue(entityName)
     }
@@ -86,6 +94,19 @@ abstract class GroovyBaseScript extends Script {
         return EntityQuery.use(binding.getVariable('delegator')).select(fields)
     }
 
+    GenericValue create(String entityName, Map fields) throws 
GenericEntityException {
+        Delegator delegator = binding.getVariable('delegator')
+        return delegator.makeValidValue(entityName, fields).create()
+    }
+
+    EntityUpdateBuilder update(String entityName) {
+        return new EntityUpdateBuilder(binding.getVariable('delegator'), 
entityName)
+    }
+
+    EntityDeleteBuilder delete(String entityName) {
+        return new EntityDeleteBuilder(binding.getVariable('delegator'), 
entityName)
+    }
+
     @Deprecated
     GenericValue findOne(String entityName, Map<String, ? extends Object> 
fields, boolean useCache) {
         return from(entityName).where(fields).cache(useCache).queryOne()
@@ -95,8 +116,13 @@ abstract class GroovyBaseScript extends Script {
     def success(Map returnValues) {
         return success(null, returnValues)
     }
+    def success(String resource, String key, Map returnValues = [:]) {
+        return success(label(resource, key), returnValues)
+    }
+    def success(String resource, String key, Map context, Map returnValues) {
+        return success(label(resource, key, context), returnValues)
+    }
     def success(String message = '', Map returnValues = [:]) {
-        // TODO: implement some clever i18n mechanism based on the userLogin 
and locale in the binding
         if (this.binding.hasVariable('request')) {
             // the script is invoked as an "event"
             if (message) {
@@ -117,8 +143,10 @@ abstract class GroovyBaseScript extends Script {
         return result
     }
     /* codenarc-enable */
+    Map failure(String resource, String key, Map returnValues = [:]) {
+        return failure(label(resource, key), returnValues)
+    }
     Map failure(String message, Map returnValues = [:]) {
-        // TODO: implement some clever i18n mechanism based on the userLogin 
and locale in the binding
         Map result = message
                 ? ServiceUtil.returnFailure(message)
                 : ServiceUtil.returnFailure()
@@ -127,9 +155,14 @@ abstract class GroovyBaseScript extends Script {
         }
         return result
     }
+    Map error(String resource, String key) {
+        return error(label(resource, key))
+    }
+    Map error(String resource, String key, Map context) {
+        return error(label(resource, key, context))
+    }
     /* codenarc-disable NoDef, MethodReturnTypeRequired */
     def error(String message) {
-        // TODO: implement some clever i18n mechanism based on the userLogin 
and locale in the binding
         if (this.binding.hasVariable('request')) {
             // the script is invoked as an "event"
             if (message) {
@@ -174,4 +207,35 @@ abstract class GroovyBaseScript extends Script {
         return UtilProperties.getMessage(ressource, message, locale)
     }
 
+    void fail(String message) throws ServiceErrorException {
+        throw new ServiceErrorException(message)
+    }
+    void fail(String resource, String key, Map context = [:]) throws 
ServiceErrorException {
+        fail(label(resource, key, context))
+    }
+    void require(boolean condition, String message) throws 
ServiceErrorException {
+        if (!condition) {
+            fail(message)
+        }
+    }
+    void require(boolean condition, String resource, String key, Map context = 
[:]) throws ServiceErrorException {
+        if (!condition) {
+            fail(resource, key, context)
+        }
+    }
+
+    private Map buildServiceContext(LocalDispatcher dispatcher, String 
serviceName, Map inputMap) {
+        DispatchContext dctx = dispatcher.getDispatchContext()
+        inputMap.userLogin = inputMap.userLogin ?: 
this.binding.hasVariable('userLogin')
+                ? this.binding.getVariable('userLogin')
+                : this.binding.getVariable('parameters').userLogin
+        inputMap.timeZone = inputMap.timeZone ?: 
this.binding.hasVariable('timeZone')
+                ? this.binding.getVariable('timeZone')
+                : this.binding.getVariable('parameters').timeZone
+        inputMap.locale = inputMap.locale ?: this.binding.hasVariable('locale')
+                ? this.binding.getVariable('locale')
+                : this.binding.getVariable('parameters').locale
+        return dctx.makeValidContext(serviceName, ModelService.IN_PARAM, 
inputMap)
+    }
+
 }
diff --git 
a/framework/service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy
 
b/framework/service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy
index 44fde00ea7..758cde9191 100644
--- 
a/framework/service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy
+++ 
b/framework/service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy
@@ -43,3 +43,81 @@ Map testPingSuccessWithDSLCall() {
 Map testPingErrorWithDSLCall() {
     run service: 'testGroovyPingError', with: parameters
 }
+
+Map testPingSuccessWithAsyncDSLCall() {
+    runAsyncService('testGroovyPingSuccess', parameters)
+    return success()
+}
+
+Map testPingSuccessWithAsyncMapDSLCall() {
+    runAsync service: 'testGroovyPingSuccess', with: parameters
+    return success()
+}
+
+Map testPingSuccessWithI18n() {
+    return success('ServiceErrorUiLabels', 'ServiceValueNotFound')
+}
+
+Map testPingSuccessWithI18nContext() {
+    return success('ServiceErrorUiLabels', 'ServiceParameterValueNotValid',
+            [parameterName: parameters.parameterName, errorDetails: 
parameters.errorDetails], [:])
+}
+
+Map testPingErrorWithI18n() {
+    return error('ServiceErrorUiLabels', 'ServiceValueNotFound')
+}
+
+Map testPingErrorWithI18nContext() {
+    return error('ServiceErrorUiLabels', 'ServiceParameterValueNotValid',
+            [parameterName: parameters.parameterName, errorDetails: 
parameters.errorDetails])
+}
+
+Map testPingFailureWithI18n() {
+    return failure('ServiceErrorUiLabels', 'ServiceValueNotFound')
+}
+
+Map testFail() {
+    fail('Direct failure message')
+    return success()
+}
+
+Map testRequire() {
+    require(parameters.conditionMet, 'Required condition was not met')
+    return success()
+}
+
+Map testFailFromNestedClosure() {
+    // Proves the throw unwinds past the closure boundary, unlike `return 
error(...)` which would
+    // only exit this closure and let execution fall through to the success() 
below.
+    Closure innerCheck = {
+        fail('Nested failure message')
+    }
+    innerCheck()
+    return success()
+}
+
+Map testFailWithI18n() {
+    fail('ServiceErrorUiLabels', 'ServiceValueNotFound')
+    return success()
+}
+
+Map testRequireWithI18nContext() {
+    require(false, 'ServiceErrorUiLabels', 'ServiceParameterValueNotValid',
+            [parameterName: parameters.parameterName, errorDetails: 
parameters.errorDetails])
+    return success()
+}
+
+Map testEntityDslCreate() {
+    create('Testing', [testingId: parameters.testingId, testingName: 
parameters.testingName])
+    return success()
+}
+
+Map testEntityDslUpdate() {
+    update('Testing').where([testingId: 
parameters.testingId]).set([testingName: parameters.testingName])
+    return success()
+}
+
+Map testEntityDslDelete() {
+    int rowsRemoved = delete('Testing').where([testingId: 
parameters.testingId])
+    return success([rowsRemoved: rowsRemoved])
+}
diff --git 
a/framework/service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy
 
b/framework/service/src/main/java/org/apache/ofbiz/service/ServiceErrorException.java
similarity index 54%
copy from 
framework/service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy
copy to 
framework/service/src/main/java/org/apache/ofbiz/service/ServiceErrorException.java
index 44fde00ea7..4d4cb7ab79 100644
--- 
a/framework/service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy
+++ 
b/framework/service/src/main/java/org/apache/ofbiz/service/ServiceErrorException.java
@@ -15,31 +15,20 @@
  * KIND, either express or implied.  See the License for the
  * specific language governing permissions and limitations
  * under the License.
-*/
-package org.apache.ofbiz.service.test
+ 
*******************************************************************************/
+package org.apache.ofbiz.service;
 
-// Despite it's name, this class is not for test, it's only services
+/**
+ * Thrown by the Groovy DSL's {@code fail()}/{@code require()} control-flow 
helpers. Extends
+ * {@link ExecutionServiceException} so it is recognized, with no engine 
changes, by the same
+ * {@code instanceof ExecutionServiceException} check {@code GroovyEngine} 
already uses to convert
+ * a nested {@code runService()} failure into a service error result, and is 
caught generically by
+ * {@code GroovyEventHandler}'s existing catch-all exception handling for 
events.
+ */
+@SuppressWarnings("serial")
+public class ServiceErrorException extends ExecutionServiceException {
 
-Map testPingSuccess() {
-    Map returnMap = success('Service result success')
-    if (parameters.ping) {
-        returnMap.pong = parameters.ping
+    public ServiceErrorException(String str) {
+        super(str);
     }
-    return returnMap
-}
-
-Map testPingError() {
-    Map returnMap = error('Service result error')
-    if (parameters.ping) {
-        returnMap.pong = parameters.ping
-    }
-    return returnMap
-}
-
-Map testPingSuccessWithDSLCall() {
-    run service: 'testGroovyPingSuccess', with: parameters
-}
-
-Map testPingErrorWithDSLCall() {
-    run service: 'testGroovyPingError', with: parameters
 }
diff --git 
a/framework/service/src/test/java/org/apache/ofbiz/service/test/GroovyDslServiceEngineTests.java
 
b/framework/service/src/test/java/org/apache/ofbiz/service/test/GroovyDslServiceEngineTests.java
index 8b888b19c0..56e991f563 100644
--- 
a/framework/service/src/test/java/org/apache/ofbiz/service/test/GroovyDslServiceEngineTests.java
+++ 
b/framework/service/src/test/java/org/apache/ofbiz/service/test/GroovyDslServiceEngineTests.java
@@ -19,11 +19,21 @@
 package org.apache.ofbiz.service.test;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import java.sql.Timestamp;
+import java.util.Locale;
 import java.util.Map;
+import java.util.UUID;
 
+import org.apache.ofbiz.base.util.UtilDateTime;
 import org.apache.ofbiz.base.util.UtilMisc;
+import org.apache.ofbiz.base.util.UtilProperties;
+import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.entity.condition.EntityCondition;
+import org.apache.ofbiz.entity.condition.EntityOperator;
 import org.apache.ofbiz.service.ModelService;
 import org.apache.ofbiz.service.ServiceUtil;
 import org.apache.ofbiz.testtools.JunitJupiterTest;
@@ -61,4 +71,230 @@ public class GroovyDslServiceEngineTests implements 
JupiterTestHelper {
         assertTrue(ServiceUtil.isError(result));
         assertEquals("Service result error", 
result.get(ModelService.ERROR_MESSAGE));
     }
+
+    @Test
+    public final void testGroovyServiceAsyncDSLCall() throws Exception {
+        String pingMsg = "Unit Test Async";
+        Map<String, Object> pingMap = UtilMisc.toMap("ping", pingMsg);
+
+        // ofbiz --test does not wipe JobSandbox between runs, and JobPoller 
never picks up a
+        // pending job during a test run (see below) - so a prior run's 
leftover SERVICE_PENDING
+        // row for this same service would otherwise satisfy every assertion 
below even if
+        // runAsyncService() were broken. Scoping to runTime >= beforeCall 
makes the test hermetic.
+        Timestamp beforeCall = UtilDateTime.nowTimestamp();
+        Map<String, Object> result = 
getDispatcher().runSync("testGroovyPingSuccessWithAsyncDSLCall", pingMap);
+        assertTrue(ServiceUtil.isSuccess(result));
+
+        // Verifying actual pickup/completion by the Job Scheduler is not 
possible from inside an
+        // "ofbiz --test" run: JobPoller's loop is gated on 
Start.getInstance().getCurrentState()
+        // == RUNNING, but StartupControlPanel.loadContainers() only flips the 
server to RUNNING
+        // after ContainerLoader.load() returns - and that method runs 
TestRunContainer (which runs
+        // every test suite synchronously on that same thread) before 
returning. So the server never
+        // reaches RUNNING until after this entire test suite has already 
finished, and JobPoller
+        // never gets to poll during the run. Instead, this asserts that 
runAsyncService() itself
+        // persisted a well-formed job request - the only thing observable 
from within the test -
+        // matching the existing precedent in ServicePurgeTest.groovy and
+        // ServiceMultipleNodeRecoveryTest.groovy, neither of which waits for 
live completion either.
+        GenericValue jobSandbox = from("JobSandbox")
+                .where(EntityCondition.makeCondition("serviceName", 
"testGroovyPingSuccess"),
+                        EntityCondition.makeCondition("runTime", 
EntityOperator.GREATER_THAN_EQUAL_TO, beforeCall))
+                .orderBy("-runTime")
+                .queryFirst();
+        assertNotNull(jobSandbox, "Expected runAsyncService() to persist a 
JobSandbox record for testGroovyPingSuccess");
+        assertEquals("SERVICE_PENDING", jobSandbox.getString("statusId"));
+        assertNotNull(jobSandbox.getString("runtimeDataId"),
+                "Expected the async call's context to be persisted as 
RuntimeData");
+    }
+
+    @Test
+    public final void testGroovyServiceAsyncMapDSLCall() throws Exception {
+        // Same as testGroovyServiceAsyncDSLCall above, but exercises the 
`runAsync service: ...,
+        // with: ...` map-argument calling form instead of the positional 
runAsyncService(name, map)
+        // form - see runAsync(Map) in GroovyBaseScript.
+        String pingMsg = "Unit Test Async Map";
+        Map<String, Object> pingMap = UtilMisc.toMap("ping", pingMsg);
+
+        Timestamp beforeCall = UtilDateTime.nowTimestamp();
+        Map<String, Object> result = 
getDispatcher().runSync("testGroovyPingSuccessWithAsyncMapDSLCall", pingMap);
+        assertTrue(ServiceUtil.isSuccess(result));
+
+        GenericValue jobSandbox = from("JobSandbox")
+                .where(EntityCondition.makeCondition("serviceName", 
"testGroovyPingSuccess"),
+                        EntityCondition.makeCondition("runTime", 
EntityOperator.GREATER_THAN_EQUAL_TO, beforeCall))
+                .orderBy("-runTime")
+                .queryFirst();
+        assertNotNull(jobSandbox, "Expected runAsync service:/with: to persist 
a JobSandbox record for testGroovyPingSuccess");
+        assertEquals("SERVICE_PENDING", jobSandbox.getString("statusId"));
+        assertNotNull(jobSandbox.getString("runtimeDataId"),
+                "Expected the async call's context to be persisted as 
RuntimeData");
+    }
+
+    @Test
+    public final void testGroovyPingSuccessWithI18n() throws Exception {
+        // Two different locales for the same resource/key prove the message 
is actually being
+        // resolved through UtilProperties, not just echoed back as a literal 
string.
+        Map<String, Object> resultEn = 
getDispatcher().runSync("testGroovyPingSuccessWithI18n",
+                UtilMisc.toMap("locale", Locale.ENGLISH));
+        assertTrue(ServiceUtil.isSuccess(resultEn));
+        assertEquals(UtilProperties.getMessage("ServiceErrorUiLabels", 
"ServiceValueNotFound", Locale.ENGLISH),
+                resultEn.get(ModelService.SUCCESS_MESSAGE));
+
+        Map<String, Object> resultFr = 
getDispatcher().runSync("testGroovyPingSuccessWithI18n",
+                UtilMisc.toMap("locale", Locale.FRENCH));
+        assertTrue(ServiceUtil.isSuccess(resultFr));
+        assertEquals(UtilProperties.getMessage("ServiceErrorUiLabels", 
"ServiceValueNotFound", Locale.FRENCH),
+                resultFr.get(ModelService.SUCCESS_MESSAGE));
+    }
+
+    @Test
+    public final void testGroovyPingSuccessWithI18nContext() throws Exception {
+        Map<String, Object> input = UtilMisc.toMap("locale", Locale.ENGLISH,
+                "parameterName", "productId", "errorDetails", "must not be 
empty");
+
+        Map<String, Object> result = 
getDispatcher().runSync("testGroovyPingSuccessWithI18nContext", input);
+
+        assertTrue(ServiceUtil.isSuccess(result));
+        Map<String, Object> expectedContext = UtilMisc.toMap("parameterName", 
"productId", "errorDetails", "must not be empty");
+        assertEquals(UtilProperties.getMessage("ServiceErrorUiLabels", 
"ServiceParameterValueNotValid", expectedContext, Locale.ENGLISH),
+                result.get(ModelService.SUCCESS_MESSAGE));
+    }
+
+    @Test
+    public final void testGroovyPingErrorWithI18n() throws Exception {
+        Map<String, Object> result = 
getDispatcher().runSync("testGroovyPingErrorWithI18n",
+                UtilMisc.toMap("locale", Locale.FRENCH));
+        assertTrue(ServiceUtil.isError(result));
+        assertEquals(UtilProperties.getMessage("ServiceErrorUiLabels", 
"ServiceValueNotFound", Locale.FRENCH),
+                ServiceUtil.getErrorMessage(result));
+    }
+
+    @Test
+    public final void testGroovyPingErrorWithI18nContext() throws Exception {
+        Map<String, Object> input = UtilMisc.toMap("locale", Locale.ENGLISH,
+                "parameterName", "quantity", "errorDetails", "must be a 
positive number");
+
+        Map<String, Object> result = 
getDispatcher().runSync("testGroovyPingErrorWithI18nContext", input);
+
+        assertTrue(ServiceUtil.isError(result));
+        Map<String, Object> expectedContext = UtilMisc.toMap("parameterName", 
"quantity", "errorDetails", "must be a positive number");
+        assertEquals(UtilProperties.getMessage("ServiceErrorUiLabels", 
"ServiceParameterValueNotValid", expectedContext, Locale.ENGLISH),
+                ServiceUtil.getErrorMessage(result));
+    }
+
+    @Test
+    public final void testGroovyPingFailureWithI18n() throws Exception {
+        Map<String, Object> result = 
getDispatcher().runSync("testGroovyPingFailureWithI18n",
+                UtilMisc.toMap("locale", Locale.ENGLISH));
+        assertTrue(ServiceUtil.isFailure(result));
+        assertEquals(UtilProperties.getMessage("ServiceErrorUiLabels", 
"ServiceValueNotFound", Locale.ENGLISH),
+                ServiceUtil.getErrorMessage(result));
+    }
+
+    @Test
+    public final void testGroovyFail() throws Exception {
+        Map<String, Object> result = getDispatcher().runSync("testGroovyFail", 
UtilMisc.toMap());
+        assertTrue(ServiceUtil.isError(result));
+        assertEquals("Direct failure message", 
ServiceUtil.getErrorMessage(result));
+    }
+
+    @Test
+    public final void testGroovyRequireConditionFalse() throws Exception {
+        Map<String, Object> result = 
getDispatcher().runSync("testGroovyRequire", UtilMisc.toMap("conditionMet", 
false));
+        assertTrue(ServiceUtil.isError(result));
+        assertEquals("Required condition was not met", 
ServiceUtil.getErrorMessage(result));
+    }
+
+    @Test
+    public final void testGroovyRequireConditionTrue() throws Exception {
+        Map<String, Object> result = 
getDispatcher().runSync("testGroovyRequire", UtilMisc.toMap("conditionMet", 
true));
+        assertTrue(ServiceUtil.isSuccess(result));
+    }
+
+    @Test
+    public final void testGroovyFailFromNestedClosure() throws Exception {
+        // The whole point of throwing rather than returning: a plain `return 
error(...)` inside
+        // innerCheck() would only exit that closure and fall through to 
success() in the test
+        // service - this proves the throw actually unwinds past the closure 
boundary instead.
+        Map<String, Object> result = 
getDispatcher().runSync("testGroovyFailFromNestedClosure", UtilMisc.toMap());
+        assertTrue(ServiceUtil.isError(result));
+        assertEquals("Nested failure message", 
ServiceUtil.getErrorMessage(result));
+    }
+
+    @Test
+    public final void testGroovyFailWithI18n() throws Exception {
+        Map<String, Object> result = 
getDispatcher().runSync("testGroovyFailWithI18n",
+                UtilMisc.toMap("locale", Locale.FRENCH));
+        assertTrue(ServiceUtil.isError(result));
+        assertEquals(UtilProperties.getMessage("ServiceErrorUiLabels", 
"ServiceValueNotFound", Locale.FRENCH),
+                ServiceUtil.getErrorMessage(result));
+    }
+
+    @Test
+    public final void testGroovyRequireWithI18nContext() throws Exception {
+        Map<String, Object> input = UtilMisc.toMap("locale", Locale.ENGLISH,
+                "parameterName", "quantity", "errorDetails", "must be a 
positive number");
+
+        Map<String, Object> result = 
getDispatcher().runSync("testGroovyRequireWithI18nContext", input);
+
+        assertTrue(ServiceUtil.isError(result));
+        Map<String, Object> expectedContext = UtilMisc.toMap("parameterName", 
"quantity", "errorDetails", "must be a positive number");
+        assertEquals(UtilProperties.getMessage("ServiceErrorUiLabels", 
"ServiceParameterValueNotValid", expectedContext, Locale.ENGLISH),
+                ServiceUtil.getErrorMessage(result));
+    }
+
+    // testingId is a VARCHAR(20) pk, so test ids need to stay short - an 
8-char UUID fragment
+    // per prefix is plenty of uniqueness for a single test run.
+    private String shortTestingId(String prefix) {
+        return prefix + UUID.randomUUID().toString().substring(0, 8);
+    }
+
+    @Test
+    public final void testGroovyEntityDslCreate() throws Exception {
+        String testingId = shortTestingId("gdCr-");
+        Map<String, Object> input = UtilMisc.toMap("testingId", testingId, 
"testingName", "Created via DSL");
+
+        Map<String, Object> result = 
getDispatcher().runSync("testGroovyEntityDslCreate", input);
+        assertTrue(ServiceUtil.isSuccess(result));
+
+        GenericValue created = from("Testing").where("testingId", 
testingId).queryOne();
+        assertNotNull(created, "Expected create() to persist a new Testing 
record");
+        assertEquals("Created via DSL", created.getString("testingName"));
+    }
+
+    @Test
+    public final void testGroovyEntityDslUpdate() throws Exception {
+        String testingId = shortTestingId("gdUp-");
+        getDelegator().create("Testing", UtilMisc.toMap("testingId", 
testingId, "testingName", "Before"));
+
+        Map<String, Object> result = 
getDispatcher().runSync("testGroovyEntityDslUpdate",
+                UtilMisc.toMap("testingId", testingId, "testingName", 
"After"));
+        assertTrue(ServiceUtil.isSuccess(result));
+
+        GenericValue updated = from("Testing").where("testingId", 
testingId).queryOne();
+        assertEquals("After", updated.getString("testingName"));
+    }
+
+    @Test
+    public final void testGroovyEntityDslUpdateNotFound() throws Exception {
+        // requireNewTransaction=true so the thrown-and-caught 
ExecutionServiceException's
+        // rollback-only marking does not poison this test method's own 
transaction, matching
+        // the existing testGroovyPingErrorWithDSLCall precedent above.
+        Map<String, Object> result = 
getDispatcher().runSync("testGroovyEntityDslUpdate",
+                UtilMisc.toMap("testingId", shortTestingId("gdMiss-"), 
"testingName", "Irrelevant"), 60, true);
+        assertTrue(ServiceUtil.isError(result));
+    }
+
+    @Test
+    public final void testGroovyEntityDslDelete() throws Exception {
+        String testingId = shortTestingId("gdDel-");
+        getDelegator().create("Testing", UtilMisc.toMap("testingId", 
testingId, "testingName", "To be removed"));
+
+        Map<String, Object> result = 
getDispatcher().runSync("testGroovyEntityDslDelete",
+                UtilMisc.toMap("testingId", testingId));
+        assertTrue(ServiceUtil.isSuccess(result));
+        assertEquals(1, result.get("rowsRemoved"));
+
+        GenericValue afterDelete = from("Testing").where("testingId", 
testingId).queryOne();
+        assertNull(afterDelete, "Expected delete() to remove the Testing 
record");
+    }
 }

Reply via email to