Skip to main content
Version: Current

SpEL tips and tricks

Common errors and misunderstandings​

  • Unlike JSON, SpEL uses curly brackets to denote the start and end of a list, e.g.: {1, 2, 3, 4}. To make things more confusing, in the code hints and the debugger Nussknacker shows lists using JSON notation for lists. So, stay alert.
  • {:} stands for the empty record (map). {} denotes an empty list.

Handling data types problems​

Sometimes the Nussknacker’s data typing subsystem, on which hints and validations are built, is too restrictive and complains about a data type mismatch. In such a case, you can use the #CONV.toAny() function to trick Nussknacker’s data typing system into thinking that the data type of the entered expression is valid. You can see an example of how to use #CONV.toAny in the 'Handling enums' section. Note that the #CONV.toAny() is quite brutal in how it works - it effectively disables all the type checking during the scenario authoring. Unless you monitor logs and scenario metrics, you may not notice runtime validation errors concealed during scenario authoring by using #CONV.toAny().

Handling enums​

Both AVRO and JSON schema specification allow you to define an enumeration value, defined as a constant list of values (strings) this enum can hold. For example, let’s consider this AVRO DeliveryStatus type:

{
"type": "enum",
"name": "status",
"symbols" : ["ORDERED", "READY", "DELIVERED"]
}

Suppose we want to define some kind of logic dependent on delivery status. If we use field of this type in a filter/choice node, we will discover that it is of type EnumSymbol (a generic Java type representing the enum) and cannot be compared with plain String - even if it contains one of the allowed values.

What is the EnumSymbol? It is a generic Java type representing the enum. Currently, the workaround is to convert it to String before the comparison is done, as shown in the screenshot below.

Now, imagine that we got one of the above enum values in a variable of type string. If you try to make the comparison, the Nussknacker typing system will complain:

The workaround here is to use the #CONV.toAny() function to trick the typing subsystem into thinking that it is a valid enum symbol:

Safe list navigation​

Often, we are interested in the first element of the list. If the list is empty and we try to access the first element, we will get a runtime error. The obvious way out is to use the Elvis operator:

myList.size == 0 ? null : myList[0]

There is a shorter way to achieve exactly the same:

myList.^[true]

How does it work? The .^ returns a first element of a filtered list. In our case the filtering predicate is simply true, so the filter will return the original list. The .^ will safely return the first element of the list - if it exists, or null if the list is empty. If you want the last element of the list - and you are not certain whether list is empty or not - just use:

myList.$[true]

Non-trivial operations on JSON records and lists​

The ! operator is truly powerful; one can achieve a lot of magic with it. A few examples follow.  

Convert record to a list​

Suppose one wants to convert a JSON record to a list containing records with fieldName and fieldValue fields.

