Working with Decision Table Results

A decision table lookup rarely returns a single number. When a table has several OUT columns, or when the context matches more than one row, higson.getValue(...) returns a matrix — a sub-matrix of the table containing every row that matched the context.

This page is a complete reference for reading that matrix inside a function body. For the basics of writing functions — available objects, type converters, calling other functions — see Best Practices for Writing Function Code.

Objects#

Everything you do with a decision table result involves three objects. Knowing which one you are holding tells you which methods are available:

Object You get it from Represents Key methods
higson always available in the function body access point to the engine getValue, getString/getNumber/…, call/callValue
matrix higson.getValue(table, ctx) all rows matching the context size, isEmpty, isBlank, row(i), rows(), get(...)
row matrix.row(i) or an element of matrix.rows() one row and its OUT columns size, get(...), typed getters, array/list getters, getEnum

The distinction matters in practice: array and list getters, getEnum and getDatetime exist only on a row, not on the matrix. To read a multi-value column you must go through row(i) first.

Retrieving the Matrix#

The context supplies the values for the table’s IN columns. There are four ways to build it, all returning the same matrix object:

// 1. the function's own context
higson.getValue('demo.motor.price', ctx)

// 2. the function's context with selected paths overridden by the passed tokens
higson.getValue('demo.motor.price', ctx, 'car.model.year', '2020')

// 3. a context built on the fly from key/value pairs, without ctx
higson.getValue('demo.motor.price', 'car.model.type', 'BMW', 'car.model.year', '2020')

// 4. a reusable context object
def standalone = higson.createContext('car.model.type', 'BMW', 'car.model.year', '2020')
def inherited  = higson.createContext(ctx, 'car.model.year', '2020')  // inherits ctx, overrides one path
def matrix     = higson.getValue('demo.motor.price', inherited)

Variants 2–4 are the ones to reach for when a single function queries the same table for several scenarios — for example pricing the same risk for three different coverage variants — instead of building a fresh context each time.

Guarding Against No Match#

When no row matches the context, getValue does not throw. It returns an empty matrix, and an empty matrix behaves differently from a populated one:

def matrix = higson.getValue('demo.motor.price', ctx)

if (matrix == null || matrix.isEmpty()) {
    return null    // or a business default
}

Why this check is not optional:

  • On an empty matrix, row() and row(i) return null instead of throwing. Without the guard, the failure surfaces later as a NullPointerException on the next get(...) — several lines away from the real cause.
  • On a populated matrix, an out-of-range index behaves the other way round: row(99) throws INVALID_ROW_INDEX and names the available range in the message.

Three methods describe the state of a matrix:

Method Returns
matrix.size() number of matched rows
matrix.isEmpty() true when no row matched
matrix.isBlank() true when rows exist but every cell in them is empty

isEmpty() and isBlank() are not opposites and not mutually exclusive: a row of nulls is blank but not empty, and an empty matrix is also blank (there is no non-blank row in it). Test isEmpty() first when you need to tell the two apart.

Matrix Dimensions#

size() means something different on each object:

int rowCount = matrix.size()          // number of rows
int colCount = matrix.row(0).size()   // number of OUT columns in that row

Reading a Single Cell#

Columns can be addressed by index (0-based, in OUT column order) or by name (the OUT column name from the table definition).

Methods without a row number always operate on row 0:

matrix.get()              // row 0, column 0
matrix.get(5)             // row 0, column 5
matrix.get('model')       // row 0, column named 'model'

Methods with a row number take it as the first argument:

matrix.get(0, 5)          // row 0, column 5
matrix.get(1, 5)          // row 1, column 5
matrix.get(1, 'model')    // row 1, column named 'model'

Both forms are valid, but get('model') and get(1, 'model') are different methods, and in a long function it is easy to misread which argument is the row and which is the column. Prefer the explicit form, which cannot be misread:

matrix.row(0).get('model')
matrix.row(1).get('model')

To have Higson cast the value for you, pass the target class:

matrix.get(0, 'model', String.class)
matrix.row(1).get('model', String.class)

Type Conversion#

get(...) returns the raw value. The typed getters convert it. They are available on both the matrix (acting on row 0) and on any row, each accepting a column index, a column name, or nothing at all (column 0):

Method Returns Note
getString(...) String
getNumber(...) double primitive — an empty cell silently becomes 0.0
getDecimal(...) BigDecimal preferred for monetary values
getBigDecimal(...) BigDecimal alias of getDecimal
getInteger(...) Integer
getLong(...) Long
getDate(...) java.util.Date
getBoolean(...) boolean primitive — an empty cell silently becomes false
def brand  = matrix.getString('brand')        // row 0
def price  = matrix.getDecimal('price')       // row 0
def power  = matrix.row(1).getInteger('power')
def valid  = matrix.row(1).getDate('validFrom')

For nullable columns choose getDecimal, getInteger or getLong — they return objects and preserve null.

getNumber and getBoolean return primitives and cannot represent a missing value, so they substitute a default instead: an empty cell reads as 0.0 and false respectively, with no error. A missing rate therefore prices as zero rather than failing, which is why a nullable column is safer read through getDecimal and checked explicitly.

Row-Only Getters#

def row = matrix.row(0)

row.getDatetime('createdAt')            // java.util.Date including time
row.getEnum('status', MyStatus.class)   // enum constant

Multi-Value Columns#

A column declared as an array holds several values in one cell. These getters exist only on a row:

