Add number type conversion validation to ensure proper casting between numeric types#1205
Add number type conversion validation to ensure proper casting between numeric types#1205mcruzdev wants to merge 2 commits intoserverlessworkflow:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds a convertNumber private method to AbstractWorkflowModel to properly dispatch numeric type conversions when as(Class<T>) is called with a Number-derived class (previously returning whatever asNumber() produced, which could cause a ClassCastException at the call site). It also adds a comprehensive suite of integration tests covering several primitive wrapper conversions (Long↔Integer, Double↔Integer, Float↔Double, Short↔Integer, Byte↔Integer).
Changes:
AbstractWorkflowModel.java: Replace the rawasNumber()call withasNumber().map(num -> convertNumber(num, clazz)), and add theconvertNumberdispatch method.WorkflowNumberConversionTest.java: New integration test class exercising seven conversion scenarios.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
impl/core/src/main/java/io/serverlessworkflow/impl/AbstractWorkflowModel.java |
Adds convertNumber method and wires it into as() for Number-typed requests |
impl/test/src/test/java/io/serverlessworkflow/impl/test/WorkflowNumberConversionTest.java |
Integration tests for numeric type conversions across multiple primitive wrapper types |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } else { | ||
| return (T) num; | ||
| } |
There was a problem hiding this comment.
The convertNumber method does not handle BigDecimal or BigInteger types, even though both are Number subclasses supported by the codebase (as evidenced by JacksonModelFactory.from(Number) and JsonUtils handling them). When as(BigDecimal.class) or as(BigInteger.class) is called, execution reaches the else { return (T) num; } branch. Because asNumber() in JacksonModel always returns a Long (via node.asLong()), this unchecked cast will produce a ClassCastException at the call site. The convertNumber method should explicitly handle BigDecimal and BigInteger — for example, by calling new BigDecimal(num.toString()) or BigDecimal.valueOf(num.longValue()) for BigDecimal, and BigInteger.valueOf(num.longValue()) for BigInteger.
impl/test/src/test/java/io/serverlessworkflow/impl/test/WorkflowNumberConversionTest.java
Show resolved
Hide resolved
impl/core/src/main/java/io/serverlessworkflow/impl/AbstractWorkflowModel.java
Outdated
Show resolved
Hide resolved
.../lambda/src/main/java/io/serverlessworkflow/impl/expressions/func/JavaExpressionFactory.java
Outdated
Show resolved
Hide resolved
| private <T> T convertNumber(Number num, Class<T> clazz) { | ||
| if (clazz == Integer.class) { | ||
| return (T) Integer.valueOf(num.intValue()); | ||
| } else if (clazz == Long.class) { | ||
| return (T) Long.valueOf(num.longValue()); | ||
| } else if (clazz == Double.class) { | ||
| return (T) Double.valueOf(num.doubleValue()); | ||
| } else if (clazz == Float.class) { | ||
| return (T) Float.valueOf(num.floatValue()); | ||
| } else if (clazz == Short.class) { | ||
| return (T) Short.valueOf(num.shortValue()); | ||
| } else if (clazz == Byte.class) { | ||
| return (T) Byte.valueOf(num.byteValue()); | ||
| } else { | ||
| return (T) num; | ||
| } |
There was a problem hiding this comment.
I think we need to handle this differently, rather than using the asNumber public interface method, add a new protected method here asNumber(Class<?> targetNumberClass) and implement it on the children classes.
That way, you can use JsonNode.asLong, asInt methods in JakcsonModel and copy this implementation to JavaModel. The approach you took implies two conversions: the one performed by asNumber (in JavaModel is straightforward, but in Jackson it is not) and the one performed by this method. It is more efficient to have one in the children class.
…n numeric types Signed-off-by: Matheus Cruz <matheuscruz.dev@gmail.com>
| return (Optional<T>) asDate(); | ||
| } else if (Number.class.isAssignableFrom(clazz)) { | ||
| return (Optional<T>) asNumber(); | ||
| return asNumber().map(num -> convertNumber(num, clazz)); |
There was a problem hiding this comment.
here is whereasNumber(class)method should be invoked
Signed-off-by: Matheus Cruz <matheuscruz.dev@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @Override | ||
| @SuppressWarnings("unchecked") | ||
| protected <T> Optional<T> asNumber(Class<?> targetNumberClass) { | ||
| if (!node.isNumber()) { | ||
| return Optional.empty(); | ||
| } | ||
| if (targetNumberClass == Integer.class) { | ||
| return (Optional<T>) Optional.of(node.asInt()); | ||
| } else if (targetNumberClass == Long.class) { | ||
| return (Optional<T>) Optional.of(node.asLong()); | ||
| } else if (targetNumberClass == Double.class) { | ||
| return (Optional<T>) Optional.of(node.asDouble()); | ||
| } else if (targetNumberClass == Float.class) { | ||
| return (Optional<T>) Optional.of((float) node.asDouble()); | ||
| } else if (targetNumberClass == Short.class) { | ||
| return (Optional<T>) Optional.of((short) node.asInt()); | ||
| } else if (targetNumberClass == Byte.class) { | ||
| return (Optional<T>) Optional.of((byte) node.asInt()); | ||
| } else { | ||
| return (Optional<T>) Optional.of(node.numberValue()); | ||
| } | ||
| } |
There was a problem hiding this comment.
The else branch returns node.numberValue() for any Number subclass target (e.g., BigDecimal.class, BigInteger.class). numberValue() is not guaranteed to be an instance of targetNumberClass, so model.as(BigDecimal.class) can return an Optional containing an Integer/Long/... and then fail with a ClassCastException at the call site. Fix by either (1) explicitly handling BigDecimal/BigInteger using node.decimalValue() / node.bigIntegerValue(), and/or (2) checking targetNumberClass.isInstance(value) before returning and otherwise returning Optional.empty() (or performing a supported conversion).
| @Override | ||
| @SuppressWarnings("unchecked") | ||
| protected <T> Optional<T> asNumber(Class<?> targetNumberClass) { | ||
| if (!(object instanceof Number num)) { | ||
| return Optional.empty(); | ||
| } | ||
| if (targetNumberClass == Integer.class) { | ||
| return (Optional<T>) Optional.of(num.intValue()); | ||
| } else if (targetNumberClass == Long.class) { | ||
| return (Optional<T>) Optional.of(num.longValue()); | ||
| } else if (targetNumberClass == Double.class) { | ||
| return (Optional<T>) Optional.of(num.doubleValue()); | ||
| } else if (targetNumberClass == Float.class) { | ||
| return (Optional<T>) Optional.of(num.floatValue()); | ||
| } else if (targetNumberClass == Short.class) { | ||
| return (Optional<T>) Optional.of(num.shortValue()); | ||
| } else if (targetNumberClass == Byte.class) { | ||
| return (Optional<T>) Optional.of(num.byteValue()); | ||
| } else { | ||
| return (Optional<T>) Optional.of(num); | ||
| } | ||
| } |
There was a problem hiding this comment.
Same issue as the Jackson implementation: for targets like BigDecimal.class / BigInteger.class, the else branch returns the original num without ensuring it's an instance of targetNumberClass, which can surface as a ClassCastException for model.as(BigDecimal.class). Consider explicitly converting for common types (e.g., BigDecimal, BigInteger) and/or returning empty when !targetNumberClass.isInstance(convertedValue).
|
|
||
| protected abstract <T> Optional<T> convert(Class<T> clazz); | ||
|
|
||
| protected abstract <T> Optional<T> asNumber(Class<?> targetNumberClass); |
There was a problem hiding this comment.
Adding a new abstract method to a public abstract class is a source/binary breaking change for any external subclasses. If AbstractWorkflowModel is intended to be extended outside this module, consider providing a backward-compatible default implementation (e.g., a non-abstract asNumber(...) that falls back to the existing numeric handling) or reducing the exposure (package-private/sealed) if external extension isn’t supported.
| protected abstract <T> Optional<T> asNumber(Class<?> targetNumberClass); | |
| @SuppressWarnings("unchecked") | |
| protected <T> Optional<T> asNumber(Class<?> targetNumberClass) { | |
| if (!Number.class.isAssignableFrom(targetNumberClass)) { | |
| return Optional.empty(); | |
| } | |
| return (Optional<T>) convert((Class<T>) targetNumberClass); | |
| } |
| return (Optional<T>) asDate(); | ||
| } else if (Number.class.isAssignableFrom(clazz)) { | ||
| return (Optional<T>) asNumber(); | ||
| return asNumber(clazz); |
There was a problem hiding this comment.
The new numeric path uses Class<?> + unchecked casts in implementations. To improve type safety and remove the need for @SuppressWarnings(\"unchecked\"), consider changing the hook to a bounded generic signature such as protected abstract <N extends Number> Optional<N> asNumber(Class<N> targetNumberClass); and calling it with clazz.asSubclass(Number.class) (or equivalent) in this branch.
| "scoreProposal", | ||
| (Proposal input) -> { | ||
| Integer score = calculateScore(input.abstractText()); | ||
| System.out.println("Score calculated having the result as: " + score); |
There was a problem hiding this comment.
Avoid System.out.println in unit tests since it adds noise to test output and makes failures harder to scan. Prefer assertions only, or use a test logger if you need diagnostic output.
| System.out.println("Score calculated having the result as: " + score); |
Changes
convertNumbermethod in AbstractWorkflowModel to handle conversions between numeric typesCloses #1200