This is an automated email from the ASF dual-hosted git repository.

pvillard31 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 798809a0408 NIFI-14181 Allow FlowFile attributes in 
QuerySalesforceObject sObject Name and Field Names (#11756)
798809a0408 is described below

commit 798809a04082d29639645f5d098c7afa036691a2
Author: Nanjesh Ramesh <[email protected]>
AuthorDate: Tue Oct 6 07:35:35 2026 -0700

    NIFI-14181 Allow FlowFile attributes in QuerySalesforceObject sObject Name 
and Field Names (#11756)
    
    QuerySalesforceObject accepts an optional input FlowFile and already 
evaluates
    Custom WHERE Condition against it, but the sObject Name and Field Names
    properties were declared with ENVIRONMENT expression language scope and
    evaluated without a FlowFile. A value such as ${object} therefore resolved 
to an
    empty string, and the describe call to Salesforce failed with NOT_FOUND.
    
    Declare both properties with FLOWFILE_ATTRIBUTES scope and evaluate them 
against
    the input FlowFile. Without an input FlowFile the behavior is unchanged.
    
    Because the sObject can now differ between FlowFiles, store the Age Field
    timestamp per sObject (last_age_filter.<sObject name, lower case>) instead 
of as
    one value, so incremental queries work with a dynamic sObject Name. The age
    field name must exist on every queried object. State written by earlier
    versions under last_age_filter is used once as the starting point for the 
first
    sObject queried and is then replaced by the per sObject key.
    
    Also evaluate expression language in Age Delay, which was read without
    evaluation.
    
    Add tests for attribute based sObject Name and Field Names, for age filters
    kept separate per sObject, and for the migration of the previous state.
---
 .../salesforce/QuerySalesforceObject.java          |  18 +--
 .../salesforce/util/IncrementalContext.java        |  16 ++-
 .../additionalDetails.md                           |   7 +-
 .../salesforce/QuerySalesforceObjectTest.java      | 121 +++++++++++++++++++--
 4 files changed, 143 insertions(+), 19 deletions(-)

diff --git 
a/nifi-extension-bundles/nifi-salesforce-bundle/nifi-salesforce-processors/src/main/java/org/apache/nifi/processors/salesforce/QuerySalesforceObject.java
 
b/nifi-extension-bundles/nifi-salesforce-bundle/nifi-salesforce-processors/src/main/java/org/apache/nifi/processors/salesforce/QuerySalesforceObject.java
index ddc8c5d39c9..b3f451cd696 100644
--- 
a/nifi-extension-bundles/nifi-salesforce-bundle/nifi-salesforce-processors/src/main/java/org/apache/nifi/processors/salesforce/QuerySalesforceObject.java
+++ 
b/nifi-extension-bundles/nifi-salesforce-bundle/nifi-salesforce-processors/src/main/java/org/apache/nifi/processors/salesforce/QuerySalesforceObject.java
@@ -119,8 +119,9 @@ import static 
org.apache.nifi.processors.salesforce.util.CommonSalesforcePropert
         + " The processor can accept an optional input FlowFile and reference 
the FlowFile attributes in the query."
         + " When 'Include Deleted Records' is true, the processor will include 
deleted records (soft-deletes) in the results by using the 'queryAll' API."
         + " The 'IsDeleted' field will be automatically included in the 
results when querying deleted records.")
-@Stateful(scopes = Scope.CLUSTER, description = "When 'Age Field' is set, 
after performing a query the time of execution is stored. Subsequent queries 
will be augmented"
-        + " with an additional condition so that only records that are newer 
than the stored execution time (adjusted with the optional value of 'Age 
Delay') will be retrieved."
+@Stateful(scopes = Scope.CLUSTER, description = "When 'Age Field' is set, 
after performing a query the time of execution is stored for the queried 
sObject."
+        + " Subsequent queries of the same sObject will be augmented with an 
additional condition so that only records that are newer than the stored 
execution time"
+        + " (adjusted with the optional value of 'Age Delay') will be 
retrieved."
         + " State is stored across the cluster so that this Processor can be 
run on Primary Node only and if a new Primary Node is selected,"
         + " the new node can pick up where the previous node left off, without 
duplicating the data.")
 @WritesAttributes({
@@ -156,7 +157,7 @@ public class QuerySalesforceObject extends 
AbstractProcessor {
             .name("sObject Name")
             .description("The Salesforce sObject to be queried")
             .required(true)
-            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            
.expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
             .addValidator(StandardValidators.NON_BLANK_VALIDATOR)
             .dependsOn(QUERY_TYPE, PROPERTY_BASED_QUERY)
             .build();
@@ -165,7 +166,7 @@ public class QuerySalesforceObject extends 
AbstractProcessor {
             .name("Field Names")
             .description("Comma-separated list of field names requested from 
the sObject to be queried. When this field is left empty, all fields are 
queried.")
             .required(false)
-            .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+            
.expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
             .addValidator(StandardValidators.NON_BLANK_VALIDATOR)
             .dependsOn(QUERY_TYPE, PROPERTY_BASED_QUERY)
             .build();
@@ -395,15 +396,15 @@ public class QuerySalesforceObject extends 
AbstractProcessor {
 
     private void processQuery(ProcessContext context, ProcessSession session, 
FlowFile originalFlowFile) {
         AtomicReference<String> nextRecordsUrl = new AtomicReference<>();
-        String sObject = 
context.getProperty(SOBJECT_NAME).evaluateAttributeExpressions().getValue();
-        String fields = 
context.getProperty(FIELD_NAMES).evaluateAttributeExpressions().getValue();
+        String sObject = 
context.getProperty(SOBJECT_NAME).evaluateAttributeExpressions(originalFlowFile).getValue();
+        String fields = 
context.getProperty(FIELD_NAMES).evaluateAttributeExpressions(originalFlowFile).getValue();
         String customWhereClause = 
context.getProperty(CUSTOM_WHERE_CONDITION).evaluateAttributeExpressions(originalFlowFile).getValue();
         RecordSetWriterFactory writerFactory = 
context.getProperty(RECORD_WRITER).asControllerService(RecordSetWriterFactory.class);
         boolean createZeroRecordFlowFiles = 
context.getProperty(CREATE_ZERO_RECORD_FILES).asBoolean();
         boolean includeDeletedRecords = 
context.getProperty(INCLUDE_DELETED_RECORDS).asBoolean();
 
         StateMap state = getState(session);
-        IncrementalContext incrementalContext = new 
IncrementalContext(context, state);
+        IncrementalContext incrementalContext = new 
IncrementalContext(context, state, sObject);
         SalesforceSchemaHolder salesForceSchemaHolder = 
getConvertedSalesforceSchema(sObject, fields, includeDeletedRecords);
 
         if (StringUtils.isBlank(fields)) {
@@ -479,7 +480,8 @@ public class QuerySalesforceObject extends 
AbstractProcessor {
 
                 if (incrementalContext.getAgeFilterUpper() != null) {
                     Map<String, String> newState = new 
HashMap<>(state.toMap());
-                    newState.put(LAST_AGE_FILTER, 
incrementalContext.getAgeFilterUpper());
+                    newState.remove(LAST_AGE_FILTER);
+                    newState.put(incrementalContext.getAgeFilterStateKey(), 
incrementalContext.getAgeFilterUpper());
                     updateState(session, newState);
                 }
             } catch (Exception e) {
diff --git 
a/nifi-extension-bundles/nifi-salesforce-bundle/nifi-salesforce-processors/src/main/java/org/apache/nifi/processors/salesforce/util/IncrementalContext.java
 
b/nifi-extension-bundles/nifi-salesforce-bundle/nifi-salesforce-processors/src/main/java/org/apache/nifi/processors/salesforce/util/IncrementalContext.java
index 207cf9b713b..0c93f7bad55 100644
--- 
a/nifi-extension-bundles/nifi-salesforce-bundle/nifi-salesforce-processors/src/main/java/org/apache/nifi/processors/salesforce/util/IncrementalContext.java
+++ 
b/nifi-extension-bundles/nifi-salesforce-bundle/nifi-salesforce-processors/src/main/java/org/apache/nifi/processors/salesforce/util/IncrementalContext.java
@@ -23,6 +23,7 @@ import 
org.apache.nifi.processors.salesforce.QuerySalesforceObject;
 import java.time.OffsetDateTime;
 import java.time.format.DateTimeFormatter;
 import java.time.temporal.ChronoUnit;
+import java.util.Locale;
 import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 
@@ -32,8 +33,11 @@ public class IncrementalContext {
     private final String initialAgeFilter;
     private final String ageFilterUpper;
     private final String ageFilterLower;
+    private final String ageFilterStateKey;
+
+    public IncrementalContext(ProcessContext context, StateMap state, String 
sObject) {
+        ageFilterStateKey = QuerySalesforceObject.LAST_AGE_FILTER + "." + 
sObject.toLowerCase(Locale.ROOT);
 
-    public IncrementalContext(ProcessContext context, StateMap state) {
         ageField = 
context.getProperty(QuerySalesforceObject.AGE_FIELD).evaluateAttributeExpressions().getValue();
 
         if (ageField == null) {
@@ -42,8 +46,10 @@ public class IncrementalContext {
             ageFilterUpper = null;
         } else {
             initialAgeFilter = 
context.getProperty(QuerySalesforceObject.INITIAL_AGE_FILTER).evaluateAttributeExpressions().getValue();
-            ageFilterLower = state.get(QuerySalesforceObject.LAST_AGE_FILTER);
-            Optional<Long> ageDelayMs = 
Optional.ofNullable(context.getProperty(QuerySalesforceObject.AGE_DELAY).asTimePeriod(TimeUnit.MILLISECONDS));
+            // State written before the age filter was tracked per sObject is 
kept under the plain key and is picked up once
+            ageFilterLower = Optional.ofNullable(state.get(ageFilterStateKey))
+                    .orElseGet(() -> 
state.get(QuerySalesforceObject.LAST_AGE_FILTER));
+            Optional<Long> ageDelayMs = 
Optional.ofNullable(context.getProperty(QuerySalesforceObject.AGE_DELAY).evaluateAttributeExpressions().asTimePeriod(TimeUnit.MILLISECONDS));
             OffsetDateTime ageFilterUpperTime = ageDelayMs
                     .map(delay -> OffsetDateTime.now().minus(delay, 
ChronoUnit.MILLIS))
                     .orElse(OffsetDateTime.now());
@@ -66,4 +72,8 @@ public class IncrementalContext {
     public String getAgeFilterLower() {
         return ageFilterLower;
     }
+
+    public String getAgeFilterStateKey() {
+        return ageFilterStateKey;
+    }
 }
diff --git 
a/nifi-extension-bundles/nifi-salesforce-bundle/nifi-salesforce-processors/src/main/resources/docs/org.apache.nifi.processors.salesforce.QuerySalesforceObject/additionalDetails.md
 
b/nifi-extension-bundles/nifi-salesforce-bundle/nifi-salesforce-processors/src/main/resources/docs/org.apache.nifi.processors.salesforce.QuerySalesforceObject/additionalDetails.md
index 273eb4289c0..89fe88f3338 100644
--- 
a/nifi-extension-bundles/nifi-salesforce-bundle/nifi-salesforce-processors/src/main/resources/docs/org.apache.nifi.processors.salesforce.QuerySalesforceObject/additionalDetails.md
+++ 
b/nifi-extension-bundles/nifi-salesforce-bundle/nifi-salesforce-processors/src/main/resources/docs/org.apache.nifi.processors.salesforce.QuerySalesforceObject/additionalDetails.md
@@ -56,4 +56,9 @@ than the previous run time but earlier than the current run 
time.
 The processor uses the Initial Age Filter as a specific timestamp that sets 
the beginning of the sliding window from
 which processing builds the initial query. The format must adhere to the 
Salesforce SOQL standards (see Salesforce
 documentation). The Age Delay moves the time of the records to be processed 
earlier than the current run time if
-necessary.
\ No newline at end of file
+necessary.
+
+The 'sObject Name' and 'Field Names' properties can reference attributes of an 
incoming flowfile. When the Age Field
+property is set, the processor stores the end of the last time window 
separately for each queried sObject, so the same
+processor can incrementally query several objects. The Age Field must be a 
datetime field that exists on every object
+that is queried, as the name is shared across them.
\ No newline at end of file
diff --git 
a/nifi-extension-bundles/nifi-salesforce-bundle/nifi-salesforce-processors/src/test/java/org/apache/nifi/processors/salesforce/QuerySalesforceObjectTest.java
 
b/nifi-extension-bundles/nifi-salesforce-bundle/nifi-salesforce-processors/src/test/java/org/apache/nifi/processors/salesforce/QuerySalesforceObjectTest.java
index e45c0817e5f..a649624023a 100644
--- 
a/nifi-extension-bundles/nifi-salesforce-bundle/nifi-salesforce-processors/src/test/java/org/apache/nifi/processors/salesforce/QuerySalesforceObjectTest.java
+++ 
b/nifi-extension-bundles/nifi-salesforce-bundle/nifi-salesforce-processors/src/test/java/org/apache/nifi/processors/salesforce/QuerySalesforceObjectTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.nifi.processors.salesforce;
 
+import org.apache.nifi.components.state.Scope;
 import org.apache.nifi.oauth2.OAuth2AccessTokenProvider;
 import org.apache.nifi.processors.salesforce.rest.SalesforceConfiguration;
 import org.apache.nifi.processors.salesforce.rest.SalesforceRestClient;
@@ -28,6 +29,7 @@ import org.apache.nifi.util.TestRunners;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
@@ -37,11 +39,18 @@ import java.time.OffsetTime;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
 import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 @ExtendWith(MockitoExtension.class)
@@ -49,6 +58,8 @@ class QuerySalesforceObjectTest {
 
     private static final DateTimeFormatter DATE_TIME_FORMATTER = 
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX");
     private static final DateTimeFormatter TIME_FORMATTER = 
DateTimeFormatter.ofPattern("HH:mm:ss.SSSX");
+    private static final String AGE_FIELD = "LastModifiedDate";
+    private static final String ACCOUNT_STATE_KEY = 
QuerySalesforceObject.LAST_AGE_FILTER + ".account";
 
     private TestRunner testRunner;
 
@@ -86,13 +97,7 @@ class QuerySalesforceObjectTest {
         String sObjectName = "TestObject";
         testRunner.setProperty(QuerySalesforceObject.SOBJECT_NAME, 
sObjectName);
 
-        String recordWriterServiceId = "record_writer_service";
-        RecordSetWriterFactory recordWriterService = 
MockCsvRecordWriter.builder()
-                .quoteValues(false)
-                .build();
-        testRunner.addControllerService(recordWriterServiceId, 
recordWriterService);
-        testRunner.enableControllerService(recordWriterService);
-        testRunner.setProperty(QuerySalesforceObject.RECORD_WRITER, 
recordWriterServiceId);
+        setUpRecordWriter();
 
         
when(mockSalesforceRestClient.describeSObject(sObjectName)).thenReturn(getResourceAsStream("query/describe_sobject.json"));
         
when(mockSalesforceRestClient.query(any())).thenReturn(getResourceAsStream("query/query_sobject.json"));
@@ -125,6 +130,108 @@ class QuerySalesforceObjectTest {
         assertEquals(expectedTime, fields[3].trim());
     }
 
+    @Test
+    void testSObjectNameEvaluatedWithFlowFileAttributes() throws Exception {
+        String sObjectName = "TestObject";
+        testRunner.setProperty(QuerySalesforceObject.SOBJECT_NAME, 
"${object}");
+        setUpRecordWriter();
+
+        
when(mockSalesforceRestClient.describeSObject(sObjectName)).thenReturn(getResourceAsStream("query/describe_sobject.json"));
+        
when(mockSalesforceRestClient.query(any())).thenReturn(getResourceAsStream("query/query_sobject.json"));
+
+        testRunner.enqueue("", Map.of("object", sObjectName));
+        testRunner.run();
+
+        testRunner.assertTransferCount(QuerySalesforceObject.REL_SUCCESS, 1);
+        testRunner.assertTransferCount(QuerySalesforceObject.REL_ORIGINAL, 1);
+        testRunner.assertTransferCount(QuerySalesforceObject.REL_FAILURE, 0);
+        verify(mockSalesforceRestClient).describeSObject(sObjectName);
+    }
+
+    @Test
+    void testFieldNamesEvaluatedWithFlowFileAttributes() throws Exception {
+        testRunner.setProperty(QuerySalesforceObject.SOBJECT_NAME, 
"TestObject");
+        testRunner.setProperty(QuerySalesforceObject.FIELD_NAMES, "${fields}");
+        setUpRecordWriter();
+
+        
when(mockSalesforceRestClient.describeSObject("TestObject")).thenReturn(getResourceAsStream("query/describe_sobject.json"));
+        
when(mockSalesforceRestClient.query(any())).thenReturn(getResourceAsStream("query/query_sobject.json"));
+
+        testRunner.enqueue("", Map.of("fields", "id__c,date__c"));
+        testRunner.run();
+
+        ArgumentCaptor<String> query = ArgumentCaptor.forClass(String.class);
+        verify(mockSalesforceRestClient).query(query.capture());
+        assertEquals("SELECT id__c,date__c FROM TestObject", query.getValue());
+        testRunner.assertTransferCount(QuerySalesforceObject.REL_SUCCESS, 1);
+    }
+
+    @Test
+    void testAgeFilterStoredSeparatelyForEachSObject() throws Exception {
+        testRunner.setProperty(QuerySalesforceObject.SOBJECT_NAME, 
"${object}");
+        testRunner.setProperty(QuerySalesforceObject.AGE_FIELD, AGE_FIELD);
+        setUpRecordWriter();
+
+        
when(mockSalesforceRestClient.describeSObject(any())).thenAnswer(invocation -> 
getResourceAsStream("query/describe_sobject.json"));
+        when(mockSalesforceRestClient.query(any())).thenAnswer(invocation -> 
getResourceAsStream("query/query_sobject.json"));
+
+        testRunner.enqueue("", Map.of("object", "Account"));
+        testRunner.run();
+        String accountAgeFilter = 
testRunner.getStateManager().getState(Scope.CLUSTER).get(ACCOUNT_STATE_KEY);
+        assertNotNull(accountAgeFilter);
+
+        testRunner.enqueue("", Map.of("object", "Contact"));
+        testRunner.run();
+
+        testRunner.enqueue("", Map.of("object", "Account"));
+        testRunner.run();
+
+        ArgumentCaptor<String> queries = ArgumentCaptor.forClass(String.class);
+        verify(mockSalesforceRestClient, times(3)).query(queries.capture());
+        List<String> executedQueries = queries.getAllValues();
+
+        assertFalse(executedQueries.get(0).contains(AGE_FIELD + " >= "));
+        assertFalse(executedQueries.get(1).contains(AGE_FIELD + " >= "), 
"Contact must not use the age filter stored for Account");
+        assertTrue(executedQueries.get(2).contains(AGE_FIELD + " >= " + 
accountAgeFilter));
+
+        Map<String, String> state = 
testRunner.getStateManager().getState(Scope.CLUSTER).toMap();
+        assertNotNull(state.get(ACCOUNT_STATE_KEY));
+        assertNotNull(state.get(QuerySalesforceObject.LAST_AGE_FILTER + 
".contact"));
+        assertNull(state.get(QuerySalesforceObject.LAST_AGE_FILTER));
+    }
+
+    @Test
+    void testAgeFilterStoredWithoutSObjectIsMigrated() throws Exception {
+        String previousAgeFilter = "2020-01-01T00:00:00.000+0000";
+        testRunner.setProperty(QuerySalesforceObject.SOBJECT_NAME, "Account");
+        testRunner.setProperty(QuerySalesforceObject.AGE_FIELD, AGE_FIELD);
+        setUpRecordWriter();
+        
testRunner.getStateManager().setState(Map.of(QuerySalesforceObject.LAST_AGE_FILTER,
 previousAgeFilter), Scope.CLUSTER);
+
+        
when(mockSalesforceRestClient.describeSObject("Account")).thenReturn(getResourceAsStream("query/describe_sobject.json"));
+        
when(mockSalesforceRestClient.query(any())).thenReturn(getResourceAsStream("query/query_sobject.json"));
+
+        testRunner.run();
+
+        ArgumentCaptor<String> query = ArgumentCaptor.forClass(String.class);
+        verify(mockSalesforceRestClient).query(query.capture());
+        assertTrue(query.getValue().contains(AGE_FIELD + " >= " + 
previousAgeFilter));
+
+        Map<String, String> state = 
testRunner.getStateManager().getState(Scope.CLUSTER).toMap();
+        assertNotNull(state.get(ACCOUNT_STATE_KEY));
+        assertNull(state.get(QuerySalesforceObject.LAST_AGE_FILTER));
+    }
+
+    private void setUpRecordWriter() throws Exception {
+        String recordWriterServiceId = "record_writer_service";
+        RecordSetWriterFactory recordWriterService = 
MockCsvRecordWriter.builder()
+                .quoteValues(false)
+                .build();
+        testRunner.addControllerService(recordWriterServiceId, 
recordWriterService);
+        testRunner.enableControllerService(recordWriterService);
+        testRunner.setProperty(QuerySalesforceObject.RECORD_WRITER, 
recordWriterServiceId);
+    }
+
     private InputStream getResourceAsStream(String resourceName) {
         return getClass().getClassLoader().getResourceAsStream(resourceName);
     }

Reply via email to