The example input record (referred to as #myInputRecord later) look like this:

{"fieldA": "124.60", "fieldB": "123"}

and the expected result list is below.

[
{
"fieldName": "fieldA",
"numValue": 124.6
},
{
"fieldName": "fieldB",
"numValue": 123
}
]

The expression to use is as follows:

#myRecord.![{"fieldName": #this.key, "numValue": #this.value.toDouble}]  

Convert list to a map (record)​

 The toMap() method converts a list which contains {key: "key-name", value: some-value} elements to a map.

Example - the following expression:   {{key: "k1", value: 11}, {key: "k2", value: true}, {key: "k3", value: "myString"}}.toMap()

will be evaluated to:

{
"k1": 11,
"k2": true,
"k3": "myString"
},

Use can use dynamic navigation to access fields of a map or a record.  

Not trivial list transformations​

In this example, a {1,2,3,4,5} list is transformed to a list of records in two steps.

Expression:

{1,2,3,4,5}.![{#this, #this mod 2}].![{"value" : #this[0], "odd?" : #this[1] == 1 ? true : false}]

Result (in JSON notation) of the first step:

[
{1,1},
{2,0},
{3,1},
{4,0},
{5,1}
]

Final result (JSON notation):

[
{"value": 1, "odd?": true },
{"value": 2, "odd?": false },
{"value": 3, "odd?": true },
{"value": 4, "odd?": false },
{"value": 5, "odd?": true }
]

Arithmetic with zero and numeric types​

SpEL arithmetic depends on the numeric types used in an expression. As a result, the same operation may either fail or produce a value, depending on the data.

Division by zero​

Example  ResultReasoning
1 / 0Runtime errorBoth operands are treated as integers, so SpEL performs integer division. Integer division has no concept of Infinity/NaN, so dividing by zero fails.
1.0 / 0InfinityThe presence of 1.0 makes the operation floating-point. With floating-point arithmetic, dividing a non-zero number by zero means “the result grows without bound”, so the engine returns Infinity (or -Infinity for a negative numerator). This is a valid result.
0.0 / 0NaNThis is the “undefined” case. The question becomes “what number multiplied by 0 gives 0?” — many answers fit, so there is no single meaningful result. Floating-point arithmetic signals this with NaN.

The Infinity and NaN special values propagate further and do not stop scenario execution. The bottom line: when division is part of decision logic, handle the zero case in the scenario flow. A common approach is to add a Filter node before the calculation and decide what should happen when the divisor is zero.

General rules​

  1. Decide which arithmetic mode you are in:

    • Integer mode if both operands are integer types (e.g. Integer, Long).
    • Floating-point mode if any operand is floating-point (e.g. Double, Float) — including a divisor like 0.0. Some numeric types (notably BigDecimal) may also throw an error on division by zero.
  2. If the divisor is not zero

    • Integer mode: result is truncated toward zero.
    • Floating-point mode: normal fractional result.
  3. If the divisor is zero - see previous section.

Precise calculations with BigDecimal​

Use BigDecimal for money, interest rates, or anything where rounding errors are unacceptable. Doubles are faster but accumulate error; for currency-grade calculations always promote to BigDecimal early and keep all intermediate values as BigDecimal.

Constructing a value

Classes from java.math can be named without the package, as those from java.lang: BigDecimal, RoundingMode.

new BigDecimal('7.9')          // from string - exact
new BigDecimal(150000) // from int - exact
T(BigDecimal).valueOf(1200L) // valueOf(long) - exact
T(BigDecimal).valueOf(7.9d) // valueOf(double) - uses double's representation, also exact for short values

Prefer the string constructor for decimal literals - new BigDecimal(0.1) carries Double's binary representation (0.10000000000000000555…), new BigDecimal('0.1') is exactly 0.1. valueOf(int) is not callable from SpEL because Java only declares valueOf(long) and valueOf(double) - add the L or d suffix.

Why divide and pow need extra care

BigDecimal.divide(divisor) without an explicit scale throws ArithmeticException: Non-terminating decimal expansion whenever the result has no finite decimal representation (e.g. 1 / 3). Always pass (scale, roundingMode):

new BigDecimal('7.9')
.divide(new BigDecimal(1200), 10, T(RoundingMode).HALF_UP)
// → 0.0065833333 (scale=10, rounding=HALF_UP)

BigDecimal.pow(int n) only accepts non-negative n up to 999999999. For a negative exponent rewrite x^(-n) as 1 / x^n:

new BigDecimal(1)
.divide(new BigDecimal('1.00658').pow(60), 10, T(RoundingMode).HALF_UP)

pow does not bound precision - repeated multiplication grows the internal scale and you end up carrying hundreds of digits. Follow each pow with a setScale(...) to cap precision:

new BigDecimal('1.00658').pow(60).setScale(10, T(RoundingMode).HALF_UP)

Rounding mode

Pass a RoundingMode constant wherever a method takes the rounding mode - divide, setScale:

new BigDecimal('2.675').setScale(2, T(RoundingMode).HALF_UP)        // → 2.68
new BigDecimal('7.9').divide(new BigDecimal(1200), 10, T(RoundingMode).HALF_UP)
ConstantBehaviour
T(RoundingMode).UPaway from zero
T(RoundingMode).DOWNtoward zero
T(RoundingMode).CEILINGtoward +∞
T(RoundingMode).FLOORtoward −∞
T(RoundingMode).HALF_UP"round half up" - usual financial default
T(RoundingMode).HALF_DOWNhalf toward zero
T(RoundingMode).HALF_EVENbanker's rounding

Older scenarios use the deprecated integer constants on BigDecimal, e.g. T(java.math.BigDecimal).ROUND_HALF_UP; they still work, with the same meaning. MathContext is not available in expressions - give the scale and the rounding mode to divide and setScale instead.

Worked example - annuity payment

monthly_payment = P * r / (1 - (1 + r)^(-n))
where r = nominal_rate / 100 / 12

For P = 150000, nominal_rate = 7.9, n = 60 (and using #HU as a shorthand for T(RoundingMode).HALF_UP - in practice you'd bind it to a Variable node):

new BigDecimal(150000)
.multiply(
new BigDecimal('7.9').divide(new BigDecimal(1200), 10, #HU)
)
.divide(
new BigDecimal(1).subtract(
new BigDecimal(1).divide(
(new BigDecimal(1).add(
new BigDecimal('7.9').divide(new BigDecimal(1200), 10, #HU)
)).pow(60),
10, #HU
)
),
2, #HU
)
// → 3034.29

To keep the expression readable, split it across several Variable nodes (one each for r, pow_part, factor, monthly) instead of one giant ternary - node-by-node debugging in the test runner becomes possible.

Bottom line

  • Use new BigDecimal('...') to construct, string form for decimal literals.
  • Always supply (scale, roundingMode) on every divide. T(RoundingMode).HALF_UP is the safe default.
  • Wrap each pow in setScale(...) to stop the internal scale from exploding.
  • Round with T(RoundingMode) constants; MathContext is not available.

Handling date and time​

The date and time related data types are explained in SpEL Cheat Sheet. The date and time related functions and methods are documented here.

How to check whether an event happened today (server timezone)?​

#eventInstant
.atZone(#DATE.defaultTimeZone)
.toLocalDate ==
#DATE.nowAtDefaultTimeZone.toLocalDate

How to check whether an event happened today in a specific timezone?​

#eventInstant
.atZone(#DATE.zone('Europe/Warsaw'))
.toLocalDate ==
#DATE.nowAtZone(#DATE.zone('Europe/Warsaw')).toLocalDate

How to compare a date/time value with a literal?​

#localDateTime.isAfter('2024-03-01T09:00:00') #localDate.isBefore('2024-03-01') #localTime.isAfter('09:00')

How to attach a timezone to an Instant?​

#instantVar.atZone(#DATE.zone('Europe/Warsaw'))