diff --git a/src/main/java/com/imsweb/staging/engine/DecisionEngine.java b/src/main/java/com/imsweb/staging/engine/DecisionEngine.java index 7211754fd..103c06c96 100644 --- a/src/main/java/com/imsweb/staging/engine/DecisionEngine.java +++ b/src/main/java/com/imsweb/staging/engine/DecisionEngine.java @@ -14,6 +14,7 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.Objects; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -46,16 +47,17 @@ public class DecisionEngine { // string to use for blank or null in error strings public static final String _BLANK_OUTPUT = ""; private static final Pattern _TEMPLATE_REFERENCE = Pattern.compile("\\{\\{(.*?)}}"); - private DataProvider _provider; + private final DataProvider _provider; private static final String _CONTEXT_MISSING_MESSAGE = "Context must not be missing"; /** * Construct the decision engine with the passed data provider * @param provider a DataProvider + * @throws NullPointerException if provider is null */ public DecisionEngine(DataProvider provider) { - setProvider(provider); + _provider = Objects.requireNonNull(provider, "Provider must not be null"); } /** @@ -218,14 +220,6 @@ public DataProvider getProvider() { return _provider; } - /** - * Sets the provider and initiaizes all definitions and tables - * @param provider a DataProvider - */ - public void setProvider(DataProvider provider) { - _provider = provider; - } - /** * Given a mapping and a context, check the inclusion/exclusion tables to see if mapping should be processed * @param mapping a Mapping @@ -646,6 +640,10 @@ public Result process(String schemaId, Map context) { /** * Using the supplied context, process a schema. The results will be added to the context. + *

+ * Input-mapping destination keys on a table path are temporary aliases scoped to that path. They are added before the path is processed and removed afterward. An input-mapping + * destination must therefore not be used to preserve a pre-existing context value; any previous value with the same key is overwritten and is not restored. + *

* @param schema a schema * @param context a Map containing the context * @return a Result @@ -666,8 +664,10 @@ public Result process(Schema schema, Map context) { String value = context.get(input.getKey()); // if value not supplied, use the default or defaultTable and set it back into the context; if not supplied and no default, set the input the blank - if (value == null) - context.put(input.getKey(), getDefault(input, context, result)); + if (value == null) { + value = getDefault(input, context, result); + context.put(input.getKey(), value); + } // validate value against associated table if supplied; if a value is not supplied, or blank, there is no need to validate it against the table if (value != null && !value.isEmpty() && input.getTable() != null) { @@ -845,7 +845,7 @@ protected boolean process(String mappingId, String tableId, TablePath path, Resu for (Endpoint endpoint : endpoints) { if (EndpointType.STOP.equals(endpoint.getType())) continueProcessing = false; - else if (EndpointType.JUMP.equals(endpoint.getType())) + else if (EndpointType.JUMP.equals(endpoint.getType()) && continueProcessing) continueProcessing = process(mappingId, endpoint.getValue(), path, result, stack); else if (EndpointType.ERROR.equals(endpoint.getType())) { String message = endpoint.getValue(); diff --git a/src/test/java/com/imsweb/staging/engine/DecisionEngineTest.java b/src/test/java/com/imsweb/staging/engine/DecisionEngineTest.java index f5286ce77..866b8cf14 100644 --- a/src/test/java/com/imsweb/staging/engine/DecisionEngineTest.java +++ b/src/test/java/com/imsweb/staging/engine/DecisionEngineTest.java @@ -4,6 +4,7 @@ */ package com.imsweb.staging.engine; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -29,6 +30,7 @@ import com.imsweb.staging.entities.Schema; import com.imsweb.staging.entities.Table; import com.imsweb.staging.entities.impl.StagingColumnDefinition; +import com.imsweb.staging.entities.impl.StagingKeyValue; import com.imsweb.staging.entities.impl.StagingMapping; import com.imsweb.staging.entities.impl.StagingRange; import com.imsweb.staging.entities.impl.StagingSchema; @@ -53,6 +55,38 @@ class DecisionEngineTest { private static DecisionEngine _ENGINE; + @Test + void testProviderIsRequired() { + NullPointerException exception = assertThrows(NullPointerException.class, () -> new DecisionEngine(null)); + assertEquals("Provider must not be null", exception.getMessage()); + } + + @Test + void testUtilityMethodEdgeCases() { + assertFalse(DecisionEngine.isReferenceVariable(null)); + assertFalse(DecisionEngine.isReferenceVariable("{{key}")); + assertTrue(DecisionEngine.isReferenceVariable("{{key}}")); + assertEquals("{{", DecisionEngine.trimBraces("{{")); + assertEquals("key", DecisionEngine.trimBraces("{{key}}")); + + assertTrue(DecisionEngine.testMatch(null, "anything", new HashMap<>())); + assertTrue(DecisionEngine.testMatch(Collections.emptyList(), "anything", new HashMap<>())); + assertNull(DecisionEngine.translateValue(null, new HashMap<>())); + assertEquals("{{key", DecisionEngine.translateValue("{{key", new HashMap<>())); + + StagingTable table = new StagingTable("matching"); + table.addColumnDefinition("input", ColumnType.INPUT); + table.addRawRow("A"); + InMemoryDataProvider provider = new InMemoryDataProvider("Test", "1.0"); + provider.addTable(table); + + Map context = new HashMap<>(); + context.put("input", "A"); + Table matchingTable = provider.getTable("matching"); + assertEquals(0, DecisionEngine.findMatchingTableRow(matchingTable, context)); + assertThrows(IllegalStateException.class, () -> DecisionEngine.findMatchingTableRow(matchingTable, null)); + } + @BeforeAll static void init() { InMemoryDataProvider provider = new InMemoryDataProvider("test", "1.0"); @@ -967,6 +1001,45 @@ void testProcessWithStop() { assertFalse(input.containsKey("shared_result")); } + @Test + void testStopIsNotOverwrittenByLaterJump() { + InMemoryDataProvider provider = new InMemoryDataProvider("Test", "1.0"); + + StagingTable table = new StagingTable("table_stop_then_jump"); + table.addColumnDefinition("input", ColumnType.INPUT); + table.addColumnDefinition("stop", ColumnType.ENDPOINT); + table.addColumnDefinition("jump", ColumnType.ENDPOINT); + table.addRawRow("1", "STOP", "JUMP:table_jump_target"); + provider.addTable(table); + + table = new StagingTable("table_jump_target"); + table.addColumnDefinition("input", ColumnType.INPUT); + table.addColumnDefinition("jumped", ColumnType.ENDPOINT); + table.addRawRow("1", "VALUE:YES"); + provider.addTable(table); + + table = new StagingTable("table_after_stop"); + table.addColumnDefinition("input", ColumnType.INPUT); + table.addColumnDefinition("continued", ColumnType.ENDPOINT); + table.addRawRow("1", "VALUE:YES"); + provider.addTable(table); + + StagingSchema schema = new StagingSchema("stop_then_jump"); + schema.setSchemaSelectionTable("table_stop_then_jump"); + schema.addInput("input"); + schema.addMapping(new StagingMapping("m1", Arrays.asList(new StagingTablePath("table_stop_then_jump"), new StagingTablePath("table_after_stop")))); + provider.addSchema(schema); + + Map context = new HashMap<>(); + context.put("input", "1"); + Result result = new DecisionEngine(provider).process(schema, context); + + assertFalse(result.hasErrors()); + assertEquals(Collections.singletonList("m1.table_stop_then_jump"), result.getPath()); + assertFalse(context.containsKey("jumped")); + assertFalse(context.containsKey("continued")); + } + @Test void testProcessWithBlanks() { Map input = new HashMap<>(); @@ -1292,6 +1365,59 @@ void testInclusionsAndExclusions() { assertEquals("SUCCESS", input.get("special")); } + @Test + void testMappedInclusionsAndExclusionsAndMissingTables() { + InMemoryDataProvider provider = new InMemoryDataProvider("Test", "1.0"); + StagingTable table = new StagingTable("criteria"); + table.addColumnDefinition("mapped", ColumnType.INPUT); + table.addRawRow("A"); + provider.addTable(table); + + DecisionEngine engine = new DecisionEngine(provider); + StagingTablePath path = new StagingTablePath("criteria"); + path.addInputMapping("source", "mapped"); + + StagingMapping inclusion = new StagingMapping("inclusion"); + inclusion.setInclusionTables(Collections.singletonList(path)); + Map context = new HashMap<>(); + context.put("source", "A"); + assertTrue(engine.isMappingInvolved(inclusion, context)); + context.put("source", "B"); + assertFalse(engine.isMappingInvolved(inclusion, context)); + + StagingMapping exclusion = new StagingMapping("exclusion"); + exclusion.setExclusionTables(Collections.singletonList(path)); + context.put("source", "A"); + assertFalse(engine.isMappingInvolved(exclusion, context)); + context.put("source", "B"); + assertTrue(engine.isMappingInvolved(exclusion, context)); + + assertThrows(IllegalStateException.class, () -> engine.isMappingInvolved(inclusion, null)); + StagingSchema schema = new StagingSchema("schema"); + assertThrows(IllegalStateException.class, () -> engine.getInvolvedMappings(schema, null)); + + inclusion.setInclusionTables(Collections.singletonList(new StagingTablePath("missing"))); + assertThrows(IllegalStateException.class, () -> engine.isMappingInvolved(inclusion, context)); + exclusion.setExclusionTables(Collections.singletonList(new StagingTablePath("missing"))); + assertThrows(IllegalStateException.class, () -> engine.isMappingInvolved(exclusion, context)); + } + + @Test + void testMissingSchemaAndTableReferences() { + InMemoryDataProvider provider = new InMemoryDataProvider("Test", "1.0"); + DecisionEngine engine = new DecisionEngine(provider); + + assertThrows(IllegalStateException.class, () -> engine.process("missing", new HashMap<>())); + assertThrows(IllegalStateException.class, () -> engine.getInvolvedTables("missing")); + assertTrue(engine.getInputs(new StagingTablePath("missing")).isEmpty()); + assertTrue(engine.getOutputs(new StagingTablePath("missing")).isEmpty()); + + StagingTablePath path = new StagingTablePath("starting_table"); + Result result = new Result(new HashMap<>()); + assertTrue(engine.process("mapping", "missing", path, result, new ArrayDeque<>())); + assertEquals(Error.Type.UNKNOWN_TABLE, result.getErrors().getFirst().getType()); + } + @Test void testDuplicateAlgorithms() { InMemoryDataProvider provider = new InMemoryDataProvider("Test", "1.0"); @@ -1416,9 +1542,17 @@ void testOutputsAndDefaults() { assertEquals("A", input.get("output1")); // no default value so it should be blank assertEquals("", input.get("output2")); - assertFalse(result.hasErrors()); + // a blank default should use the standard blank marker in validation errors + schema.getOutputs().getFirst().setDefault(null); + provider.initSchema(schema); + input = new HashMap<>(); + input.put("input1", "000"); + result = engine.process("sample_outputs", input); + assertTrue(result.hasErrors()); + assertEquals("Invalid 'output1' value (" + DecisionEngine._BLANK_OUTPUT + ")", result.getErrors().getFirst().getMessage()); + assertEquals(new HashSet<>(Arrays.asList("table_input", "table_output")), engine.getInvolvedTables(schema)); // modify the definition to create a bad default value for output1 @@ -1442,6 +1576,149 @@ void testOutputsAndDefaults() { assertEquals("", input.get("output2")); } + @Test + void testDefaultInputValidation() { + InMemoryDataProvider provider = new InMemoryDataProvider("Test", "1.0"); + + StagingTable table = new StagingTable("valid_inputs"); + table.addColumnDefinition("input", ColumnType.INPUT); + table.addRawRow("A"); + provider.addTable(table); + + table = new StagingTable("invalid_default"); + table.addColumnDefinition("selector", ColumnType.INPUT); + table.addColumnDefinition("input", ColumnType.ENDPOINT); + table.addRawRow("*", "VALUE:X"); + provider.addTable(table); + + StagingSchema schema = new StagingSchema("valid_literal_default"); + schema.setSchemaSelectionTable("valid_inputs"); + schema.setOnInvalidInput(Schema.StagingInputErrorHandler.FAIL); + StagingSchemaInput input = new StagingSchemaInput("input", "input", "valid_inputs"); + input.setDefault("A"); + schema.addInput(input); + provider.addSchema(schema); + + schema = new StagingSchema("invalid_literal_default"); + schema.setSchemaSelectionTable("valid_inputs"); + schema.setOnInvalidInput(Schema.StagingInputErrorHandler.FAIL); + input = new StagingSchemaInput("input", "input", "valid_inputs"); + input.setDefault("X"); + schema.addInput(input); + provider.addSchema(schema); + + schema = new StagingSchema("invalid_required_default"); + schema.setSchemaSelectionTable("valid_inputs"); + schema.setOnInvalidInput(Schema.StagingInputErrorHandler.FAIL_WHEN_USED_FOR_STAGING); + input = new StagingSchemaInput("input", "input", "valid_inputs"); + input.setDefault("X"); + input.setUsedForStaging(true); + schema.addInput(input); + provider.addSchema(schema); + + schema = new StagingSchema("invalid_non_required_default"); + schema.setSchemaSelectionTable("valid_inputs"); + schema.setOnInvalidInput(Schema.StagingInputErrorHandler.FAIL_WHEN_USED_FOR_STAGING); + input = new StagingSchemaInput("input", "input", "valid_inputs"); + input.setDefault("X"); + input.setUsedForStaging(false); + schema.addInput(input); + provider.addSchema(schema); + + schema = new StagingSchema("invalid_table_default"); + schema.setSchemaSelectionTable("valid_inputs"); + schema.setOnInvalidInput(Schema.StagingInputErrorHandler.FAIL); + input = new StagingSchemaInput("input", "input", "valid_inputs"); + input.setDefaultTable("invalid_default"); + schema.addInput(input); + provider.addSchema(schema); + + DecisionEngine engine = new DecisionEngine(provider); + + Result result = engine.process("valid_literal_default", new HashMap<>()); + assertEquals(Type.STAGED, result.getType()); + assertFalse(result.hasErrors()); + assertEquals("A", result.getContext().get("input")); + + result = engine.process("invalid_literal_default", new HashMap<>()); + assertEquals(Type.FAILED_INPUT, result.getType()); + assertEquals(Error.Type.INVALID_NON_REQUIRED_INPUT, result.getErrors().getFirst().getType()); + assertEquals("X", result.getContext().get("input")); + + result = engine.process("invalid_required_default", new HashMap<>()); + assertEquals(Type.FAILED_INPUT, result.getType()); + assertEquals(Error.Type.INVALID_REQUIRED_INPUT, result.getErrors().getFirst().getType()); + + result = engine.process("invalid_non_required_default", new HashMap<>()); + assertEquals(Type.STAGED, result.getType()); + assertEquals(Error.Type.INVALID_NON_REQUIRED_INPUT, result.getErrors().getFirst().getType()); + + result = engine.process("invalid_table_default", new HashMap<>()); + assertEquals(Type.FAILED_INPUT, result.getType()); + assertEquals(Error.Type.INVALID_NON_REQUIRED_INPUT, result.getErrors().getFirst().getType()); + assertEquals("X", result.getContext().get("input")); + } + + @Test + void testProcessingReferenceErrorsAndMappingInitialContext() { + InMemoryDataProvider provider = new InMemoryDataProvider("Test", "1.0"); + + StagingTable selection = new StagingTable("selection"); + selection.addColumnDefinition("selector", ColumnType.INPUT); + selection.addRawRow("*"); + provider.addTable(selection); + + StagingTable mappingTable = new StagingTable("mapping_table"); + mappingTable.addColumnDefinition("temporary", ColumnType.INPUT); + mappingTable.addColumnDefinition("result", ColumnType.ENDPOINT); + mappingTable.addRawRow("A", "VALUE:OK"); + provider.addTable(mappingTable); + + StagingSchema schema = new StagingSchema("mapping_initial_context"); + schema.setSchemaSelectionTable("selection"); + schema.addOutput(new StagingSchemaOutput("result")); + StagingMapping mapping = new StagingMapping("mapping"); + mapping.setInitialContext(Collections.singleton(new StagingKeyValue("temporary", "A"))); + mapping.addTablePath(new StagingTablePath("mapping_table")); + schema.addMapping(mapping); + provider.addSchema(schema); + + schema = new StagingSchema("unknown_input_mapping"); + schema.setSchemaSelectionTable("selection"); + schema.addOutput(new StagingSchemaOutput("result")); + StagingTablePath path = new StagingTablePath("mapping_table"); + path.addInputMapping("missing_source", "temporary"); + schema.addMapping(new StagingMapping("mapping", Collections.singletonList(path))); + provider.addSchema(schema); + + schema = new StagingSchema("unknown_input_table"); + schema.setSchemaSelectionTable("selection"); + schema.addInput(new StagingSchemaInput("input", "input", "missing_input_table")); + provider.addSchema(schema); + + schema = new StagingSchema("unknown_output_table"); + schema.setSchemaSelectionTable("selection"); + schema.addOutput(new StagingSchemaOutput("result", "result", "missing_output_table")); + provider.addSchema(schema); + + DecisionEngine engine = new DecisionEngine(provider); + assertTrue(engine.getInputs(mapping, new HashSet<>()).isEmpty()); + Result result = engine.process("mapping_initial_context", new HashMap<>()); + assertFalse(result.hasErrors()); + assertEquals("OK", result.getContext().get("result")); + + result = engine.process("unknown_input_mapping", new HashMap<>()); + assertThat(result.getErrors()).extracting(Error::getType).contains(Error.Type.UNKNOWN_INPUT_MAPPING); + + Map context = new HashMap<>(); + context.put("input", "A"); + result = engine.process("unknown_input_table", context); + assertThat(result.getErrors()).extracting(Error::getType).containsExactly(Error.Type.UNKNOWN_TABLE); + + result = engine.process("unknown_output_table", new HashMap<>()); + assertThat(result.getErrors()).extracting(Error::getType).containsExactly(Error.Type.UNKNOWN_TABLE); + } + @Test void testInitialContextReferences() { StagingSchema schema = new StagingSchema("test_initial_context");