diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/TestFlowController.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/TestFlowController.java index 385178fb12..ad5972dfa2 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/TestFlowController.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/TestFlowController.java @@ -16,6 +16,8 @@ */ package org.apache.nifi.controller; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; import org.apache.nifi.admin.service.AuditService; @@ -33,11 +35,15 @@ import org.apache.nifi.cluster.protocol.StandardDataFlow; import org.apache.nifi.components.PropertyDescriptor; import org.apache.nifi.controller.exception.ProcessorInstantiationException; import org.apache.nifi.controller.flow.FlowManager; +import org.apache.nifi.controller.flow.VersionedDataflow; +import org.apache.nifi.controller.flow.VersionedFlowEncodingVersion; import org.apache.nifi.controller.reporting.ReportingTaskInstantiationException; import org.apache.nifi.controller.repository.FlowFileEventRepository; import org.apache.nifi.controller.scheduling.StandardProcessScheduler; import org.apache.nifi.controller.serialization.FlowSynchronizationException; import org.apache.nifi.controller.serialization.FlowSynchronizer; +import org.apache.nifi.controller.serialization.StandardFlowSynchronizer; +import org.apache.nifi.controller.serialization.VersionedFlowSynchronizer; import org.apache.nifi.controller.service.ControllerServiceNode; import org.apache.nifi.controller.service.ControllerServiceProvider; import org.apache.nifi.controller.service.mock.DummyProcessor; @@ -47,6 +53,9 @@ import org.apache.nifi.controller.service.mock.ServiceB; import org.apache.nifi.controller.status.history.StatusHistoryRepository; import org.apache.nifi.encrypt.PropertyEncryptor; import org.apache.nifi.encrypt.PropertyEncryptorFactory; +import org.apache.nifi.flow.ComponentType; +import org.apache.nifi.flow.Position; +import org.apache.nifi.flow.VersionedProcessGroup; import org.apache.nifi.groups.BundleUpdateStrategy; import org.apache.nifi.groups.ProcessGroup; import org.apache.nifi.logging.LogLevel; @@ -59,6 +68,7 @@ import org.apache.nifi.nar.SystemBundle; import org.apache.nifi.parameter.Parameter; import org.apache.nifi.parameter.ParameterContext; import org.apache.nifi.parameter.ParameterDescriptor; +import org.apache.nifi.persistence.FlowConfigurationArchiveManager; import org.apache.nifi.processor.Relationship; import org.apache.nifi.provenance.MockProvenanceRepository; import org.apache.nifi.registry.VariableRegistry; @@ -125,6 +135,7 @@ public class TestFlowController { private VariableRegistry variableRegistry; private ExtensionDiscoveringManager extensionManager; private StatusHistoryRepository statusHistoryRepository; + private FlowSynchronizer standardFlowSynchronizer; @Before public void setup() { @@ -188,6 +199,11 @@ public class TestFlowController { bulletinRepo = mock(BulletinRepository.class); controller = FlowController.createStandaloneInstance(flowFileEventRepo, nifiProperties, authorizer, auditService, encryptor, bulletinRepo, variableRegistry, mock(FlowRegistryClient.class), extensionManager, statusHistoryRepository); + + final XmlFlowSynchronizer xmlFlowSynchronizer = new XmlFlowSynchronizer(nifiProperties, extensionManager); + final VersionedFlowSynchronizer versionedFlowSynchronizer = new VersionedFlowSynchronizer(extensionManager, + nifiProperties.getFlowConfigurationJsonFile(), new FlowConfigurationArchiveManager(nifiProperties)); + standardFlowSynchronizer = new StandardFlowSynchronizer(xmlFlowSynchronizer, versionedFlowSynchronizer); } @After @@ -198,8 +214,6 @@ public class TestFlowController { @Test public void testSynchronizeFlowWithReportingTaskAndProcessorReferencingControllerService() throws IOException { - final FlowSynchronizer standardFlowSynchronizer = new XmlFlowSynchronizer(nifiProperties, extensionManager); - // create a mock proposed data flow with the same auth fingerprint as the current authorizer final String authFingerprint = authorizer.getFingerprint(); final File flowFile = new File("src/test/resources/conf/reporting-task-with-cs-flow-0.7.0.xml"); @@ -259,8 +273,6 @@ public class TestFlowController { @Test public void testSynchronizeFlowWithProcessorReferencingControllerService() throws IOException { - final FlowSynchronizer standardFlowSynchronizer = new XmlFlowSynchronizer(nifiProperties, extensionManager); - // create a mock proposed data flow with the same auth fingerprint as the current authorizer final String authFingerprint = authorizer.getFingerprint(); final File flowFile = new File("src/test/resources/conf/processor-with-cs-flow-0.7.0.xml"); @@ -300,8 +312,6 @@ public class TestFlowController { @Test public void testSynchronizeFlowWhenAuthorizationsAreEqual() { - final FlowSynchronizer standardFlowSynchronizer = new XmlFlowSynchronizer(nifiProperties, extensionManager); - // create a mock proposed data flow with the same auth fingerprint as the current authorizer final String authFingerprint = authorizer.getFingerprint(); final DataFlow proposedDataFlow = mock(DataFlow.class); @@ -314,8 +324,6 @@ public class TestFlowController { @Test(expected = UninheritableFlowException.class) public void testSynchronizeFlowWhenAuthorizationsAreDifferent() throws IOException { - final FlowSynchronizer standardFlowSynchronizer = new XmlFlowSynchronizer(nifiProperties, extensionManager); - final File flowFile = new File("src/test/resources/conf/processor-with-cs-flow-0.7.0.xml"); final String flow = IOUtils.toString(new FileInputStream(flowFile), StandardCharsets.UTF_8); @@ -335,8 +343,6 @@ public class TestFlowController { @Test(expected = FlowSynchronizationException.class) public void testSynchronizeFlowWithInvalidParameterContextReference() throws IOException { - final FlowSynchronizer standardFlowSynchronizer = new XmlFlowSynchronizer(nifiProperties, extensionManager); - final File flowFile = new File("src/test/resources/conf/parameter-context-flow-error.xml"); final String flow = IOUtils.toString(new FileInputStream(flowFile), StandardCharsets.UTF_8); @@ -353,8 +359,6 @@ public class TestFlowController { @Test public void testSynchronizeFlowWithNestedParameterContexts() throws IOException { - final FlowSynchronizer standardFlowSynchronizer = new XmlFlowSynchronizer(nifiProperties, extensionManager); - final File flowFile = new File("src/test/resources/conf/parameter-context-flow.xml"); final String flow = IOUtils.toString(new FileInputStream(flowFile), StandardCharsets.UTF_8); @@ -377,8 +381,6 @@ public class TestFlowController { @Test public void testCreateParameterContextWithAndWithoutValidation() throws IOException { - final FlowSynchronizer standardFlowSynchronizer = new XmlFlowSynchronizer(nifiProperties, extensionManager); - final File flowFile = new File("src/test/resources/conf/parameter-context-flow.xml"); final String flow = IOUtils.toString(new FileInputStream(flowFile), StandardCharsets.UTF_8); @@ -425,8 +427,6 @@ public class TestFlowController { @Test public void testSynchronizeFlowWhenAuthorizationsAreDifferentAndFlowEmpty() { - final FlowSynchronizer standardFlowSynchronizer = new XmlFlowSynchronizer(nifiProperties, extensionManager); - // create a mock proposed data flow with different auth fingerprint as the current authorizer final String authFingerprint = ""; final DataFlow proposedDataFlow = mock(DataFlow.class); @@ -442,8 +442,6 @@ public class TestFlowController { @Test public void testSynchronizeFlowWhenProposedAuthorizationsAreNull() throws IOException { - final FlowSynchronizer standardFlowSynchronizer = new XmlFlowSynchronizer(nifiProperties, extensionManager); - final File flowFile = new File("src/test/resources/conf/processor-with-cs-flow-0.7.0.xml"); final String flow = IOUtils.toString(new FileInputStream(flowFile), StandardCharsets.UTF_8); @@ -467,8 +465,6 @@ public class TestFlowController { @Test public void testSynchronizeFlowWhenProposedAuthorizationsAreNullAndEmptyFlow() { - final FlowSynchronizer standardFlowSynchronizer = new XmlFlowSynchronizer(nifiProperties, extensionManager); - final DataFlow proposedDataFlow = mock(DataFlow.class); when(proposedDataFlow.getAuthorizerFingerprint()).thenReturn(null); @@ -498,8 +494,6 @@ public class TestFlowController { @Test public void testSynchronizeFlowWhenCurrentAuthorizationsAreEmptyAndProposedAreNot() { - final FlowSynchronizer standardFlowSynchronizer = new XmlFlowSynchronizer(nifiProperties, extensionManager); - // create a mock proposed data flow with the same auth fingerprint as the current authorizer final String authFingerprint = authorizer.getFingerprint(); final DataFlow proposedDataFlow = mock(DataFlow.class); @@ -510,15 +504,13 @@ public class TestFlowController { controller.shutdown(true); controller = FlowController.createStandaloneInstance(flowFileEventRepo, nifiProperties, authorizer, - auditService, encryptor, bulletinRepo, variableRegistry, mock(FlowRegistryClient.class), extensionManager, statusHistoryRepository); + auditService, encryptor, bulletinRepo, variableRegistry, mock(FlowRegistryClient.class), extensionManager, statusHistoryRepository); controller.synchronize(standardFlowSynchronizer, proposedDataFlow, mock(FlowService.class), BundleUpdateStrategy.IGNORE_BUNDLE); assertEquals(authFingerprint, authorizer.getFingerprint()); } @Test public void testSynchronizeFlowWhenProposedMissingComponentsAreDifferent() { - final FlowSynchronizer standardFlowSynchronizer = new XmlFlowSynchronizer(nifiProperties, extensionManager); - final Set missingComponents = new HashSet<>(); missingComponents.add("1"); missingComponents.add("2"); @@ -536,9 +528,6 @@ public class TestFlowController { @Test public void testSynchronizeFlowWhenExistingMissingComponentsAreDifferent() throws IOException { - final PropertyEncryptor encryptor = PropertyEncryptorFactory.getPropertyEncryptor(nifiProperties); - final FlowSynchronizer standardFlowSynchronizer = new XmlFlowSynchronizer(nifiProperties, extensionManager); - final ProcessorNode mockProcessorNode = mock(ProcessorNode.class); when(mockProcessorNode.getIdentifier()).thenReturn("1"); when(mockProcessorNode.isExtensionMissing()).thenReturn(true); @@ -581,8 +570,6 @@ public class TestFlowController { @Test public void testSynchronizeFlowWhenBundlesAreSame() throws IOException { - final FlowSynchronizer standardFlowSynchronizer = new XmlFlowSynchronizer(nifiProperties, extensionManager); - final LogRepository logRepository = LogRepositoryFactory.getRepository("d89ada5d-35fb-44ff-83f1-4cc00b48b2df"); logRepository.removeAllObservers(); @@ -592,8 +579,6 @@ public class TestFlowController { @Test public void testSynchronizeFlowWhenBundlesAreDifferent() throws IOException { - final FlowSynchronizer standardFlowSynchronizer = new XmlFlowSynchronizer(nifiProperties, extensionManager); - final LogRepository logRepository = LogRepositoryFactory.getRepository("d89ada5d-35fb-44ff-83f1-4cc00b48b2df"); logRepository.removeAllObservers(); @@ -1184,4 +1169,103 @@ public class TestFlowController { assertEquals(1, controller.getFlowManager().getRootGroup().getControllerServices(false).size()); } -} + @Test + public void testSynchronizeNewJsonFlow() throws IOException { + final String authFingerprint = authorizer.getFingerprint(); + final String flow = getNewJsonFlow(); + + final DataFlow proposedDataFlow = new StandardDataFlow(flow.getBytes(StandardCharsets.UTF_8), + null, + authFingerprint.getBytes(StandardCharsets.UTF_8), + Collections.emptySet()); + + // following assertion asserts that VersionedFlowSynchronizer is used + assertFalse(proposedDataFlow.isXml()); + + controller.synchronize(standardFlowSynchronizer, proposedDataFlow, mock(FlowService.class), BundleUpdateStrategy.IGNORE_BUNDLE); + + // should be an empty dataflow + final Map componentCounts = controller.getFlowManager().getComponentCounts(); + + assertEquals(0, componentCounts.get("Processors").intValue()); + assertEquals(0, componentCounts.get("Controller Services").intValue()); + assertEquals(0, componentCounts.get("Reporting Tasks").intValue()); + assertEquals(0, componentCounts.get("Process Groups").intValue()); + assertEquals(0, componentCounts.get("Remote Process Groups").intValue()); + assertEquals(0, componentCounts.get("Local Input Ports").intValue()); + assertEquals(0, componentCounts.get("Local Output Ports").intValue()); + assertEquals(0, componentCounts.get("Public Input Ports").intValue()); + assertEquals(0, componentCounts.get("Public Output Ports").intValue()); + + assertNotNull(controller.getFlowManager().getRootGroup()); + } + + @Test + public void testSynchronizeJsonFlowMissingComponentIds() throws IOException { + final String authFingerprint = authorizer.getFingerprint(); + final File jsonFlowFile = new File("src/test/resources/conf/flow-json-missing-component-id.json"); + final String flow = IOUtils.toString(new FileInputStream(jsonFlowFile), StandardCharsets.UTF_8); + final DataFlow proposedDataFlow = new StandardDataFlow(flow.getBytes(StandardCharsets.UTF_8), + null, + authFingerprint.getBytes(StandardCharsets.UTF_8), + Collections.emptySet()); + + // following assertion asserts that VersionedFlowSynchronizer is used + assertFalse(proposedDataFlow.isXml()); + + controller.synchronize(standardFlowSynchronizer, proposedDataFlow, mock(FlowService.class), BundleUpdateStrategy.IGNORE_BUNDLE); + + final Map componentCounts = controller.getFlowManager().getComponentCounts(); + + assertEquals(2, componentCounts.get("Processors").intValue()); + assertEquals(0, componentCounts.get("Controller Services").intValue()); + assertEquals(0, componentCounts.get("Reporting Tasks").intValue()); + assertEquals(0, componentCounts.get("Process Groups").intValue()); + assertEquals(0, componentCounts.get("Remote Process Groups").intValue()); + assertEquals(0, componentCounts.get("Local Input Ports").intValue()); + assertEquals(0, componentCounts.get("Local Output Ports").intValue()); + assertEquals(0, componentCounts.get("Public Input Ports").intValue()); + assertEquals(0, componentCounts.get("Public Output Ports").intValue()); + } + + private String getNewJsonFlow() throws JsonProcessingException { + final VersionedDataflow versionedDataflow = new VersionedDataflow(); + + versionedDataflow.setEncodingVersion(new VersionedFlowEncodingVersion(2, 0)); + versionedDataflow.setMaxTimerDrivenThreadCount(10); + versionedDataflow.setRegistries(Collections.emptyList()); + versionedDataflow.setParameterContexts(Collections.emptyList()); + versionedDataflow.setControllerServices(Collections.emptyList()); + versionedDataflow.setReportingTasks(Collections.emptyList()); + versionedDataflow.setTemplates(Collections.emptySet()); + + final VersionedProcessGroup rootGroup = new VersionedProcessGroup(); + rootGroup.setIdentifier(UUID.randomUUID().toString()); + rootGroup.setInstanceIdentifier(UUID.randomUUID().toString()); + rootGroup.setName("NiFi Flow"); + rootGroup.setComments(""); + rootGroup.setPosition(new Position(0, 0)); + rootGroup.setProcessGroups(Collections.emptySet()); + rootGroup.setRemoteProcessGroups(Collections.emptySet()); + rootGroup.setProcessors(Collections.emptySet()); + rootGroup.setInputPorts(Collections.emptySet()); + rootGroup.setOutputPorts(Collections.emptySet()); + rootGroup.setConnections(Collections.emptySet()); + rootGroup.setLabels(Collections.emptySet()); + rootGroup.setFunnels(Collections.emptySet()); + rootGroup.setControllerServices(Collections.emptySet()); + rootGroup.setVariables(Collections.emptyMap()); + rootGroup.setDefaultFlowFileExpiration("0 sec"); + rootGroup.setDefaultBackPressureObjectThreshold(10000L); + rootGroup.setDefaultBackPressureDataSizeThreshold("1 GB"); + rootGroup.setFlowFileOutboundPolicy("STREAM_WHEN_AVAILABLE"); + rootGroup.setFlowFileConcurrency("UNBOUNDED"); + rootGroup.setComponentType(ComponentType.PROCESS_GROUP); + versionedDataflow.setRootGroup(rootGroup); + + final ObjectMapper mapper = new ObjectMapper(); + + final String jsonString = mapper.writeValueAsString(versionedDataflow); + return jsonString; + } +} \ No newline at end of file diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/resources/conf/flow-json-missing-component-id.json b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/resources/conf/flow-json-missing-component-id.json new file mode 100644 index 0000000000..107724948d --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/resources/conf/flow-json-missing-component-id.json @@ -0,0 +1,161 @@ +{ + "encodingVersion": { + "majorVersion": 2, + "minorVersion": 0 + }, + "maxTimerDrivenThreadCount": 10, + "registries": [], + "parameterContexts": [], + "controllerServices": [], + "reportingTasks": [], + "templates": [], + "rootGroup": { + "identifier": "2a2b649d-8538-3239-9965-536b5b993cc5", + "instanceIdentifier": "13c477b8-0182-1000-31df-454d42e70446", + "name": "NiFi Flow", + "comments": "", + "position": { + "x": 0, + "y": 0 + }, + "processGroups": [], + "remoteProcessGroups": [], + "processors": [ + { + "instanceIdentifier": "182b786c-0182-1000-9677-a42b98ed4f7c", + "name": "GenerateFlowFile", + "comments": "", + "position": { + "x": 356, + "y": 133 + }, + "type": "org.apache.nifi.processors.standard.GenerateFlowFile", + "bundle": { + "group": "org.apache.nifi", + "artifact": "nifi-standard-nar", + "version": "1.17.0-SNAPSHOT" + }, + "properties": { + "character-set": "UTF-8", + "File Size": "0B", + "generate-ff-custom-text": "test", + "Batch Size": "1", + "Unique FlowFiles": "false", + "Data Format": "Text" + }, + "propertyDescriptors": {}, + "style": {}, + "schedulingPeriod": "0 sec", + "schedulingStrategy": "TIMER_DRIVEN", + "executionNode": "ALL", + "penaltyDuration": "30 sec", + "yieldDuration": "1 sec", + "bulletinLevel": "WARN", + "runDurationMillis": 0, + "concurrentlySchedulableTaskCount": 1, + "autoTerminatedRelationships": [], + "scheduledState": "ENABLED", + "retryCount": 10, + "retriedRelationships": [], + "backoffMechanism": "PENALIZE_FLOWFILE", + "maxBackoffPeriod": "10 mins", + "componentType": "PROCESSOR", + "groupIdentifier": "2a2b649d-8538-3239-9965-536b5b993cc5" + }, + { + "identifier": "eb393564-ce84-3777-a8a8-f923dd912e2f", + "instanceIdentifier": "182bd303-0182-1000-d9ab-a31944d6b6f6", + "name": "LogAttribute", + "comments": "", + "position": { + "x": 360, + "y": 352 + }, + "type": "org.apache.nifi.processors.standard.LogAttribute", + "bundle": { + "group": "org.apache.nifi", + "artifact": "nifi-standard-nar", + "version": "1.17.0-SNAPSHOT" + }, + "properties": { + "character-set": "UTF-8", + "Log FlowFile Properties": "true", + "Log Level": "info", + "attributes-to-log-regex": ".*", + "Output Format": "Line per Attribute", + "Log Payload": "false" + }, + "propertyDescriptors": {}, + "style": {}, + "schedulingPeriod": "0 sec", + "schedulingStrategy": "TIMER_DRIVEN", + "executionNode": "ALL", + "penaltyDuration": "30 sec", + "yieldDuration": "1 sec", + "bulletinLevel": "WARN", + "runDurationMillis": 0, + "concurrentlySchedulableTaskCount": 1, + "autoTerminatedRelationships": [ + "success" + ], + "scheduledState": "ENABLED", + "retryCount": 10, + "retriedRelationships": [], + "backoffMechanism": "PENALIZE_FLOWFILE", + "maxBackoffPeriod": "10 mins", + "componentType": "PROCESSOR", + "groupIdentifier": "2a2b649d-8538-3239-9965-536b5b993cc5" + } + ], + "inputPorts": [], + "outputPorts": [], + "connections": [ + { + "identifier": "64a51da4-0263-3d40-ab64-055bee2a8856", + "instanceIdentifier": "182c0ec9-0182-1000-942b-c928cf2c52f2", + "name": "", + "source": { + "id": "365da014-6f15-3dbd-a8a0-3431923790ba", + "type": "PROCESSOR", + "groupId": "2a2b649d-8538-3239-9965-536b5b993cc5", + "name": "GenerateFlowFile", + "comments": "", + "instanceIdentifier": "182b786c-0182-1000-9677-a42b98ed4f7c" + }, + "destination": { + "id": "eb393564-ce84-3777-a8a8-f923dd912e2f", + "type": "PROCESSOR", + "groupId": "2a2b649d-8538-3239-9965-536b5b993cc5", + "name": "LogAttribute", + "comments": "", + "instanceIdentifier": "182bd303-0182-1000-d9ab-a31944d6b6f6" + }, + "labelIndex": 1, + "zIndex": 0, + "selectedRelationships": [ + "success" + ], + "backPressureObjectThreshold": 10000, + "backPressureDataSizeThreshold": "1 GB", + "flowFileExpiration": "0 sec", + "prioritizers": [], + "bends": [], + "loadBalanceStrategy": "DO_NOT_LOAD_BALANCE", + "partitioningAttribute": "", + "loadBalanceCompression": "DO_NOT_COMPRESS", + "componentType": "CONNECTION", + "groupIdentifier": "2a2b649d-8538-3239-9965-536b5b993cc5" + } + ], + "labels": [], + "funnels": [], + "controllerServices": [], + "variables": {}, + "defaultFlowFileExpiration": "0 sec", + "defaultBackPressureObjectThreshold": 10000, + "defaultBackPressureDataSizeThreshold": "1 GB", + "flowFileConcurrency": "UNBOUNDED", + "flowFileOutboundPolicy": "STREAM_WHEN_AVAILABLE", + "componentType": "PROCESS_GROUP" + } +} \ No newline at end of file diff --git a/nifi-registry/nifi-registry-core/nifi-registry-flow-diff/src/main/java/org/apache/nifi/registry/flow/diff/StandardFlowDifference.java b/nifi-registry/nifi-registry-core/nifi-registry-flow-diff/src/main/java/org/apache/nifi/registry/flow/diff/StandardFlowDifference.java index ec730bb472..c03480126e 100644 --- a/nifi-registry/nifi-registry-core/nifi-registry-flow-diff/src/main/java/org/apache/nifi/registry/flow/diff/StandardFlowDifference.java +++ b/nifi-registry/nifi-registry-core/nifi-registry-flow-diff/src/main/java/org/apache/nifi/registry/flow/diff/StandardFlowDifference.java @@ -89,8 +89,8 @@ public class StandardFlowDifference implements FlowDifference { @Override public int hashCode() { - return 31 + 17 * (componentA == null ? 0 : componentA.getIdentifier().hashCode()) + - 17 * (componentB == null ? 0 : componentB.getIdentifier().hashCode()) + + return 31 + 17 * (componentA == null ? 0 : Objects.hashCode(componentA.getIdentifier())) + + 17 * (componentB == null ? 0 : Objects.hashCode(componentB.getIdentifier())) + 15 * (componentA == null ? 0 : Objects.hash(componentA.getInstanceIdentifier())) + 15 * (componentB == null ? 0 : Objects.hash(componentB.getInstanceIdentifier())) + Objects.hash(description, type, valueA, valueB);