From 3e052eb28fdab768a7b0363512e2b39afbe16516 Mon Sep 17 00:00:00 2001 From: delchev Date: Thu, 17 Sep 2026 13:31:24 +0300 Subject: [PATCH] bpm: a process variable started as a whole number stays whole (#7373) A process started through `Process.start(key, businessKey, json)` had its variables read with the platform's default Gson, which types every untyped JSON number as a `Double`. So a record's primary key handed over as `33` landed in Flowable's variable store as `33.0`, and every reader that renders such a variable as text said so: an Inbox row's subject locator read "33.0", which the generated controller's integer path parameter then refused - the card could not load the very record the task is about. The variables of a start and of a task completion are now read with the LONG_OR_DOUBLE number strategy: a JSON number with no fractional part is an integer, a genuine decimal stays a decimal. Generated delegates were never affected (they read the key as `((Number) key).longValue()`), which is why the symptom was confined to the one path that renders the key as text. An instance started before this fix still carries the widened key, so the subject reads it as the whole number it is rather than leaving those tasks broken until their process ends. Fixes #7373 Co-Authored-By: Claude Opus 5 --- .../flowable/config/BpmProviderFlowable.java | 6 +- .../bpm/flowable/config/ProcessVariables.java | 49 +++++++++++ .../bpm/flowable/config/TaskServiceImpl.java | 3 +- .../engine/bpm/flowable/dto/TaskSubject.java | 22 ++++- .../config/ProcessVariablesEngineTest.java | 85 +++++++++++++++++++ .../flowable/config/ProcessVariablesTest.java | 51 +++++++++++ .../bpm/flowable/dto/TaskSubjectTest.java | 14 +++ .../tests/api/IntentEmissionCoverageIT.java | 8 +- 8 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/ProcessVariables.java create mode 100644 components/engine/engine-bpm-flowable/src/test/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/ProcessVariablesEngineTest.java create mode 100644 components/engine/engine-bpm-flowable/src/test/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/ProcessVariablesTest.java diff --git a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/BpmProviderFlowable.java b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/BpmProviderFlowable.java index 2edced73ac2..e0da2887db7 100644 --- a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/BpmProviderFlowable.java +++ b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/BpmProviderFlowable.java @@ -24,7 +24,6 @@ import java.util.Set; import java.util.stream.Collectors; import org.apache.commons.io.IOUtils; -import org.eclipse.dirigible.commons.api.helpers.GsonHelper; import org.eclipse.dirigible.components.base.tenant.Tenant; import org.eclipse.dirigible.components.base.tenant.TenantContext; import org.eclipse.dirigible.components.engine.bpm.BpmProvider; @@ -174,10 +173,7 @@ public void undeployProcess(String deploymentId) { * @return the process instance id */ public String startProcess(String key, String businessKey, String parameters) { - @SuppressWarnings("unchecked") - Map variables = GsonHelper.fromJson(parameters, HashMap.class); - - return startProcess(key, businessKey, variables); + return startProcess(key, businessKey, ProcessVariables.fromJson(parameters)); } public String startProcess(String key, String businessKey, Map variables) { diff --git a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/ProcessVariables.java b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/ProcessVariables.java new file mode 100644 index 00000000000..b45b188f5c2 --- /dev/null +++ b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/ProcessVariables.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.engine.bpm.flowable.config; + +import java.util.Map; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.ToNumberPolicy; +import com.google.gson.reflect.TypeToken; + +/** + * Reads a JSON document of process variables, keeping a whole number whole (issue #7373). + *

+ * The default Gson deserialization of an untyped JSON number is a {@code Double}, so a record's + * primary key handed to {@code Process.start} as {@code 33} landed in Flowable's variable store as + * {@code 33.0} - and every reader that renders such a variable as text says so: an Inbox row's + * subject locator read {@code "33.0"}, which the generated controller's integer path parameter then + * refused, so the card could not load the very record the task is about. A JSON number with no + * fractional part is an integer, so it is read as a {@code Long}; a genuine decimal stays a + * {@code Double}. + */ +final class ProcessVariables { + + /** Variables are untyped, so the number strategy is what decides Long versus Double. */ + private static final Gson GSON = new GsonBuilder().setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .create(); + + private static final TypeToken> VARIABLES = new TypeToken<>() {}; + + private ProcessVariables() {} + + /** + * Parses a JSON document whose top-level fields are process variables. + * + * @param json the document, may be null + * @return the variables, or null when there is no document + */ + static Map fromJson(String json) { + return GSON.fromJson(json, VARIABLES); + } +} diff --git a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/TaskServiceImpl.java b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/TaskServiceImpl.java index 8ba2298298e..b5c81e42d3d 100644 --- a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/TaskServiceImpl.java +++ b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/TaskServiceImpl.java @@ -183,8 +183,7 @@ public void setTaskVariable(String taskId, String variableName, Object variable) @Override public void completeTask(String taskId, String variables) { - Map processVariables = GsonHelper.fromJson(variables, HashMap.class); - completeTask(taskId, processVariables); + completeTask(taskId, ProcessVariables.fromJson(variables)); } @Override diff --git a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/dto/TaskSubject.java b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/dto/TaskSubject.java index e51665aecf1..ce37c4bb80a 100644 --- a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/dto/TaskSubject.java +++ b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/dto/TaskSubject.java @@ -68,7 +68,7 @@ public record Field(String property, String kind, String url, String label) { public static TaskSubject from(Map variables) { String declaration = text(variables.get(SUBJECT_FIELDS)); String url = text(variables.get(ENTITY_URL)); - String id = text(variables.get(ENTITY_ID)); + String id = key(variables.get(ENTITY_ID)); if (declaration == null || url == null || id == null) { return null; } @@ -101,6 +101,26 @@ public static TaskSubject from(Map variables) { return fields.isEmpty() ? null : new TaskSubject(url, id, fields); } + /** + * The record's key, as the whole number it is (issue #7373). An instance started before the + * variable store stopped widening an untyped JSON number to a {@code Double} carries the key as + * {@code 33.0}, and the generated controller's integer path parameter refuses that - so the card + * could not load the record it exists to show. Anything that is not an integral number is read as + * it stands. + * + * @param value the raw {@code __entityId} variable + * @return the key as text, or null when there is none + */ + private static String key(Object value) { + if (value instanceof Double || value instanceof Float) { + double raw = ((Number) value).doubleValue(); + if (Double.isFinite(raw) && raw == Math.rint(raw)) { + return String.valueOf((long) raw); + } + } + return text(value); + } + private static String text(Object value) { if (value == null) { return null; diff --git a/components/engine/engine-bpm-flowable/src/test/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/ProcessVariablesEngineTest.java b/components/engine/engine-bpm-flowable/src/test/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/ProcessVariablesEngineTest.java new file mode 100644 index 00000000000..b995d76120d --- /dev/null +++ b/components/engine/engine-bpm-flowable/src/test/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/ProcessVariablesEngineTest.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.engine.bpm.flowable.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.eclipse.dirigible.components.engine.bpm.flowable.dto.TaskSubject; +import org.flowable.engine.ProcessEngine; +import org.flowable.engine.impl.cfg.StandaloneInMemProcessEngineConfiguration; +import org.flowable.task.api.Task; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * That a key started as a JSON number survives Flowable's variable store as the whole number it is, + * against a REAL engine (issue #7373): the store types the variable from the value it is handed, so + * a {@code Double} there is what every reader downstream renders as {@code "33.0"} - the locator an + * Inbox row's subject card then cannot load the record through. + */ +class ProcessVariablesEngineTest { + + private static final String PROCESS_XML = + """ + + + + + + + + + + + """; + + /** Exactly what a generated trigger hands Process.start - Json.stringify of its locator map. */ + private static final String START_PAYLOAD = """ + {"Id": 33, "__entityUrl": "/services/java/billing/gen/billing/api/sales_invoice/SalesInvoiceController", + "__entityId": 33, "__subjectFields": "Number:text,Total:number"} + """; + + private static ProcessEngine engine; + + @BeforeAll + static void startEngine() { + StandaloneInMemProcessEngineConfiguration configuration = new StandaloneInMemProcessEngineConfiguration(); + configuration.setJdbcUrl("jdbc:h2:mem:process-variables-test;DB_CLOSE_DELAY=-1"); + engine = configuration.buildProcessEngine(); + engine.getRepositoryService() + .createDeployment() + .addString("approve.bpmn20.xml", PROCESS_XML) + .deploy(); + engine.getRuntimeService() + .startProcessInstanceByKey("approve", ProcessVariables.fromJson(START_PAYLOAD)); + } + + @AfterAll + static void stopEngine() { + if (engine != null) { + engine.close(); + } + } + + @Test + void theRecordsKeyIsReadableAsAnIntegerPathParameter() { + Task task = engine.getTaskService() + .createTaskQuery() + .taskAssignee("admin") + .includeProcessVariables() + .singleResult(); + + assertEquals(33L, task.getProcessVariables() + .get("__entityId")); + assertEquals("33", TaskSubject.from(task.getProcessVariables()) + .id()); + } +} diff --git a/components/engine/engine-bpm-flowable/src/test/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/ProcessVariablesTest.java b/components/engine/engine-bpm-flowable/src/test/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/ProcessVariablesTest.java new file mode 100644 index 00000000000..02ed4a03bbe --- /dev/null +++ b/components/engine/engine-bpm-flowable/src/test/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/ProcessVariablesTest.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.engine.bpm.flowable.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +/** + * Reading the JSON a process is started and a task is completed with (issue #7373). + */ +class ProcessVariablesTest { + + /** + * The defect itself: the record's key reached the variable store as a Double, so every reader that + * renders it as text said "33.0" - which the generated controller's integer path parameter refuses. + */ + @Test + void aWholeNumberStaysWhole() { + Map variables = ProcessVariables.fromJson("{\"__entityId\": 33, \"Id\": 33}"); + + assertEquals(33L, variables.get("__entityId")); + assertEquals("33", String.valueOf(variables.get("Id"))); + } + + /** A genuine decimal keeps its fraction - only the widening of an integer was wrong. */ + @Test + void aDecimalStaysADecimal() { + Map variables = ProcessVariables.fromJson("{\"Total\": 12.5, \"Name\": \"ACME\", \"Draft\": true}"); + + assertEquals(12.5d, variables.get("Total")); + assertEquals("ACME", variables.get("Name")); + assertEquals(Boolean.TRUE, variables.get("Draft")); + } + + /** A start with no parameters at all is a start with no variables, as before. */ + @Test + void noDocumentIsNoVariables() { + assertNull(ProcessVariables.fromJson(null)); + } +} diff --git a/components/engine/engine-bpm-flowable/src/test/java/org/eclipse/dirigible/components/engine/bpm/flowable/dto/TaskSubjectTest.java b/components/engine/engine-bpm-flowable/src/test/java/org/eclipse/dirigible/components/engine/bpm/flowable/dto/TaskSubjectTest.java index cf3c4fc11c8..e8382f4357c 100644 --- a/components/engine/engine-bpm-flowable/src/test/java/org/eclipse/dirigible/components/engine/bpm/flowable/dto/TaskSubjectTest.java +++ b/components/engine/engine-bpm-flowable/src/test/java/org/eclipse/dirigible/components/engine/bpm/flowable/dto/TaskSubjectTest.java @@ -65,6 +65,20 @@ void aRelationWithoutItsLocatorIsDropped() { .size()); } + /** + * An instance started before the variable store stopped widening an untyped JSON number carries the + * key as a Double; the locator must still be the integer the generated controller's path parameter + * accepts, or the card cannot load the record (issue #7373). + */ + @Test + void aKeyWidenedToADoubleStillReadsAsAWholeNumber() { + Map variables = variables(); + variables.put("__entityId", 33.0d); + + assertEquals("33", TaskSubject.from(variables) + .id()); + } + /** A hand-authored BPMN, or a deployment generated before #7077, declares no subject at all. */ @Test void aProcessThatDeclaresNoSubjectHasNone() { diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java index 744b2fdebf3..6a87507c241 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java @@ -5312,7 +5312,13 @@ private void assertRuntimeEnforcement() { .then() .statusCode(200) .body("find { it.name == 'Confirm' }.subject.url", notNullValue()) - .body("find { it.name == 'Confirm' }.subject.fields.property", hasItem("Name")), + .body("find { it.name == 'Confirm' }.subject.fields.property", hasItem("Name")) + // ...and the record's key is the WHOLE number the generated controller's + // integer path parameter accepts (#7373): started as an untyped JSON + // number it used to reach the variable store as a Double, so the locator + // read "1.0" and the card could not load the record it exists to show. + .body("find { it.name == 'Confirm' }.subject.id", + org.hamcrest.Matchers.matchesPattern("\\d+")), 30); // ...and the record knows about the instance that task belongs to: the trigger stamped the