Array form List form Element type
getStringArray(...) getStringList(...) String
getIntegerArray(...) getIntegerList(...) Integer
getLongArray(...) Long
getNumberArray(...) getNumberList(...) double / Double
getDecimalArray(...) / getBigDecimalArray(...) getDecimalList(...) BigDecimal
getDateArray(...) getDateList(...) java.util.Date
getArray(...) ValueHolder (unconverted)
def tags = matrix.row(0).getStringList('tags')
if ('PREMIUM' in tags) {
    premium = premium * 1.1
}

Value Holders#

A holder is the cell before conversion — it carries the Higson type assigned to the column in the table definition, and unlike the typed getters it keeps “the cell was empty” and “the cell was zero” apart:

def holder = matrix.getHolder('model')
holder.getValue()                  // the underlying value
holder.getClass().simpleName       // StringHolder, IntegerHolder, DateHolder, ...
def holder = matrix.getHolder('premium')

if (holder.isNull()) {
    return null                     // the cell really was empty
}
holder.getBigDecimal()              // null-preserving
holder.doubleValue()                // 0.0 for an empty cell

getHolder accepts the same addressing forms as get: getHolder(), getHolder(col), getHolder('name'), getHolder(row, col), getHolder(row, 'name'). The full holder surface is described in Built-in Objects in a Function.

Iterating Over Rows#

// 1. index loop — toStringInline() prints a whole row on one line, useful when logging
for (int i = 0; i < matrix.size(); i++) {
    log.info("row {}: {}", i, matrix.row(i).toStringInline())
}

// 2. for-in, backed by the matrix iterator
for (def row in matrix) {
    def price = row.getDecimal('price')
}

// 3. rows() with the index
matrix.rows().eachWithIndex { row, i ->
    log.info("row {}: {}", i, row.getString('brand'))
}

// 4. columns of one row — reads a table whose column names you do not know yet
def row = matrix.row(0)
for (int c = 0; c < row.size(); c++) {
    log.info("column[{}] = {} ({})", c, row.get(c), row.getHolder(c)?.getClass()?.simpleName)
}

The last loop is the fastest way to inspect an unfamiliar table from the Tester: it prints every column with its index, value and declared type.

The Matrix as a Collection#

matrix.rows() returns a java.util.List, so the full Groovy collection API applies to a decision table result. This is usually shorter and clearer than an index loop:

def matrix = higson.getValue('demo.motor.price', ctx)

// one column across all rows
def allPrices = matrix.rows().collect { it.getDecimal('price') }

// filter rows on a business condition
def available = matrix.rows().findAll { it.getBoolean('isAvailable') }

// first row satisfying a condition, null when there is none
def cheapest = matrix.rows().find { it.getDecimal('price') < 50000 }

// aggregate — the elvis operator protects against a null cell
def total = matrix.rows().sum { it.getDecimal('price') ?: BigDecimal.ZERO }

// extreme row
def topRow = matrix.rows().max { it.getDecimal('price') }

// sort — ALWAYS pass false; see the warning below
def sorted = matrix.rows().sort(false) { it.getDecimal('price') }   // false = work on a copy

// group rows into buckets
def byBrand = matrix.rows().groupBy { it.getString('brand') }

// rebuild rows as maps — the usual return shape for a function exposed over Runtime REST
def result = matrix.rows().collect { row ->
    [ brand: row.getString('brand'), price: row.getDecimal('price') ]
}

rows() is a live view of the matrix, not a copy. Sorting it in place — rows().sort { } without the false argument — permanently reorders the matrix, so every later row(i) and get(rowNo, ...) sees the new order. Pass sort(false) { } unless you mean to reorder it.

Raw Access#

When a result has to be handed to Java code that expects the engine’s own types, unwrap it:

matrix.row(0).unwrap()   // Object[] — raw values of one row, no holders
matrix.row(0).raw()      // MultiValue — the engine's row object
matrix.unwrap()          // ParamValue — the engine's matrix object

Reading a Cell Without Choosing a Type#

higson.get(table, ctx) reads the first cell and hands back whatever the column holds — a numeric column arrives as double, an integer column as long, anything else as the raw object. Prefer the typed getters unless you genuinely do not know the column type.

Functions Returning a Matrix#

A function can return a matrix just like a table does. Retrieve it with callValue:

def matrix = higson.callValue('demo.motor.price.variants', ctx)
def variants = matrix.rows().collect { it.getString('code') }

A matrix from callValue guards differently from one from getValue. callValue always wraps the result in an ordinary matrix, never the empty one, so on a zero-row result row(0) throws INVALID_ROW_INDEX rather than returning null. Check size() or isEmpty() before addressing a row — the row() == null idiom does not apply here.

The other call* methods are listed in Calling Other Functions.

Calling a Flow#

A flow can be invoked from a function body too. It returns a map of the variables the flow marks for return:

def result = higson.flow('demo.pricing.flow', ctx)
def premium = result.premium

def withArgs = higson.flow('demo.pricing.flow', ctx, 'FULL', 3)

This is the function-body route. The $w notation described in Using Functions in Cascade Expressions is the configuration-level one — same flows, different call site.

Quick Reference#

def m = higson.getValue('table.code', ctx)     // matrix
if (m == null || m.isEmpty()) return null      // always guard

m.size()                                       // row count
m.row(0).size()                                // column count

m.get('col')                                   // row 0, by name
m.get(1, 'col')                                // row 1, by name
m.row(1).get('col')                            // same, preferred form
m.row(1).getDecimal('col')                     // converted

m.row(0).getStringList('tags')                 // multi-value column (row only)
m.rows().findAll { it.getBoolean('active') }   // matrix as a collection