Please wait. This can take some minutes ...
Many resources are needed to download a project. Please understand that we have to compensate our server costs. Thank you in advance.
Project price only 1 $
You can buy this project and download/modify it how often you want.
com.cedarsoftware.ncube.DecisionTable.groovy Maven / Gradle / Ivy
package com.cedarsoftware.ncube
import com.cedarsoftware.util.CaseInsensitiveMap
import com.cedarsoftware.util.CaseInsensitiveSet
import com.cedarsoftware.util.CompactCILinkedMap
import com.cedarsoftware.util.LongHashSet
import com.cedarsoftware.util.StringUtilities
import com.google.common.base.Splitter
import groovy.transform.CompileStatic
import it.unimi.dsi.fastutil.ints.IntIterator
import it.unimi.dsi.fastutil.ints.IntOpenHashSet
import it.unimi.dsi.fastutil.ints.IntSet
import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap
import static com.cedarsoftware.ncube.NCubeConstants.*
import static com.cedarsoftware.util.Converter.convertToBigDecimal
import static com.cedarsoftware.util.Converter.convertToBoolean
import static com.cedarsoftware.util.Converter.convertToDate
import static com.cedarsoftware.util.Converter.convertToDouble
import static com.cedarsoftware.util.Converter.convertToInteger
import static com.cedarsoftware.util.Converter.convertToLong
import static com.cedarsoftware.util.Converter.convertToString
import static com.cedarsoftware.util.StringUtilities.hasContent
import static java.lang.String.CASE_INSENSITIVE_ORDER
/**
* Decision Table implements a list of rules that filter a variable number of inputs (decision variables) against
* a list of constraints.
*
* @author John DeRegnaucourt ([email protected] ), Josh Snyder ([email protected] )
*
* Copyright (c) Cedar Software LLC
*
* Licensed under the Apache License, Version 2.0 (the "License")
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@CompileStatic
class DecisionTable
{
private NCube decisionCube
private String fieldAxisName = null
private String rowAxisName = null
private Set inputColumns = getLinkedHashSet() // All input columns (ranges map as one here - the 'named' input on the inbound map)
private Set inputKeys = getLinkedHashSet()
private Set outputColumns = getLinkedHashSet(4)
private Set rangeColumns = getLinkedHashSet(4)
private Set rangeKeys = getLinkedHashSet(4)
private Set requiredColumns = getLinkedHashSet()
private Map inputVarNameToRangeColumns = new CaseInsensitiveMap<>(Collections.emptyMap(), new HashMap<>(4))
private static final String BANG = '!'
private static final Splitter COMMA_SPLITTER = Splitter.on(',').trimResults().omitEmptyStrings()
private static List operators = [_OR_, _AND_, _NOR_, _NAND_, _NOT_]
protected DecisionTable(NCube decisionCube)
{
this.decisionCube = decisionCube
if (this.decisionCube.getBusinessEngine() instanceof DecisionTable)
{
DecisionTable other = (DecisionTable) decisionCube.getBusinessEngine()
fieldAxisName = other.fieldAxisName
rowAxisName = other.rowAxisName
inputColumns = other.inputColumns
inputKeys = other.inputKeys
outputColumns = other.outputColumns
rangeColumns = other.rangeColumns
rangeKeys = other.rangeKeys
requiredColumns = other.requiredColumns
inputVarNameToRangeColumns = other.inputVarNameToRangeColumns
}
else
{
verifyAndCache()
}
}
private static class RangeSpec
{
String inputVarName
String lowColumnName
String highColumnName
String dataType
}
protected static Set getLinkedHashSet(Set source)
{
return new CaseInsensitiveSet(source, new CaseInsensitiveMap(Collections.emptyMap(), new LinkedHashMap<>(source.size())))
}
protected static Set getLinkedHashSet(int initialSize = 16)
{
return new CaseInsensitiveSet(Collections.emptySet(), new CaseInsensitiveMap(Collections.emptyMap(), new LinkedHashMap<>(initialSize)))
}
/**
* @return Set All input keys that can be passed to the DecisionTable.getDecision() API. Extra keys
* can be passed, but will be ignored.
*/
Set getInputKeys()
{
Set orderedKeys = getLinkedHashSet(inputKeys)
return orderedKeys
}
/**
* @return Set All required keys (meta-property key REQUIRED value of true on a field column) that
* must be passed to the DecisionTable.getDecision() API.
*/
Set getRequiredKeys()
{
Set requiredKeys = getLinkedHashSet(requiredColumns)
return requiredKeys
}
/**
* @return Set All output keys that are returned when calling the DecisionTable.getDecision() API.
*/
Set getOutputKeys()
{
Set outputKeys = getLinkedHashSet(outputColumns)
return outputKeys
}
/**
* Main API for querying a Decision Table with multiple inputs, where the result will be OR'ed together.
* @param iterable Iterable containing one or more input Maps containing the key/value pairs for all the
* input_value columns. Each Map will perform a separate query and the results of each query will be merged into a
* single result.
* @param output {@link Map} optional output to allow DecisionTables to write to or pick up {@link RuleInfo}
* @return List>
*/
Map getDecision(Iterable> iterable, Map output = null)
{
Map results = new TreeMap<>()
if (iterable.size() == 0)
{
return getDecision([:])
}
else
{
for (Map item : iterable)
{
Map result = getDecision(item, output)
results.putAll(result)
}
}
return results
}
/**
* Main API for querying a Decision Table with a single query input.
* @param input Map containing key/value pairs for all the input_value columns
* @param output {@link Map} optional output to allow DecisionTables to write to or pick up {@link RuleInfo}
* @return List>
*/
Map getDecision(Map input, Map output = null)
{
Map> ranges = new CaseInsensitiveMap<>(Collections.emptyMap(), new HashMap<>())
Map copyInput = new CaseInsensitiveMap<>(input, new HashMap<>(input.size()))
copyInput.keySet().retainAll(inputKeys)
copyInput.put(IGNORE, null)
Axis fieldAxis = decisionCube.getAxis(fieldAxisName)
ensuredRequiredInputs(copyInput)
for (String colValue : inputColumns)
{
Column column = fieldAxis.findColumn(colValue)
Map colMetaProps = column.metaProperties
if (rangeColumns.contains(colValue))
{
[INPUT_LOW, INPUT_HIGH].each { String highLow ->
if (colMetaProps.containsKey(highLow))
{
// Allow ranges to be processed in ANY order (even intermixed with other ranges)
String inputVarName = colMetaProps.get(highLow)
Map spec = getRangeSpec(ranges, inputVarName)
spec.put(highLow, column.value)
if (!spec.containsKey(INPUT_VALUE))
{
// Last range post (high or low) processed, sets the input_value, data_type, and copies the range spec to the input map.
// ["date": date instance] becomes ("date": [low:1900, high:2100, input_value: date instance, data_type: DATE])
if (copyInput.get(inputVarName) instanceof Iterable)
{
// Multiple values supplied to range input (operator case)
Iterable i = (Iterable) copyInput.get(inputVarName)
Iterable inputs = new LinkedHashSet<>(i.size())
if (i.size() > 0)
{
String op = _OR_
if (i.size() > 1)
{
op = i.first()
if (!(op instanceof String) || !operators.contains(((String) op).toLowerCase()))
{
throw new IllegalArgumentException("When passing an array of items to check against a range, the first element must be one of the following operators: ${operators}. Found: ${op}")
}
}
op = op.toLowerCase()
inputs.add(op)
Iterator it = i.iterator()
it.next() // skip first/operator element
while (it.hasNext())
{
Comparable value = convertDataType((Comparable) it.next(), (String) colMetaProps.get(DATA_TYPE))
inputs.add(value)
}
}
spec.put(INPUT_VALUE, inputs)
}
else
{
// Single value supplied to match range (normal case)
Comparable value = convertDataType((Comparable) copyInput.get(inputVarName), (String) colMetaProps.get(DATA_TYPE))
spec.put(INPUT_VALUE, value)
}
spec.put(DATA_TYPE, colMetaProps.get(DATA_TYPE))
copyInput.put(inputVarName, spec)
}
}
}
}
else
{ // Convert inputs on the Map that match non-range inputs to String (or Iterable of Strings) so that they
// do not have to be converted on each mapReduce row.
Object val = copyInput.get(colValue)
if (val != null || copyInput.containsKey(colValue))
{ // Input Map contains a key for the input decision variable (column in table)
if (val instanceof Iterable)
{
Iterable i = (Iterable) val
Iterable inputs = new LinkedHashSet<>(i.size())
for (Object value : i)
{
inputs.add(convertToString(value))
}
copyInput.put(colValue, inputs)
}
else if (!(val instanceof String))
{ // Convert non-Strings to Strings
copyInput.put(colValue, convertToString(val))
}
}
}
}
// Add on the IGNORE column if the field axis had it.
Set colsToSearch = getLinkedHashSet(inputColumns)
if (fieldAxis.contains(IGNORE))
{
colsToSearch.add(IGNORE)
}
// Add on the PRIORITY column if the field axis had it.
Set colsToReturn = getLinkedHashSet(outputColumns)
if (fieldAxis.findColumn(PRIORITY))
{
colsToReturn.add(PRIORITY)
}
Map closureInput = new CaseInsensitiveMap<>(input, new HashMap<>(input.size()))
closureInput.dvs = copyInput
Map options = [
(NCube.MAP_REDUCE_COLUMNS_TO_SEARCH): colsToSearch,
(NCube.MAP_REDUCE_COLUMNS_TO_RETURN): colsToReturn,
input: closureInput
]
if (output != null)
{
RuleInfo ruleInfo = NCube.getRuleInfo(output)
ruleInfo.getInputKeysUsed().addAll(inputKeys)
}
Map result = decisionCube.mapReduce(fieldAxisName, decisionTableClosure, options)
result = determinePriority(result)
return result
}
/**
* Return a single value from a specified output column. Uses a DecisionTable that returns one row only.
* @param outputColumnName {@link String} output column name for the value to return.
* @param decisionInput {@link Map} containing key/value pairs for all the input_value columns.
* @param output {@link Map} optional output to allow DecisionTables to write to or pick up {@link RuleInfo}
* @param strict boolean set to false to return null back if no results are found.
* @return Object configured in the returned cell.
*/
Object val(String outputColumnName, Map decisionInput, Map output = null, boolean strict = true)
{
if (!outputKeys.contains(outputColumnName))
{
throw new IllegalArgumentException("Decision table: ${decisionCube.name} does not have output column: ${outputColumnName}.")
}
Map decision = getDecision(decisionInput, output)
if (decision.isEmpty())
{
if (strict)
{
throw new IllegalStateException("Decision table: ${decisionCube.name} returned no results for output column: ${outputColumnName} with these inputs: ${decisionInput}")
}
else
{
return null
}
}
if (decision.size() > 1)
{
throw new IllegalStateException("Decision table: ${decisionCube.name} returned more than 1 result (${decision.size()}) for output column: ${outputColumnName} with these inputs: ${decisionInput}")
}
Map row = (Map) decision.values().first()
return row[outputColumnName]
}
/**
* @return String name of Axis that is the 'fields' or 'attributes' of the Decision Table (main columns).
* An IllegalStateException will be thrown if this method is called on a non-Decision Table.
*/
String getDecisionAxisName()
{
return fieldAxisName
}
/**
* @return String name of Axis that is the 'rows' of the Decision Table.
* An IllegalStateException will be thrown if this method is called on a non-Decision Table.
*/
String getDecisionRowName()
{
return rowAxisName
}
/**
* Validate that the Decision Table has no overlapping rules. In other words, it must return 0 or 1
* records, never more.
* NOTE: Currently, only supports one range variable (low, high) columns.
* @return Set rowIds of the rows that have duplicate rules. If the returned Set is empty,
* then there are no overlapping rules in the DecisionTable.
*/
Set validateDecisionTable()
{
List rows = decisionCube.getAxis(rowAxisName).columnsWithoutDefault
NCube blowout = createValidationNCube(rows)
Set badRows = validateDecisionTableRows(blowout, rows)
return badRows
}
/**
* @return NCube that sits within the DecisionTable.
*/
NCube getUnderlyingNCube()
{
return decisionCube
}
/**
* Return the values defined in the decision table for every input_value column. The values represent the possible
* values of the input_value columns. The values do not account for cells that are left blank which would match on
* any input value passed.
* @return Map Map with keys representing the input_value columns and values representing
* all values defined in the cells associated to those columns
*/
Map> getDefinedValues()
{
Axis fieldAxis = decisionCube.getAxis(fieldAxisName)
Axis rowAxis = decisionCube.getAxis(rowAxisName)
List rows = rowAxis.columnsWithoutDefault
Map> definedValues = [:]
Map coord = [:]
Set discreteInputCols = inputColumns
for (String colValue : discreteInputCols)
{
Column field = fieldAxis.findColumn(colValue)
Map colMetaProps = field.metaProperties
String fieldValue = field.value
Set values = new TreeSet<>(CASE_INSENSITIVE_ORDER)
if (colMetaProps.containsKey(INPUT_LOW) || colMetaProps.containsKey(INPUT_HIGH))
{
String inputKey = colMetaProps[INPUT_LOW] ?: colMetaProps[INPUT_HIGH]
definedValues.put(inputKey, values)
continue
}
coord.put(fieldAxisName, fieldValue)
for (Column row : rows)
{
String rowValue = row.value
coord.put(rowAxisName, rowValue)
Set idCoord = new LongHashSet(field.id, row.id)
String cellValue = convertToString(decisionCube.getCellById(idCoord, coord, [:], null, true))
if (hasContent(cellValue))
{
cellValue -= BANG
Iterable cellValues = COMMA_SPLITTER.split(cellValue)
values.addAll(cellValues)
}
}
definedValues.put(colValue, values)
}
return definedValues
}
/**
* Closure used with mapReduce() on special 2D decision n-cubes.
*/
private static Closure getDecisionTableClosure()
{
return { Map rowValues, Map input ->
Map inputMap = (Map) input.get('dvs')
for (Map.Entry entry : inputMap)
{
String decVarName = entry.key
Object decVarValue = rowValues.get(decVarName)
Object inputValue = entry.value
// 1. Check special IGNORE row variable
if (IGNORE == decVarName)
{
if (decVarValue)
{
return false // skip row
}
continue // check next decision variable
}
// 2. Check range variables
if (inputValue instanceof Map)
{
Comparable low = (Comparable)rowValues.get((String) inputValue.get(INPUT_LOW))
Comparable high = (Comparable)rowValues.get((String) inputValue.get(INPUT_HIGH))
Object val = inputValue.get(INPUT_VALUE)
if (val instanceof Iterable)
{ // User supplied multiple values that must be between range values (1, all, none, etc.)
Iterable inputs = (Iterable) val
if (!isWithin(inputs, low, high))
{
return false
}
}
else
{ // Single value supplied - value must be between range values
Comparable value = (Comparable) val
if (!isWithin(value, low, high))
{
return false
}
}
continue // check next decision variable
}
// 3. Check discrete decision variables
String cellValue = convertToString(decVarValue)
if (StringUtilities.isEmpty(cellValue))
{ // Empty cells in input columns are treated as "*" (match any).
continue // check next decision variable
}
if (BANG == cellValue)
{
return false
}
boolean exclude = cellValue.startsWith(BANG)
cellValue -= BANG
Iterable cellValues = COMMA_SPLITTER.split(cellValue)
if (inputValue instanceof Iterable)
{
Iterable inputs = (Iterable) inputValue
if (inputs.size() == 0)
{ // Empty [] - treat as nothing assigned to input. Early check for 'required' already happened.
continue
}
String elem1 = inputs.first().toLowerCase()
if (inputs.size() == 1)
{ // Special handle one element array as "equals" or "contains"
if (okToContinue(exclude, cellValues, elem1))
{
continue
}
}
if (!executeOperator(inputs, exclude, cellValues))
{
return false
}
}
else
{ // Check single value on LHS against possible multiple in cell (or !cell)
if (!okToContinue(exclude, cellValues, (String)inputValue))
{
return false // row does not match
}
}
// continue (check next decision variable)
}
return true // row match!
}
}
private static boolean okToContinue(boolean exclude, Iterable cellValues, String inputElement)
{
if (exclude)
{
if (cellValues.contains(inputElement))
{
return false
}
}
else
{
if (!cellValues.contains(inputElement))
{
return false
}
}
return true
}
private static boolean executeOperator(Iterable inputs, boolean exclude, Iterable cellValues)
{
if (inputs.size() < 2)
{
throw new IllegalArgumentException("Array of elements must contain at least 2, found: ${inputs}")
}
Iterator i = inputs.iterator()
String operator = i.next().toLowerCase()
validateOperator(operator)
if (_AND_ == operator)
{ // _and_ operator
return and(i, exclude, cellValues)
}
else if (_OR_ == operator)
{ // _or_ operator
return or(i, exclude, cellValues)
}
else if (_NOT_ == operator)
{
if (inputs.size() != 2)
{
throw new IllegalArgumentException("_not_ operator, expected 2 arguments, found: ${inputs}")
}
return not(i, exclude, cellValues)
}
else if (_NAND_ == operator)
{
return !and(i, exclude, cellValues)
}
else if (_NOR_ == operator)
{
return !or(i, exclude, cellValues)
}
return true
}
private static boolean and(Iterator i, boolean exclude, Iterable cellValues)
{
while (i.hasNext())
{
String elem = i.next()
if (!okToContinue(exclude, cellValues, elem))
{
return false // row does not match
}
}
return true // all passed
}
private static boolean or(Iterator i, boolean exclude, Iterable cellValues)
{
boolean matched = false
while (i.hasNext())
{
String elem = i.next()
if (okToContinue(exclude, cellValues, elem))
{
matched = true
break
}
}
return matched // at least one matched
}
private static boolean not(Iterator i, boolean exclude, Iterable cellValues)
{
String elem = i.next()
return !okToContinue(exclude, cellValues, elem)
}
private static boolean isWithin(Comparable value, Comparable low, Comparable high)
{
return value != null && value >= low && value < high
}
private static boolean isWithin(Iterable inputs, Comparable low, Comparable high)
{
if (inputs.size() == 0)
{ // REQUIRED has already been checked.
return true
}
Comparable item1 = inputs.first()
if (inputs.size() == 1)
{
if (item1 instanceof String)
{
String possibleOperator = ((String)item1).toLowerCase()
if (operators.contains(possibleOperator))
{
throw new IllegalArgumentException("Operator ${possibleOperator} found, must have at least 1 argument.")
}
}
return isWithin(item1, low, high)
}
if (!(item1 instanceof String))
{
throw new IllegalArgumentException("First element of collection must be one of the following operators: ${operators}. Found ${item1}")
}
String operator = ((String) item1).toLowerCase()
validateOperator(operator)
Iterator i = inputs.iterator()
i.next() // skip operator
if (_AND_ == operator)
{ // _and_ operator
return allWithin(i, low, high)
}
else if (_OR_ == operator)
{ // _or_ operator
return oneWithin(i, low, high)
}
else if (_NOT_ == operator)
{
if (inputs.size() != 2)
{
throw new IllegalArgumentException("_not_ operator, expected 2 arguments, found: ${inputs}")
}
return !isWithin(i.next(), low, high)
}
else if (_NAND_ == operator)
{
return !allWithin(i, low, high)
}
else if (_NOR_ == operator)
{
return !oneWithin(i, low, high)
}
return true
}
/**
* Implement the 'AND' operator for ranges (relational comparison). All values referenced by the passed
* in interator must be within (low, high). This means value >= low AND value < high.
*/
private static allWithin(Iterator values, Comparable low, Comparable high)
{
while (values.hasNext())
{
Comparable testValue = values.next()
if (!isWithin(testValue, low, high))
{ // Fail fast
return false
}
}
return true
}
/**
* Implement the 'OR' operator for ranges (relational comparison). All values referenced by the passed
* in interator must be within (low, high). This means value >= low AND value < high.
*/
private static oneWithin(Iterator values, Comparable low, Comparable high)
{
while (values.hasNext())
{
Comparable testValue = values.next()
if (isWithin(testValue, low, high))
{ // Fail fast
return true
}
}
return false
}
private static void validateOperator(String op)
{
if (op == null)
{
throw new IllegalArgumentException("Operator cannot be null. The 1st element of an array associated to an input must be one of the following: ${operators}")
}
if (!operators.contains(op))
{
throw new IllegalArgumentException("For array input variables, the operator must be the first element and must be one of the following: ${operators}. Found operator: ${op}")
}
}
/**
* Perform 'airport pat-down' of 2D NCube being used as a decision table. This code will ensure that the
* passed in NCube is 2D. It will ensure that this operation is only performed once. This method will ensure
* that both Axis are DISCRETE and that the field 'top axis' is of type CISTRING. This method will ensure that
* any range columns have the DATA_TYPE meta-property set. It will ensure that no "half-ranges" are defined
* (meaning only a low or high range is specified.) This method will ensure that no two (or more) ranges
* specify the same input variable name. This method will convert all cell data in range columns to the data
* type of the range columns, so that comparison is faster during validations and searches. Ignore column
* values are converted to boolean. Priority column values are converted to int.
*/
private void verifyAndCache()
{
// If already called, ignore
if (hasContent(fieldAxisName) && hasContent(rowAxisName))
{
return
}
if (decisionCube.numDimensions != 2)
{
throw new IllegalStateException("Decision table: ${decisionCube.name} must have 2 axes.")
}
Set decisionMetaPropertyKeys = [INPUT_VALUE, INPUT_LOW, INPUT_HIGH, OUTPUT_VALUE] as Set
List axes = decisionCube.axes
Axis first = axes.first()
Axis second = axes.last()
// Figure out which axis has fields and which contains rows of data
// On the first Axis, look at the metaProperties of each non-default Column and collect the Columns which have decisionMetaPropertyKeys
Column any = first.columnsWithoutDefault.find { Column column ->
Set keys = new CaseInsensitiveSet<>(column.metaProperties.keySet())
keys.retainAll(decisionMetaPropertyKeys)
if (keys.size())
{
return column
}
}
// If there were any, we know the first Axis is where the fields are
if (any)
{
fieldAxisName = first.name
rowAxisName = second.name
}
else
{ // Else, go do the same thing on the second Axis
any = second.columnsWithoutDefault.find { Column column ->
Set keys = new CaseInsensitiveSet<>(column.metaProperties.keySet())
keys.retainAll(decisionMetaPropertyKeys)
if (keys.size())
{
return column
}
}
if (any)
{
fieldAxisName = second.name
rowAxisName = first.name
}
}
if (!fieldAxisName || !rowAxisName)
{
throw new IllegalStateException("Decision table: ${decisionCube.name} must have one axis with one or more columns with meta-property keys: input_value and/or input_high/input_low.")
}
Axis fieldAxis = decisionCube.getAxis(fieldAxisName)
if (fieldAxis.type != AxisType.DISCRETE)
{
throw new IllegalStateException("Decision table: ${decisionCube.name} field / property axis must be a DISCRETE axis. It is ${fieldAxis.type}")
}
if (fieldAxis.valueType != AxisValueType.CISTRING)
{
throw new IllegalStateException("Decision table: ${decisionCube.name} field / property axis must have value type of CISTRING. It is ${fieldAxis.valueType}")
}
Map rangeSpecs = new CaseInsensitiveMap<>(Collections.emptyMap(), new HashMap<>())
for (Column column : decisionCube.getAxis(fieldAxisName).columnsWithoutDefault)
{
Map colMetaProps = column.metaProperties
String columnValue = column.value
// Input Discrete
if (colMetaProps.get(INPUT_VALUE))
{
inputColumns.add(columnValue)
inputKeys.add(columnValue)
}
// Input Range
if (colMetaProps.containsKey(INPUT_LOW) || colMetaProps.containsKey(INPUT_HIGH))
{
String dataType = colMetaProps.get(DATA_TYPE)
if (dataType == null || !['LONG', 'DOUBLE', 'DATE', 'BIG_DECIMAL', 'STRING'].contains(dataType.toUpperCase()))
{
throw new IllegalStateException("Range columns must have 'data_type' meta-property set, column: ${columnValue}, ncube: ${decisionCube.name}. Valid values are DATE, LONG, DOUBLE, BIG_DECIMAL, STRING.")
}
inputColumns.add(columnValue)
rangeColumns.add(columnValue)
RangeSpec rangeSpec
String inputVarName
if (colMetaProps.containsKey(INPUT_LOW))
{
Object inputVariableName = colMetaProps.get(INPUT_LOW)
if (!(inputVariableName instanceof String))
{
throw new IllegalStateException("INPUT_LOW meta-property value must be of type String. Column: ${columnValue}, ncube: ${decisionCube.name}")
}
inputVarName = colMetaProps.get(INPUT_LOW)
inputKeys.add(inputVarName)
if (colMetaProps.get(REQUIRED))
{
requiredColumns.add(inputVarName)
}
rangeSpec = rangeSpecs.get(inputVarName)
if (rangeSpec == null)
{
rangeSpec = new RangeSpec()
}
if (rangeSpec.lowColumnName)
{
throw new IllegalStateException("More than one low range column with same input variable name found: ${INPUT_LOW}, ncube: ${decisionCube.name}. Each range variable should have a unique name.")
}
rangeSpec.lowColumnName = column.value
}
else
{
Object inputVariableName = colMetaProps.get(INPUT_HIGH)
if (!(inputVariableName instanceof String))
{
throw new IllegalStateException("INPUT_HIGH meta-property value must be of type String. Column: ${columnValue}, ncube: ${decisionCube.name}")
}
inputVarName = colMetaProps.get(INPUT_HIGH)
inputKeys.add(inputVarName)
if (colMetaProps.get(REQUIRED))
{
requiredColumns.add(inputVarName)
}
rangeSpec = rangeSpecs.get(inputVarName)
if (rangeSpec == null)
{
rangeSpec = new RangeSpec()
}
if (rangeSpec.highColumnName)
{
throw new IllegalStateException("More than one high range column with same input variable name found: ${INPUT_LOW}. Each range variable should have a unique name.")
}
rangeSpec.highColumnName = columnValue
}
rangeSpec.dataType = dataType
rangeSpecs.put(inputVarName, rangeSpec)
}
else
{ // Required Input Discrete
if (colMetaProps.get(REQUIRED))
{ // REQUIRED on non-input columns will be verified later in the code below.
requiredColumns.add(columnValue)
}
}
// Output
if (colMetaProps.get(OUTPUT_VALUE))
{
outputColumns.add(columnValue)
}
}
Set requiredColumnsCopy = new CaseInsensitiveSet<>(requiredColumns)
requiredColumnsCopy.removeAll(inputKeys)
if (!requiredColumnsCopy.empty)
{
throw new IllegalStateException("REQUIRED meta-property found on columns that are not input_value, input_low, or input_high. These were: ${requiredColumnsCopy}, ncube: ${decisionCube.name}")
}
// Throw error if a range is only half-defined (lower with no upper, upper with no lower).
for (RangeSpec rangeSpec : rangeSpecs.values())
{
String boundName1 = null
String boundName2 = null
if (StringUtilities.isEmpty(rangeSpec.lowColumnName))
{
boundName1 = 'Upper'
boundName2 = 'lower'
}
else if (StringUtilities.isEmpty(rangeSpec.highColumnName))
{
boundName1 = 'Lower'
boundName2 = 'upper'
}
if (boundName1)
{
throw new IllegalStateException("${boundName1} range Column defined without matching ${boundName2} range Column. Input variable name: ${rangeSpec.inputVarName}, data type: ${rangeSpec.dataType}, ncube: ${decisionCube.name}")
}
}
rangeKeys = getLinkedHashSet(inputKeys)
rangeKeys.removeAll(inputColumns)
// Convert all values in the table to the data_type specified on the column meta-property (if there is one)
Axis rowAxis = decisionCube.getAxis(rowAxisName)
Map coord = new CaseInsensitiveMap<>(Collections.emptyMap(), new HashMap<>())
List rowColumns = rowAxis.columnsWithoutDefault
Set rangePlusOutputCols = new CaseInsensitiveSet<>(rangeColumns)
rangePlusOutputCols.addAll(outputColumns)
Map cellOptions = new HashMap<>()
cellOptions.put(DONT_TRACK_INPUT_KEYS_USED, true)
cellOptions.put(NO_STACKFRAME, true)
for (String colName : rangePlusOutputCols)
{
Column column = fieldAxis.findColumn(colName)
String dataType = column.getMetaProperty(DATA_TYPE)
if (StringUtilities.isEmpty(dataType))
{ // Only convert cell values on columns that have DATA_TYPE
continue
}
Object columnValue = column.value
coord.put(fieldAxisName, columnValue)
for (Column row : rowColumns)
{
coord.put(rowAxisName, row.value)
Set idCoord = new LongHashSet(column.id, row.id)
Object value = decisionCube.getCellById(idCoord, coord, [:], null, true, cellOptions)
if (rangeColumns.contains(columnValue))
{ // Convert range column values, ensure their cell values are not null
if (value == null || !(value instanceof Comparable))
{
throw new IllegalStateException("Values in range column must be instanceof Comparable, row ${row.value}, field: ${columnValue}, ncube: ${decisionCube.name}")
}
}
else
{
if (!(value == null || value instanceof Comparable))
{
throw new IllegalStateException("Values in columns with DATA_TYPE meta-property must be instanceof Comparable, row ${row.value}, field: ${columnValue}, ncube: ${decisionCube.name}")
}
}
decisionCube.setCellById(convertDataType((Comparable) value, dataType), idCoord, true)
}
}
computeInputVarToRangeColumns()
convertSpecialColumnsToPrimitive()
// Point NCube to this DecisionTable so that it only has to verifyAndCache() one time.
decisionCube.setBusinessEngine(this)
}
/**
* Convert all IGNORE column values to boolean, and all priority column values to int.
*/
private void convertSpecialColumnsToPrimitive()
{
Axis fieldAxis = decisionCube.getAxis(fieldAxisName)
Axis rowAxis = decisionCube.getAxis(rowAxisName)
Map coord = new CaseInsensitiveMap<>(Collections.emptyMap(), new HashMap<>(4))
long ignoreColId = -1
Column ignoreCol = fieldAxis.findColumn(IGNORE)
if (ignoreCol != null && !ignoreCol.default)
{
ignoreColId = ignoreCol.id
}
long priorityColId = -1
Column priorityCol = fieldAxis.findColumn(PRIORITY)
if (priorityCol != null && !priorityCol.default)
{
priorityColId = priorityCol.id
}
if (priorityColId == -1 && ignoreColId == -1)
{ // Nothing to do here
return
}
Map cellOptions = new HashMap<>()
cellOptions.put(DONT_TRACK_INPUT_KEYS_USED, true)
cellOptions.put(NO_STACKFRAME, true)
for (Column row : rowAxis.columnsWithoutDefault)
{
coord.put(rowAxisName, row.value)
if (ignoreColId != -1)
{
coord.put(fieldAxisName, IGNORE)
Set idCoord = new LongHashSet(ignoreColId, row.id)
Object value = decisionCube.getCellById(idCoord, coord, [:], null, true, cellOptions)
decisionCube.setCellById(convertToBoolean(value), idCoord, true)
}
if (priorityColId != -1)
{
Set idCoord = new LongHashSet(priorityColId, row.id)
coord.put(fieldAxisName, PRIORITY)
Integer intValue = convertToInteger(decisionCube.getCellById(idCoord, coord, [:], null, true, cellOptions))
if (intValue < 1)
{ // If priority is not specified, then it is the lowest priority of all
intValue = Integer.MAX_VALUE
}
decisionCube.setCellById(intValue, idCoord, true)
}
}
}
/**
* Create Map that maps input variable name to the two columns that are needed to represent it.
*/
private void computeInputVarToRangeColumns()
{
Axis fieldAxis = decisionCube.getAxis(fieldAxisName)
for (String colValue : inputColumns)
{
Column field = fieldAxis.findColumn(colValue)
if (field.metaProperties.containsKey(INPUT_LOW)) // calling field.metaProperties to ensure the field is loaded
{
Range pair = inputVarNameToRangeColumns.get(field.metaProps.get(INPUT_LOW))
if (pair == null)
{
pair = new Range()
inputVarNameToRangeColumns.put((String)field.metaProps.get(INPUT_LOW), pair)
}
pair.low = field
}
else if (field.metaProps.containsKey(INPUT_HIGH))
{
Range pair = inputVarNameToRangeColumns.get(field.metaProps.get(INPUT_HIGH))
if (pair == null)
{
pair = new Range()
inputVarNameToRangeColumns.put((String)field.metaProps.get(INPUT_HIGH), pair)
}
pair.high = field
}
}
}
private static Map determinePriority(Map result)
{
Map result2 = [:]
Long highestPriority = null
for (Map.Entry entry : result.entrySet())
{
Map row = (Map) entry.value
Long currentPriority = convertToLong(row[PRIORITY])
if (highestPriority == null)
{
highestPriority = currentPriority
result2.put(entry.key, entry.value)
}
else if (currentPriority < highestPriority)
{
highestPriority = currentPriority
result2.clear()
result2.put(entry.key, entry.value)
}
else if (currentPriority == highestPriority)
{
result2.put(entry.key, entry.value)
}
row.remove(PRIORITY) // Do not return priority field
}
return result2
}
/**
* Create an N-Dimensional NCube, where the number of dimensions (N) is equivalent to the number
* of discrete (non range) columns on the field axis. When this method returns, it will have no
* cell contents, however, all Axes will be set up with each axis (dimension) containing all the
* values that appeared in rows (whether specified negative or positively, as a single value or
* a comma delimited list.)
*/
private NCube createValidationNCube(List rows)
{
NCube blowout = new NCube('validation')
Map coord = [:]
Axis fieldAxis = decisionCube.getAxis(fieldAxisName)
Set colsToProcess = new CaseInsensitiveSet<>(inputColumns)
colsToProcess.removeAll(rangeColumns)
for (String colValue : colsToProcess)
{
Column field = fieldAxis.findColumn(colValue)
Axis axis = new Axis(colValue, AxisType.DISCRETE, AxisValueType.STRING, false)
blowout.addAxis(axis)
String fieldValue = field.value
coord.put(fieldAxisName, fieldValue)
for (Column row : rows)
{
String rowValue = row.value
coord.put(rowAxisName, rowValue)
Set idCoord = new LongHashSet(field.id, row.id)
String cellValue = convertToString(decisionCube.getCellById(idCoord, coord, [:], null, true))
if (hasContent(cellValue))
{
cellValue -= BANG
Iterable values = COMMA_SPLITTER.split(cellValue)
for (String value : values)
{
if (!axis.findColumn(value))
{
blowout.addColumn(axis.name, value)
}
}
}
}
blowout.addColumn(axis.name, null)
}
return blowout
}
private static class BlowoutCell
{
private static IntSet blank = new IntOpenHashSet()
List> ranges = []
IntSet priorities = blank // This field is tested (empty), then always overwritten
}
/**
* Validate whether any input rules in the DecisionTable overlap. An overlap would happen if
* the same input to the DecisionTable caused two (2) or more rows to be returned.
* @param blowout NCube that has an Axis per each DISCRETE input_value column in the DecisionTable.
* @param rows List all rows (Column instances) from the Decision table row axis.
* @return Set row pointers (or empty Set in the case of no errors), of rows in a DecisionTable that
* conflict.
*/
private Set validateDecisionTableRows(NCube blowout, List rows)
{
Axis fieldAxis = decisionCube.getAxis(fieldAxisName)
Map coord = new CaseInsensitiveMap<>(Collections.emptyMap(), new HashMap<>(4))
Set badRows = new CaseInsensitiveSet<>()
Column ignoreColumn = fieldAxis.findColumn(IGNORE)
Column priorityColumn = fieldAxis.findColumn(PRIORITY)
int[] startCounters = new int[inputKeys.size() - rangeKeys.size()]
int[] counters = new int[startCounters.length]
boolean anyRanges = rangeColumns.size() > 0
boolean anyDiscretes = inputColumns.size() > rangeColumns.size()
// Caches to dramatically reduce memory footprint while this method is executing
Map, List> internedLists = new Object2ObjectOpenHashMap<>()
Map internedRanges = new Object2ObjectOpenHashMap<>()
Map internedIntSets = new Object2ObjectOpenHashMap<>()
Map primitives = new Object2ObjectOpenHashMap<>()
Set colsToProcess = new CaseInsensitiveSet<>(inputColumns)
colsToProcess.removeAll(rangeColumns)
Set inputKeysCopy = new CaseInsensitiveSet<>(inputKeys)
inputKeysCopy.removeAll(rangeKeys)
String[] axisNames = inputKeysCopy as String[]
String[] indexToRangeName = new String[rangeKeys.size()]
int index = 0
for (String rangeName : rangeKeys)
{
indexToRangeName[index++] = rangeName
}
for (Column row : rows)
{
Comparable rowValue = row.value
Long rowId = row.id
coord.put(rowAxisName, rowValue)
if (ignoreColumn)
{
coord.put(fieldAxisName, IGNORE)
Set idCoord = new LongHashSet(rowId, ignoreColumn.id)
if (decisionCube.getCellById(idCoord, coord, [:], null, true))
{
continue
}
}
Map> bindings = getImpliedCells(blowout, row, fieldAxis, colsToProcess)
int priority = getPriority(coord, rowId, priorityColumn)
System.arraycopy(startCounters, 0, counters, 0, startCounters.length)
Map rowRanges = getRowRanges(coord, rowId, priority, internedRanges, primitives)
boolean done = false
Set ids = new HashSet<>()
Map coordinate = new HashMap<>(axisNames.length)
// Loop written this way because do-while loops are not in Groovy until version 3
while (!done)
{
ids.clear()
int idx = 0
for (String axisName : axisNames)
{ // this loop skipped if there are no discrete variables (range(s) only)
int radix = counters[idx++]
Comparable value = bindings.get(axisName).get(radix)
coordinate.put(axisName, value)
ids.add(blowout.getAxis(axisName).findColumn(value).id)
}
Set cellPtr = new LongHashSet(ids)
boolean areDiscretesUnique = false
boolean areRangesGood = false
// Grab the blowoutCell (List>)
BlowoutCell cell = blowout.getCellById(cellPtr, coordinate, [:], null, true)
if (cell == null)
{
cell = new BlowoutCell()
blowout.setCellById(cell, cellPtr, true)
}
if (anyRanges)
{ // get all Ranges for row, track them by name
areRangesGood = checkRowRangesForOverlap(cell, indexToRangeName, rowRanges, internedLists)
done = true
}
if (anyDiscretes)
{
areDiscretesUnique = checkDiscretesForOverlap(cell, priority, internedIntSets)
done = !incrementVariableRadixCount(counters, bindings, axisNames)
}
if (!areDiscretesUnique && !areRangesGood)
{
badRows.add(rowValue)
}
}
}
return badRows
}
/**
* Check the passed in rowRanges against the 'ranges' passed for complete overlap (for the given cellPtr).
* If there is complete overlap, return false, otherwise true.
* Example of List>:
* age salary
* [0] [Range(0, 16), Range(0, 40000)]
* [1] [Range(16, 35), Range(0, 40000)]
* [2] [Range(35, 70), Range(0, 40000)]
*
* The outer List is the length of the number of unique ranges encountered per cellPtr (unique coordinate
* representing all discrete decision variables). The inner lists are prior row's ranges that have been
* encountered, that were non-overlapping - meaning they did not overlap this list (unique in at least one
* range [age or salary, ...]
*
* Although this method can indict a row's ranges (saying that they overlap another row elsewhere in the
* DecisionTable), the associated discrete variables can be unique (unique cellPtr), thereby the row is
* still good.
*/
private static boolean checkRowRangesForOverlap(BlowoutCell cell, String[] indexToRangeName, Map rowRanges, Map, List> internedLists)
{
final int len = cell.ranges.size()
List existingRanges = cell.ranges
for (int i=0; i < len; i++)
{ // Loop through however many the table has grown too (a function of how many unique ranges appear).
List existingRange = existingRanges.get(i)
int len2 = existingRange.size()
boolean good = false
for (int j=0; j < len2; j++)
{ // Loop through all range variables ranges (age, salary, date)
String rangeName = indexToRangeName[j]
Range range = existingRange.get(j)
if (!range.overlap(rowRanges.get(rangeName)))
{ // If any one range doesn't overlap, then this List of ranges is OK against another existing List of ranges
good = true
break
}
}
if (!good)
{ // Short-circuit - no need to test further, overlap found.
return false
}
}
List list = new ArrayList<>(rowRanges.values())
existingRanges.add(internList(list, internedLists))
return true
}
/**
* Get all ranges in a given row of the decision table
* @param Coord Map specifying the n-cube coordinate. The 'rowAxisName' must already
* be set before calling this method.
* @return Map that maps a range name to its ranges on a given row.
*/
private Map getRowRanges(Map coord, long rowId, int priority, Map internedRanges, Map primitives)
{
Map ranges = new CompactCILinkedMap<>()
for (String rangeName : rangeKeys)
{
Range bounds = inputVarNameToRangeColumns.get(rangeName)
Column lowColumn = (Column) bounds.low
coord.put(fieldAxisName, lowColumn.value)
Set idCoord = new LongHashSet(lowColumn.id, rowId)
Range range = new Range()
range.low = (Comparable) decisionCube.getCellById(idCoord, coord, [:], null, true)
Column highColumn = (Column) bounds.high
coord.put(fieldAxisName, highColumn.value)
idCoord = new LongHashSet(highColumn.id, rowId)
range.high = (Comparable) decisionCube.getCellById(idCoord, coord, [:], null, true)
range.priority = priority
ranges.put(rangeName, internRange(range, internedRanges, primitives))
}
return ranges
}
/**
* Get the implied cells in the blowout NCube based on a row in the DecisionTable.
* @param fieldAxis Axis representing the decision table columns
* @param row Column from the Row axis in a DecisionTable.
* @param blowout NCube that has an Axis per each DISCRETE input_value column in the DecisionTable.
* @return Map> representing all the discrete input values used for the row, or implied
* by the row in the case blank (*) or ! (exclusion) is used.
*/
private Map> getImpliedCells(NCube blowout, Column row, Axis fieldAxis, Set colsToProcess)
{
Map> bindings = new Object2ObjectOpenHashMap()
Map coord = new CompactCILinkedMap<>()
coord.put(rowAxisName, row.value)
for (String colValue : colsToProcess)
{
List faces = []
bindings.put(colValue, faces)
Column field = fieldAxis.findColumn(colValue)
Set idCoord = new LongHashSet(field.id, row.id)
coord.put(fieldAxisName, field.value)
String cellValue = convertToString(decisionCube.getCellById(idCoord, coord, [:], null, true))
if (hasContent(cellValue))
{
boolean exclude = cellValue.startsWith(BANG)
cellValue -= BANG
Iterable values = COMMA_SPLITTER.split(cellValue)
if (exclude)
{ // Not the value or list we are looking for (implies all other values)
List columns = blowout.getAxis(colValue).columns
for (Column column : columns)
{
String columnValue = column.value
if (!values.contains(columnValue))
{
faces.add(columnValue)
}
}
}
else
{ // Value or values to check
for (String value : values)
{
faces.add(value)
}
}
}
else
{ // Empty cell in input_value column implies all possible values
List columns = blowout.getAxis(colValue).columns
for (Column column : columns)
{
String columnValue = column.value
faces.add(columnValue)
}
}
}
return bindings
}
/**
* Fetch the 'int' priority value from the 'Priority' column, if it exists. If not, then
* return INTEGER.MAX_VALUE as the priority (lowest).
*/
private int getPriority(Map coord, long rowId, Column priorityColumn)
{
if (priorityColumn)
{
coord.put(fieldAxisName, priorityColumn.value)
Set idCoord = new LongHashSet(priorityColumn.id, rowId)
return decisionCube.getCellById(idCoord, coord, [:], null, true)
}
else
{
return Integer.MAX_VALUE
}
}
/**
* Fill the passed in cell with a RangeSet containing the passed in 'priority' value.
* If there is already a RangeSet with priorities there, and it already contains the same priority alue, then
* return false (we've identified a duplicate rule in the DecisionTable). If the RangeSet is there, but
* it does not contain the same priority passed in, add it.
*/
private static boolean checkDiscretesForOverlap(BlowoutCell cell, int priority, Map internedIntSets)
{
if (cell.priorities.contains(priority))
{
return false
}
else
{
// "IntSet" pulled from blowout cell duplicated so that the interned version is not modified directly.
IntSet copy = new IntOpenHashSet()
IntIterator i = cell.priorities.iterator()
while (i.hasNext())
{
copy.add(i.nextInt())
}
copy.add(priority)
cell.priorities = internSet(copy, internedIntSets)
return true
}
}
/**
* Ensure required input key/values are supplied. If required values are missing, IllegalArgumentException is
* thrown.
* @param input Map containing decision variable name/value pairs.
*/
private void ensuredRequiredInputs(Map input)
{
if (!input.keySet().containsAll(requiredColumns))
{
Set requiredCopy = new CaseInsensitiveSet<>(requiredColumns)
requiredCopy.removeAll(input.keySet())
throw new IllegalArgumentException("Required input keys: ${requiredCopy} not found, decision table: ${decisionCube.name}")
}
Iterator i = requiredColumns.iterator()
while (i.hasNext())
{
String key = i.next();
Object value = input.get(key)
if (value instanceof Iterable)
{
Iterable iterable = (Iterable) value
if (iterable.size() == 0)
{
throw new IllegalArgumentException("Required input key: ${key} has an empty array '[]' for its value. Required inputs must have at least one value associated to them, decision table: ${decisionCube.name}")
}
}
}
}
/**
* Get the range spec Map out of the 'ranges' map by input variable name. If not there, create a new
* empty range spec Map and place it there.
*/
private static Map getRangeSpec(Map> ranges, String inputVarName)
{
Map spec
if (ranges.containsKey(inputVarName))
{
spec = (Map) ranges.get(inputVarName)
}
else
{
spec = new CaseInsensitiveMap<>(Collections.emptyMap(), new HashMap<>(4))
ranges.put(inputVarName, spec)
}
return spec
}
/**
* Convert the passed in value to the data-type specified.
*/
private Comparable convertDataType(Comparable value, String dataType)
{
if (value == null)
{
return null
}
if (dataType.equalsIgnoreCase('DATE'))
{
return convertToDate(value)
}
else if (dataType.equalsIgnoreCase('LONG'))
{
return convertToLong(value)
}
else if (dataType.equalsIgnoreCase('DOUBLE'))
{
return convertToDouble(value)
}
else if (dataType.equalsIgnoreCase('BIG_DECIMAL'))
{
return convertToBigDecimal(value)
}
else if (dataType.equalsIgnoreCase('STRING'))
{
return convertToString(value)
}
else if (dataType.equalsIgnoreCase('BOOLEAN'))
{
return convertToBoolean(value)
}
throw new IllegalStateException("Data type must be one of: DATE, LONG, DOUBLE, BIG_DECIMAL, STRING, or BOOLEAN. Data type: ${dataType}, value: ${value}, decision table: ${decisionCube.name}")
}
/**
* Re-use Range instances.
*/
private static Range internRange(Range candidate, Map internedRanges, Map primitives)
{
Range internedRange = internedRanges.get(candidate)
if (internedRange != null)
{
return internedRange
}
Comparable low = primitives.get(candidate.low)
if (low != null)
{
candidate.low = low
}
else
{
primitives.put(candidate.low, candidate.low)
}
Comparable high = primitives.get(candidate.high)
if (high != null)
{
candidate.high = high
}
else
{
primitives.put(candidate.high, candidate.high)
}
internedRanges.put(candidate, candidate)
return candidate
}
/**
* Re-use Set instances.
*/
private static IntSet internSet(IntSet candidate, Map internedSets)
{
IntSet internedSet = internedSets.get(candidate)
if (internedSet != null)
{
return internedSet
}
internedSets.put(candidate, candidate)
return candidate
}
/**
* Re-use List instances
*/
private static List internList(List candidate, Map, List> internedLists)
{
List internedList = internedLists.get(candidate)
if (internedList != null)
{
return internedList
}
internedLists.put(candidate, candidate)
return candidate
}
/**
* Increment the variable radix number passed in. The number is represented by an Object[].
* @return false if more incrementing can be done, otherwise true.
*/
private static boolean incrementVariableRadixCount(int[] counters,
final Map> bindings,
final String[] axisNames)
{
int digit = axisNames.length - 1
while (true)
{
final String axisName = axisNames[digit]
final int count = counters[digit]
final List cols = bindings.get(axisName)
if (count >= cols.size() - 1)
{ // Reach max value for given dimension (digit)
if (digit == 0)
{ // we have reached the max radix for the most significant digit - we are done
return false
}
counters[digit--] = 0
}
else
{
counters[digit] = count + 1
return true
}
}
}
}