Every function body is given a set of ready-made objects. You do not import or declare them — they are injected before the code runs, and the set is the same in Groovy, Python and JavaScript functions.
| Object | Purpose |
|---|---|
ctx |
the context — input data, addressed by dotted paths |
higson |
decision tables and other functions |
log |
logger, scoped to this function |
str |
string helpers |
util |
comparisons, collections, hashes, null checks |
math |
mathematical helpers |
date |
date arithmetic, parsing and formatting |
type |
conversions between Higson types and Java types |
domain |
domain elements and their attributes |
higson is covered in Working with Decision Table Results.
This page covers the rest.
util is not the same as Util Functions. The util object described here is a Java
helper available inside a function body. Util Functions
are configuration-level functions called with $u notation from Domain Configuration and
Decision Tables. The names overlap; the two have nothing to do with each other.
Logging - log#
The logger is created per function, so its name identifies which function produced a line, and its level can be controlled per function at runtime.
log.info("Premium {} for driver aged {}", premium, age)
log.debug("Factor: {}", factor)
log.warn("No tariff row matched")
log.error("Cannot price: {}", reason)
log.trace("Intermediate: {}", value)
It follows slf4j conventions: {} placeholders substituted from the trailing arguments,
not string interpolation. Level guards are available for expensive messages:
if (log.isDebug()) {
log.debug("Full matrix: {}", matrix)
}
Methods: trace · debug · info · warn · error, each with the matching
isTrace() / isDebug() / isInfo() / isWarn() / isError() guard, plus getName().
Strings — str#
str extends Apache Commons Lang3 StringUtils, so the whole of that class is reachable
through it as well — str.isNotBlank, str.substringBefore, str.substringBetween,
str.leftPad, str.equalsIgnoreCase and the rest. The methods below are the Higson
additions on top of it.
Most are null-tolerant, but not all: str.trunc, both str.split overloads and
str.contains throw a NullPointerException on a null argument. str.trunc also throws
when maxlen is below 3 and the input is longer than maxlen.
Padding and trimming. padLeft and padRight are named after the alignment of the
original text, not the side the padding lands on — padLeft appends, padRight prepends.
For a zero-padded number you want padRight.
str.padLeft('7', '0' as char, 3) // '700' — left-ALIGNED, padding added at the END
str.padRight('AB', 5) // ' AB' — right-ALIGNED, padding added at the FRONT
str.trunc(description, 40) // cut to at most 40 characters
str.trim(value) // null-safe trim
str.compactSpaces(text) // collapse runs of whitespace
Joining and splitting
str.concat(['A', 'B', 'C'], ', ') // 'A, B, C'
str.concat(arr, ', ', '-') // '-' substituted for null elements
str.split('a;b;c', ';' as char) // ['a', 'b', 'c']
str.split(line, ';' as char, 3) // at most 3 parts
Filtering characters
str.filterDigits(pesel) // keep digits only
str.filterChars(code, 'A', 'Z') // keep characters in a range
str.filterChars(code, 'ABC123') // keep characters from a set
str.rejectChars(iban, ' -') // drop the listed characters
Null handling and formatting
str.nvl(value) // '' when null
str.notNull(value) // '' when null — same behaviour
str.format('{}: {}', label, amount) // slf4j-style {} anchors, NOT %s
str.capitalizeFirst(name)
str.capitalizeAll(name)
Inspection
str.len(value) // length, null-safe
str.count(text, 'x' as char)
str.contains(list, 'FULL') // works on a Collection or a String[]
str.getCommonStart(a, b) // longest shared prefix
str.repeat('-' as char, 20)
str.print(rows, 'Matched rows') // multi-line dump, handy in log messages
str.print(rows, 'Matched rows', 20) // capped at 20 lines — prefer this for a big matrix
Comparisons, Collections, Hashes — util#
Comparisons that survive mixed types and nulls. util.eq is not ==. It compares
numerically whenever both sides parse as numbers — including two strings, so
util.eq('10', '10.0') is true and util.gt('9', '10') is false. Only when the parse
fails does it fall back to lexical order. Two dates compare chronologically. null sorts
below any value, and two nulls are equal.
util.eq(a, b) util.gt(a, b) util.ge(a, b)
util.lt(a, b) util.le(a, b)
util.in(code, ['FULL', 'MINI'])
Emptiness
util.isEmpty(value) util.notEmpty(value)
util.isBlank(value) util.notBlank(value)
util.nvl(value, fallback)
Collections and arrays — useful when a function has to build a structure to return:
util.list('A', 'B')
util.stringList('A', 'B')
util.integerList(1, 2)
util.map('k1', 'v1', 'k2', 'v2')
util.linkedHashMap('k1', 'v1') // also: hashMap, treeMap
util.set('A', 'B') // also: hashSet, treeSet
util.arrayList('A', 'B') // explicitly an ArrayList, when the result gets mutated
util.stringArray('A', 'B')
util.stringArray(5) // sized: empty String[5]; same for integerArray, longArray
util.integerArray(1, 2)
util.matrix(3, 4) // empty 3x4 Object[][]
Sorting
util.sort(list) // natural order
util.sort(list, comparator)
util.sortReversed(list)
Conversions
util.getInt(o) util.getInteger(o)
util.getString(o) util.getBigDecimal(o)
util.toUpperCase(o) util.toLowerCase(o) util.length(o)
util.trim(o) // null-safe toString().trim() on ANY object, not only a String
Hashes — for building stable keys or anonymising identifiers:
util.sha256(pesel)
util.sha256('|', [firstName, lastName, birthDate])
util.sha1(pesel)
util.md5(pesel)
util.hash('SHA-512', '|', [firstName, lastName])
Geo
util.calculateHaversineDistance(lat1, lon1, lat2, lon2) // METERS
util.calculateHaversineDistance(lat1, lon1, elev1, lat2, lon2, elev2) // with elevation
Numbers — math#
math mirrors java.lang.Math and adds scaled rounding:
math.round(value, 2) // double, 2 decimal places — see the note below
math.trunc(value, 2) // double, truncated
math.round(value) // long
math.abs(v) math.min(a, b) math.max(a, b) math.signum(v)
math.pow(a, b) math.sqrt(v) math.cbrt(v) math.exp(v)
math.log(v) math.log10(v) math.log(base, v)
math.ceil(v) math.floor(v)
math.sin(v) math.cos(v) math.tan(v) math.asin(v) math.acos(v) math.atan(v)
math.sinh(v) math.cosh(v)
math.toRadians(deg) math.toDegrees(rad) math.random()
math.PI math.E
math.round(v, scale) is not BigDecimal.HALF_UP: it always rounds a halfway value
toward positive infinity and adds a small epsilon before rounding, so -1.005 and 1.005
move the same direction. That plus the double return makes it the wrong tool for money —
prefer _dec(value, 2) or read the figure as BigDecimal in the first place — see Best Practices.
Dates — date#
Parsing. date.parse recognises yyyy-MM-dd and dd-MM-yyyy with -, . or /
separators, so most input formats need no pattern:
date.parse('2026-03-15')
date.parse('15.03.2026')
date.parse(value, 'yyyyMMdd') // explicit pattern
date.parseNoEx(value) // null instead of an exception on bad input
date.parseNoEx(value, 'yyyyMMdd') // same, with an explicit pattern
date.parseYMD(s) date.parseDMY(s)
date.parseLong(s) // the LONG FORMAT 'yyyy-MM-dd HH:mm:ss', not epoch millis
Formatting. date.format(d) produces yyyy-MM-dd:
date.format(d) // '2026-03-15'
date.formatDMY(d) // '15-03-2026'
date.format(d, 'MM/yyyy')
date.formatTimestamp(d) // also accepts epoch millis: formatTimestamp(1773500000000)
Building a date from components
date.getDate(2026, 3, 15) // year, month (1-based), day
date.getDate(2026, 3, 15, 14, 30) // + hour, minute
date.getDate(2026, 3, 15, 14, 30, 0) // + second
date.copy(d) // defensive copy — java.util.Date is mutable
Current moment
date.currentDate() // today, time part cleared
date.currentDatetime() // now, including the time
date.current() still exists but is deprecated — use one of the two above.
Arithmetic. add* shifts, set* replaces the field, roll* changes the field
without carrying into the next one:
date.addYear(d, 1) date.addMonth(d, -3) date.addDay(d, 30)
date.addHour(d, 2) date.addMinute(d, 15) date.addSecond(d, 30)
date.setYear(d, 2027) date.setMonth(d, 6) date.setDay(d, 1)
date.setHour(d, 12) date.setMinute(d, 0) date.setSecond(d, 0)
date.rollYear(d, 1) date.rollMonth(d, 1) date.rollDay(d, 1)
date.rollHour(d, 1) date.rollMinute(d, 1) date.rollSecond(d, 1)
date.addMonthOracle(d, 1) // Oracle ADD_MONTHS semantics for end-of-month dates
Differences
date.getYearDiff(from, to) date.getFullYearDiff(from, to)
date.getMonthDiff(from, to) date.getDayDiff(from, to)
date.getDayDiff(from, to, 'yyyyMMdd') // both operands as strings in that pattern
date.getYearsBetween(a, b) date.getMonthsBetween(a, b) date.getDaysBetween(a, b)
date.getAbsoluteYearDiff(a, b)
Field access
date.getYear(d) date.getMonth(d) date.getDay(d)
date.getHour(d) date.getMinute(d) date.getSecond(d)
A field getter returns 0 when the date cannot be parsed. date.getMonth is
one-based, so a real month is 1–12 and 0 is not a valid answer — it is the signal
that the input was not understood. date.getMonth('29-10-2018') returns 0, because a
field getter accepts yyyy-MM-dd and yyyy-MM-dd HH:mm:ss only. Parse with
date.parse first when the format is anything else.
Period boundaries and business days
date.getFirstDayOfMonth(d) date.getLastDayOfMonth(d)
date.getFirstDayOfYear(d) date.getLastDayOfYear(d)
date.getFirstDayOfYear(2026) date.getFirstWorkingDayOfYear(2026)
date.isWorkDay(d) date.isLastDayOfMonth(d) date.isFirstWorkDayOfMonth(d)
date.getNextWorkDay(d) date.getNearestWorkDay(d)
date.getPolishHolidays(2026) date.getNearestWorkDayExcludingPolishHolidays(d)
Comparisons
date.isInPeriod(d, from, toExclusive)
date.isInPeriodInclusive(d, from, toInclusive)
date.compareYMD(a, b) // ignores the time part
date.max(a, b) date.min(a, b) // three-argument forms exist too
date.trim(d) // drop the time part
date.isLeapYear(2026) date.getDaysInYear(2026)
date.toInt(d) date.fromInt(20260315) // yyyyMMdd as an int
date.julianDay(d) date.julianDayDiff(a, b)
Conversions — type#
type converts any value to a Higson type, regardless of what it started as — a cell
from a decision table, a context value, or a plain Java object.
type.getString(o) type.getBoolean(o)
type.getInteger(o) type.getDecimal(o) type.getDecimal(o, 2)
type.getNumber(o) type.getNumber(o, 2)
type.getDate(o) type.getDatetime(o)
type.getLocalDate(o) type.getLocalDateTime(o)
type.getNumber(null) returns 0.0 and type.getBoolean(null) returns false — the
same substitution the primitive getters make elsewhere. Use type.getDecimal when a
missing value must stay missing.
Value Holders#
A holder is a value together with its Higson type, before conversion. It is what the engine passes around internally, and it is the one place where “the value is missing” and “the value is zero” stay distinguishable.
type.toNumberHolder(o) type.toIntegerHolder(o)
type.toStringHolder(o) type.toBooleanHolder(o)
type.toDateHolder(o) type.toDatetimeHolder(o)
type.toLocalDateHolder(o) type.toLocalDateTimeHolder(o)
Every holder answers the same questions:
| Group | Methods | On a missing value |
|---|---|---|
| presence | isNull(), isNotNull(), isBlank(), isComparable() |
— |
| raw | getValue(), getString() |
null |
| object getters | getInteger(), getLong(), getDouble(), getBoolean(), getBigDecimal(), getDate(), getDatetime(), getLocalDate(), getLocalDateTime() |
null |
| primitive getters | intValue(), longValue(), doubleValue(), booleanValue() |
0 / false |
That last pair of rows is the whole point:
def holder = type.toIntegerHolder(cell)
holder.intValue() // 0 — cannot tell "absent" from "zero"
holder.getInteger() // null — absent stays absent
holder.isNull() // true
Holders appear at three entry points, all with the same surface: type.to*Holder(value)
here, ctx.get*Holder(path) in
Reading and Writing the Context, and
matrix.getHolder(...) in
Working with Decision Table Results.
Building a result matrix. A function can return a matrix of its own, which the caller then reads exactly like a decision table result:
return type.paramValue()
.withCodes('code', 'premium')
.withTypes('string', 'number') // required, one per column
.addValues('BI', 1200,
'AC', 800)
.create()
withCodes names the columns. addValues appends cells; withValues replaces
everything accumulated so far — reach for addValues when building in a loop, or the
earlier rows vanish. Either way the cell count must be a whole multiple of the column
count, otherwise the build fails with an incomplete-matrix error. withMatrix(grid)
supplies the whole Object[][] in one call, which pairs with util.matrix(rows, cols).
withTypes(...) is required, not optional: it pins each column’s Higson type and must
have exactly as many entries as withCodes. Without it, addValues throws. Close with
create() for a matrix in the form functions read, or build() for the engine-level value.
For a single row there is type.multiValue(), with the same shape:
def row = type.multiValue()
.withCodes('code', 'premium')
.withTypes('string', 'number')
.withValues('BI', 1200)
.build()
Lower-level equivalents: type.toMultiValue(row, codes), type.toParamValue(rows), and
type.wrap(paramValue) to put an engine-level value back into the form functions work
with.
Domain Elements — domain#
domain reaches into Domain Configuration: the tree of elements (products, plans,
coverages) and their attributes.
def root = domain.get('MOTOR', ctx) // profile root; drop ctx for static attributes only
def element = domain.get('MOTOR', '/PLANS[FULL]/COVERAGES[BI]/', ctx)
def profiles = domain.getProfiles()
Navigating from an element
element.get('/COVERAGES[BI]/') // throws when the path does not exist
element.getSafe('/COVERAGES[BI]/') // null instead of throwing
element.get('COVERAGES', 'BI') // by child type and code
element.getSafe('COVERAGES', 'BI')
element.getAll('COVERAGES') // every child of that type
element.parent()
Reading attributes
element.getAttrString('name')
element.getAttrDecimal('basePremium', ctx)
element.getAttrNumber('factor')
element.getAttrInteger('maxAge')
element.getAttrDate('validFrom')
element.getAttrBoolean('active')
element.getAttrValue('tariff', ctx) // a matrix, when the attribute is backed by a table
Pass ctx whenever the attribute is dynamic — that is, sourced from a decision table, a
function or a flow. Without a context those attributes cannot be resolved.
Attribute handles. getAttrX resolves the attribute afresh on every call. When the
same attribute is read more than once, or read in two different types, take a handle:
def premium = element.attr('basePremium') // null when the attribute is undefined
premium.getDecimal(ctx)
premium.getString(ctx)
premium.getValue(ctx) // a matrix, when backed by a table
element.staticAttr('code') // resolve against the static set only
element.dynamicAttr('premium') // resolve against the dynamic set only
attr checks the static set first and falls back to the dynamic one, so it is the right
default; the explicit forms matter when a name exists in both.
Checking before reading
element.hasAttr('discount') element.isAttrDefined('discount')
element.hasStaticAttr('discount') element.isAttrSet('discount')
element.hasDynamicAttr('discount')
Identity
element.code() element.name() element.getPath()
element.getTypeCode() element.getTypeName() element.isRoot()
domain is registered only when the domain cache is enabled. On a runtime configured
without it the object is absent and any reference fails. If a function needs domain data,
confirm the runtime is configured for it.
A misspelled attribute code fails silently. Reading an attribute that does not exist
returns null / 0.0 / false rather than raising — the element substitutes an empty
attribute. Guard with hasAttr(code) when the code comes from data rather than from a
literal.
Traps#
- A
datefield getter answers0for an unparseable input.getMonthis one-based (1–12), so0means “could not read the date”, not January. Field getters acceptyyyy-MM-ddandyyyy-MM-dd HH:mm:ssonly. domain.get(path)throws when the path is missing;getSafe(path)returnsnull. Pick deliberately — the throwing form is right when a missing element is a configuration error, the safe form when it is an expected case.- Dynamic domain attributes need
ctx. Reading them without a context cannot resolve the table, function or flow behind the attribute. util.eqis not==. It coerces types and treatsnullas a value that is below everything else. That is usually what you want when comparing table cells, and rarely what you want when comparing object identity.math.roundandtype.getNumberreturn doubles. For money, stay inBigDecimalvia_dec(x, n)orgetDecimal.type.getNumber(null)is0.0,type.getBoolean(null)isfalse. No error is raised, so a missing value quietly becomes a real one.- A misspelled domain attribute code returns
null/0.0/false, not an error. str.formatuses slf4j{}anchors, not%s. A%-pattern comes back unchanged.str.trunc,str.splitandstr.containsare not null-safe, unlike the rest ofstr.padLeftappends andpadRightprepends — they name the alignment, not the pad side.util.eqcompares two numeric-looking strings numerically, so'10'equals'10.0'.calculateHaversineDistancereturns meters.math.round(v, scale)is notHALF_UPand returns adouble— not for money.date.parseLongparsesyyyy-MM-dd HH:mm:ss, it does not read epoch millis.type.paramValue()requireswithTypesas well aswithCodes.utilhere is not the$uUtil Functions of Domain Configuration.- Some
datemethods are deprecated:current(),getHourDiff,getMinuteDiff,getTime(Calendar),subtractYear, and anything named*Deprecated.
Example#
// renewalPremium — recalculates a premium for a renewal date
def policyEnd = date.parse(ctx.getString('policy.endDate'))
def renewalOn = date.addDay(policyEnd, 1)
if (!date.isWorkDay(renewalOn)) {
renewalOn = date.getNearestWorkDayExcludingPolishHolidays(renewalOn)
}
def age = date.getYearDiff(ctx.getDate('policy.holder.birthDate'), renewalOn)
def coverage = domain.get('MOTOR', '/PLANS[FULL]/COVERAGES[BI]/', ctx)
def base = coverage.getAttrDecimal('basePremium', ctx)
if (base == null) {
log.warn("No basePremium on {} — cannot renew", coverage.getPath())
return null
}
def loading = util.gt(age, 70) ? _dec('1.25') : _dec('1.00')
def premium = base * loading
log.info("Renewal {} on {} (age {}, loading {})",
premium, date.format(renewalOn), age, loading)
return util.map(
'renewalDate', date.format(renewalOn),
'premium', _dec(premium, 2)
)