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

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


The following commit(s) were added to refs/heads/main by this push:
     new 79cfa6aa70 Fixes #8993 : Run CALL { } IN TRANSACTIONS in auto-commit 
on Neo4j (#8994)
79cfa6aa70 is described below

commit 79cfa6aa709e79f39d6938cee60062607c2e38f0
Author: Bart Maertens <[email protected]>
AuthorDate: Mon Oct 19 09:25:45 2026 +0200

    Fixes #8993 : Run CALL { } IN TRANSACTIONS in auto-commit on Neo4j (#8994)
---
 .../pages/pipeline/transforms/graph-query.adoc     |  20 ++++
 .../ROOT/pages/workflow/actions/graph-script.adoc  |   9 ++
 .../neo4j/actions/cypherscript/CypherScript.java   |  82 +++++++++++++---
 .../apache/hop/neo4j/bolt/Neo4jGraphDialect.java   | 106 +++++++++++++++++++++
 .../apache/hop/neo4j/transforms/cypher/Cypher.java |  47 ++++++++-
 .../cypherscript/CypherScriptTransactionTest.java  |  92 +++++++++++++++++-
 .../hop/neo4j/bolt/BoltGraphDialectTest.java       |  53 +++++++++++
 .../neo4j/transforms/cypher/CypherRetryTest.java   | 100 +++++++++++++++++++
 8 files changed, 491 insertions(+), 18 deletions(-)

diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/graph-query.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/graph-query.adoc
index 0938834f07..84ef1d97c0 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/graph-query.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/graph-query.adoc
@@ -81,3 +81,23 @@ Give a return value the type `Vector` to read a stored 
embedding into a Vector f
 On Gremlin servers a traversal returns a row per result: maps, from 
`project()` or `elementMap()` for example, are rows of their own, anything else 
is a value called `result`.
 
 |===
+
+== Transactions
+
+The statements of a batch of input rows, or the UNWIND statement of a batch, 
run in one transaction.
+
+On Neo4j, a statement with `CALL { ... } IN TRANSACTIONS` (or `USING PERIODIC 
COMMIT` on Neo4j 4) runs on its own in an auto-commit transaction: Neo4j 
commits its batches of rows itself.
+This is the alternative to `apoc.periodic.iterate`.
+With UNWIND, the transform passes a batch of input rows and Neo4j commits them 
in smaller batches:
+
+[source,cypher]
+----
+UNWIND $rows AS row
+CALL (row) {
+  MERGE (c:Customer { id : row.id })
+  SET c.name = row.name
+} IN TRANSACTIONS OF 1000 ROWS
+----
+
+When such a statement fails, the batches Neo4j committed before the error stay 
committed.
+With retries configured, a retry runs the whole statement again and applies 
those batches a second time: use MERGE rather than CREATE to make the statement 
safe to repeat, or don't configure retries.
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/workflow/actions/graph-script.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/workflow/actions/graph-script.adoc
index 07aeafbe23..87399be7a3 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/workflow/actions/graph-script.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/workflow/actions/graph-script.adoc
@@ -34,6 +34,15 @@ The result of this action is a `true` or `false` exit code 
that determines if a
 
 **To allow you to enter semi-colons (;) in commands, strings and so on we 
require you to separate commands with a semi-colon on a new line**
 
+== Transactions
+
+On a database with transactions the script runs in one transaction: when a 
statement fails, none of the statements are kept.
+
+On Neo4j, a statement with `CALL { ... } IN TRANSACTIONS` (or `USING PERIODIC 
COMMIT` on Neo4j 4) runs on its own in an auto-commit transaction, and the 
statements before and after it run in a transaction each.
+The script then no longer runs in a single transaction: when a statement 
fails, the statements before it stay committed.
+
+On Memgraph every statement runs on its own in an auto-commit transaction.
+
 == Configuration options
 
 |===
diff --git 
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/actions/cypherscript/CypherScript.java
 
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/actions/cypherscript/CypherScript.java
index 076088eaef..09f4bbec06 100644
--- 
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/actions/cypherscript/CypherScript.java
+++ 
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/actions/cypherscript/CypherScript.java
@@ -95,23 +95,22 @@ public class CypherScript extends ActionBase implements 
IAction {
     try (IGraphConnection connection = 
graphConnection.connect(getLogChannel(), this)) {
       IGraphDialect dialect = connection.getGraphDialect();
       if (dialect.isSupportingSchemaChangesInTransactions()
+          && connection.isSupportingTransactions()
+          && statements.stream().anyMatch(dialect::isRequiringAutoCommit)) {
+        // Statements like CALL { ... } IN TRANSACTIONS can't run in an 
explicit transaction
+        //
+        if (isBasic()) {
+          logBasic(
+              "Some statements run on their own in auto-commit transactions: 
the script doesn't"
+                  + " run in a single transaction");
+        }
+        nrExecuted = executeAroundAutoCommit(connection, dialect, statements, 
result);
+      } else if (dialect.isSupportingSchemaChangesInTransactions()
           && connection.isSupportingTransactions()) {
         try {
-          nrExecuted =
-              connection.executeWrite(
-                  transaction -> {
-                    int executed = 0;
-                    for (String cypher : statements) {
-                      transaction.execute(cypher, Map.of());
-                      executed++;
-                      if (isDetailed()) {
-                        logDetailed("Executed cypher statement: " + cypher);
-                      }
-                    }
-                    // The transaction is committed when this work returns, 
and rolled back when it
-                    // throws: a failed script keeps none of its statements.
-                    return executed;
-                  });
+          // The transaction is committed when the work returns, and rolled 
back when it throws: a
+          // failed script keeps none of its statements.
+          nrExecuted = executeInTransaction(connection, statements);
         } catch (Exception e) {
           logError("Error executing cypher statements, the transaction is 
rolled back", e);
           result.setNrErrors(1);
@@ -211,6 +210,59 @@ public class CypherScript extends ActionBase implements 
IAction {
     this.replacingVariables = replacingVariables;
   }
 
+  /** Run the statements in one transaction, none if there are no statements. 
*/
+  private int executeInTransaction(IGraphConnection connection, List<String> 
statements)
+      throws HopException {
+    if (statements.isEmpty()) {
+      return 0;
+    }
+    return connection.executeWrite(
+        transaction -> {
+          int executed = 0;
+          for (String cypher : statements) {
+            transaction.execute(cypher, Map.of());
+            executed++;
+            if (isDetailed()) {
+              logDetailed("Executed cypher statement: " + cypher);
+            }
+          }
+          return executed;
+        });
+  }
+
+  /**
+   * Run the statements which need an auto-commit transaction on their own, 
and the statements
+   * between them in one transaction each, in the order of the script. An 
error stops the script:
+   * what ran before the failed statement or transaction stays committed.
+   */
+  private int executeAroundAutoCommit(
+      IGraphConnection connection, IGraphDialect dialect, List<String> 
statements, Result result) {
+    int executed = 0;
+    List<String> inTransaction = new ArrayList<>();
+    try {
+      for (String cypher : statements) {
+        if (dialect.isRequiringAutoCommit(cypher)) {
+          executed += executeInTransaction(connection, inTransaction);
+          inTransaction.clear();
+          connection.execute(cypher, Map.of());
+          executed++;
+          if (isDetailed()) {
+            logDetailed("Executed cypher statement in an auto-commit 
transaction: " + cypher);
+          }
+        } else {
+          inTransaction.add(cypher);
+        }
+      }
+      executed += executeInTransaction(connection, inTransaction);
+    } catch (Exception e) {
+      logError(
+          "Error executing cypher statements, the statements before the error 
stay committed", e);
+      result.setNrErrors(1);
+      result.setResult(false);
+    }
+    return executed;
+  }
+
   private int executeAutoCommit(
       IGraphConnection connection, List<String> statements, Result result) {
     int executed = 0;
diff --git 
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/bolt/Neo4jGraphDialect.java
 
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/bolt/Neo4jGraphDialect.java
index 6d7fef8b24..33ff7ee31c 100644
--- 
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/bolt/Neo4jGraphDialect.java
+++ 
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/bolt/Neo4jGraphDialect.java
@@ -19,6 +19,8 @@ package org.apache.hop.neo4j.bolt;
 
 import java.util.List;
 import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.graph.GraphIndex;
 import org.apache.hop.core.graph.GraphSchema;
@@ -29,10 +31,114 @@ public class Neo4jGraphDialect extends BoltGraphDialect {
 
   public static final Neo4jGraphDialect INSTANCE = new Neo4jGraphDialect();
 
+  /** A CALL keyword, not a property or a part of a longer name. */
+  private static final Pattern CALL =
+      Pattern.compile("(?<![\\w.$])CALL\\b", Pattern.CASE_INSENSITIVE);
+
+  /** What follows the subquery of CALL { ... } IN [n] [CONCURRENT] 
TRANSACTIONS. */
+  private static final Pattern IN_TRANSACTIONS =
+      Pattern.compile(
+          "\\s*IN\\s+(?:[^\\s{}()]+\\s+)?(?:CONCURRENT\\s+)?TRANSACTIONS\\b",
+          Pattern.CASE_INSENSITIVE);
+
+  /** USING PERIODIC COMMIT, before LOAD CSV in Neo4j 4. */
+  private static final Pattern PERIODIC_COMMIT =
+      Pattern.compile("(?<![\\w.$])USING\\s+PERIODIC\\s+COMMIT\\b", 
Pattern.CASE_INSENSITIVE);
+
   public Neo4jGraphDialect() {
     super("NEO4J");
   }
 
+  /**
+   * Neo4j only runs CALL { ... } IN TRANSACTIONS and USING PERIODIC COMMIT in 
an implicit
+   * transaction: they commit batches of rows themselves.
+   */
+  @Override
+  public boolean isRequiringAutoCommit(String statement) {
+    if (statement == null) {
+      return false;
+    }
+    String code = blankLiteralsAndComments(statement);
+    return PERIODIC_COMMIT.matcher(code).find() || isCallInTransactions(code);
+  }
+
+  /** True if the code has a CALL subquery followed by IN TRANSACTIONS. */
+  private static boolean isCallInTransactions(String code) {
+    Matcher call = CALL.matcher(code);
+    while (call.find()) {
+      int index = skipWhitespace(code, call.end());
+      // The variables of the subquery: CALL (n, m) { ... } or CALL (*) { ... }
+      if (index < code.length() && code.charAt(index) == '(') {
+        index = skipWhitespace(code, closingIndex(code, index, '(', ')') + 1);
+      }
+      if (index >= code.length() || code.charAt(index) != '{') {
+        // A procedure call
+        continue;
+      }
+      int end = closingIndex(code, index, '{', '}');
+      if (end < code.length()
+          && IN_TRANSACTIONS.matcher(code).region(end + 1, 
code.length()).lookingAt()) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private static int skipWhitespace(String code, int index) {
+    while (index < code.length() && 
Character.isWhitespace(code.charAt(index))) {
+      index++;
+    }
+    return index;
+  }
+
+  /** The index of the bracket which closes the one at the index, the length 
if there is none. */
+  private static int closingIndex(String code, int index, char open, char 
close) {
+    int depth = 0;
+    for (int i = index; i < code.length(); i++) {
+      char c = code.charAt(i);
+      if (c == open) {
+        depth++;
+      } else if (c == close && --depth == 0) {
+        return i;
+      }
+    }
+    return code.length();
+  }
+
+  /**
+   * The statement with the string literals, quoted names and comments 
replaced by spaces, so that
+   * the words and brackets in them don't count.
+   */
+  static String blankLiteralsAndComments(String statement) {
+    StringBuilder code = new StringBuilder(statement.length());
+    int i = 0;
+    while (i < statement.length()) {
+      char c = statement.charAt(i);
+      int end;
+      if (c == '\'' || c == '"' || c == '`') {
+        end = i + 1;
+        while (end < statement.length() && statement.charAt(end) != c) {
+          // A backslash escapes the next character in a string, not in a 
quoted name
+          end += c != '`' && statement.charAt(end) == '\\' ? 2 : 1;
+        }
+        end = Math.min(end + 1, statement.length());
+      } else if (statement.startsWith("//", i)) {
+        int newline = statement.indexOf('\n', i);
+        end = newline < 0 ? statement.length() : newline;
+      } else if (statement.startsWith("/*", i)) {
+        int close = statement.indexOf("*/", i + 2);
+        end = close < 0 ? statement.length() : close + 2;
+      } else {
+        code.append(c);
+        i++;
+        continue;
+      }
+      code.append(" ".repeat(end - i));
+      i = end;
+    }
+    return code.toString();
+  }
+
   @Override
   public List<GraphIndex> getIndexes(IGraphConnection connection) throws 
HopException {
     try {
diff --git 
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/cypher/Cypher.java
 
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/cypher/Cypher.java
index 8e831921bc..e90f4ddc09 100644
--- 
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/cypher/Cypher.java
+++ 
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/cypher/Cypher.java
@@ -481,12 +481,53 @@ public class Cypher extends BaseTransform<CypherMeta, 
CypherData> {
           throw new HopException(
               "Unable to execute cypher statement '" + 
cypherStatement.getCypher() + "'", e);
         }
-        logBasic("Retrying after attempt #" + (attempt + 1) + " with error : " 
+ e.getMessage());
+        logAutoCommitRetry(attempt, e);
       }
     }
     flushOutputRows();
   }
 
+  /**
+   * Run the unwind statement in an auto-commit transaction, with the 
configured retries. A
+   * statement like CALL { ... } IN TRANSACTIONS commits its own batches of 
rows.
+   */
+  private void writeAutoCommitUnwindList(Map<String, Object> unwindMap) throws 
HopException {
+    beginWork();
+    for (int attempt = 0; attempt < data.attempts; attempt++) {
+      try {
+        startAttempt();
+        Result result = data.session.run(data.cypher, unwindMap);
+        getResultRows(result, new Object[0], true);
+        // Wait for the end of the statement: an error in one of its batches 
is raised here
+        result.consume();
+        break;
+      } catch (Exception e) {
+        dropAttemptRows();
+        if (attempt + 1 >= data.attempts) {
+          throw new HopException("Unexpected error writing unwind list to 
Neo4j", e);
+        }
+        logAutoCommitRetry(attempt, e);
+      }
+    }
+    flushOutputRows();
+    setLinesOutput(getLinesOutput() + data.unwindList.size());
+    data.unwindList.clear();
+    data.outputCount = 0;
+  }
+
+  /**
+   * Log the retry of a statement in an auto-commit transaction. A statement 
which commits its own
+   * batches, like CALL { ... } IN TRANSACTIONS, keeps the batches committed 
before the error.
+   */
+  private void logAutoCommitRetry(int attempt, Exception e) {
+    logBasic("Retrying after attempt #" + (attempt + 1) + " with error : " + 
e.getMessage());
+    if (!meta.isReadOnly()) {
+      logBasic(
+          "Warning: the statement runs in an auto-commit transaction. Batches 
it committed before"
+              + " the error stay committed and the retry applies them again");
+    }
+  }
+
   private List<Object[]> writeUnwindList() throws HopException {
     if (data.graphConnection != null) {
       writeGenericUnwindList();
@@ -494,6 +535,10 @@ public class Cypher extends BaseTransform<CypherMeta, 
CypherData> {
     }
     HashMap<String, Object> unwindMap = new HashMap<>();
     unwindMap.put(data.unwindMapName, data.unwindList);
+    if (data.neoConnection.getDialect().isRequiringAutoCommit(data.cypher)) {
+      writeAutoCommitUnwindList(unwindMap);
+      return null;
+    }
     List<Object[]> resultRows = null;
     CypherTransactionWork cypherTransactionWork =
         new CypherTransactionWork(this, new Object[0], true, data.cypher, 
unwindMap);
diff --git 
a/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/actions/cypherscript/CypherScriptTransactionTest.java
 
b/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/actions/cypherscript/CypherScriptTransactionTest.java
index 22e184b4af..03000df0d7 100644
--- 
a/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/actions/cypherscript/CypherScriptTransactionTest.java
+++ 
b/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/actions/cypherscript/CypherScriptTransactionTest.java
@@ -91,15 +91,96 @@ class CypherScriptTransactionTest {
     assertTrue(database.committed);
   }
 
+  @Test
+  void autoCommitStatementRunsOnItsOwn() throws Exception {
+    TransactionalDatabase database = new TransactionalDatabase(new 
AutoCommitDialect());
+    CypherScript action = action(database);
+    action.setScript("CREATE (:A)\n;\nCREATE (:B)\n;\nAUTO\n;\nCREATE (:C)");
+
+    Result result = action.execute(new Result(), 0);
+
+    assertTrue(result.getResult());
+    assertEquals(0, result.getNrErrors());
+    assertEquals(List.of("CREATE (:A)", "CREATE (:B)", "AUTO", "CREATE (:C)"), 
database.executed);
+    assertEquals(List.of("AUTO"), database.autoCommitted);
+    // The statements before and after the auto-commit statement: one 
transaction each
+    assertEquals(2, database.commits);
+  }
+
+  @Test
+  void failureAfterAutoCommitKeepsWhatRanBefore() throws Exception {
+    TransactionalDatabase database = new TransactionalDatabase(new 
AutoCommitDialect());
+    CypherScript action = action(database);
+    action.setScript("CREATE (:A)\n;\nAUTO\n;\nFAIL\n;\nCREATE (:B)");
+
+    Result result = action.execute(new Result(), 0);
+
+    assertFalse(result.getResult());
+    assertEquals(1, result.getNrErrors());
+    assertEquals(List.of("CREATE (:A)", "AUTO", "FAIL"), database.executed);
+    assertEquals(List.of("AUTO"), database.autoCommitted);
+    assertEquals(1, database.commits);
+    assertTrue(
+        database.rolledBack, "The transaction with the failed statement was 
not rolled back");
+  }
+
+  @Test
+  void scriptWithoutAutoCommitStatementsKeepsOneTransaction() throws Exception 
{
+    TransactionalDatabase database = new TransactionalDatabase(new 
AutoCommitDialect());
+    CypherScript action = action(database);
+    action.setScript("CREATE (:A)\n;\nCREATE (:B)");
+
+    Result result = action.execute(new Result(), 0);
+
+    assertTrue(result.getResult());
+    assertEquals(List.of(), database.autoCommitted);
+    assertEquals(1, database.commits);
+  }
+
+  private static CypherScript action(TransactionalDatabase database) throws 
HopException {
+    MemoryMetadataProvider metadataProvider = new MemoryMetadataProvider();
+    metadataProvider
+        .getSerializer(GraphDatabaseMeta.class)
+        .save(new GraphDatabaseMeta("tx", database));
+    CypherScript action = new CypherScript("script");
+    action.setMetadataProvider(metadataProvider);
+    action.setConnectionName("tx");
+    return action;
+  }
+
+  /** Statements starting with AUTO can't run in an explicit transaction. */
+  private static class AutoCommitDialect extends CypherGraphDialect {
+    AutoCommitDialect() {
+      super("AUTO_COMMIT");
+    }
+
+    @Override
+    public boolean isRequiringAutoCommit(String statement) {
+      return statement.startsWith("AUTO");
+    }
+  }
+
   /** Runs write work in a transaction that commits on return and rolls back 
on an exception. */
   private static class TransactionalDatabase extends BaseGraphDatabase {
     final List<String> executed = new ArrayList<>();
+    final List<String> autoCommitted = new ArrayList<>();
+    private final IGraphDialect dialect;
     boolean committed;
     boolean rolledBack;
+    int commits;
+    boolean inTransaction;
+
+    TransactionalDatabase() {
+      this(CypherGraphDialect.DEFAULT);
+    }
+
+    TransactionalDatabase(IGraphDialect dialect) {
+      this.dialect = dialect;
+    }
 
     @Override
     public IGraphDialect getGraphDialect() {
-      return CypherGraphDialect.DEFAULT;
+      return dialect;
     }
 
     @Override
@@ -109,6 +190,9 @@ class CypherScriptTransactionTest {
         public List<Map<String, Object>> execute(String statement, Map<String, 
Object> parameters)
             throws HopException {
           executed.add(statement);
+          if (!inTransaction) {
+            autoCommitted.add(statement);
+          }
           if (statement.startsWith("FAIL")) {
             throw new HopException("Syntax error in " + statement);
           }
@@ -145,19 +229,23 @@ class CypherScriptTransactionTest {
                   // Nothing to close
                 }
               };
+          inTransaction = true;
           try {
             T value = work.execute(transaction);
             committed = true;
+            commits++;
             return value;
           } catch (HopException | RuntimeException e) {
             rolledBack = true;
             throw e;
+          } finally {
+            inTransaction = false;
           }
         }
 
         @Override
         public IGraphDialect getGraphDialect() {
-          return CypherGraphDialect.DEFAULT;
+          return dialect;
         }
 
         @Override
diff --git 
a/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/bolt/BoltGraphDialectTest.java
 
b/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/bolt/BoltGraphDialectTest.java
index 6bfe2a91ac..3abb7f18e4 100644
--- 
a/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/bolt/BoltGraphDialectTest.java
+++ 
b/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/bolt/BoltGraphDialectTest.java
@@ -135,6 +135,59 @@ class BoltGraphDialectTest {
     assertFalse(Neo4jGraphDialect.INSTANCE.isRequiringAutoCommit("SHOW 
INDEXES"));
   }
 
+  /** Neo4j only runs statements which commit their own batches in an implicit 
transaction. */
+  @Test
+  void testNeo4jAutoCommitStatements() {
+    Neo4jGraphDialect neo4j = Neo4jGraphDialect.INSTANCE;
+    assertTrue(
+        neo4j.isRequiringAutoCommit(
+            "MATCH (n:Person) CALL (n) { SET n.processed = true } IN 
TRANSACTIONS OF 10 ROWS"));
+    assertTrue(
+        neo4j.isRequiringAutoCommit(
+            "MATCH (n:Person) CALL { WITH n SET n.processed = true } IN 
TRANSACTIONS"));
+    assertTrue(neo4j.isRequiringAutoCommit("call () {\n  create (:A)\n}\nin 
transactions"));
+    assertTrue(
+        neo4j.isRequiringAutoCommit(
+            "UNWIND $rows AS row CALL (*) { MERGE (p:Person {id: row.id}) SET 
p += {name: row.name}"
+                + " } IN 4 CONCURRENT TRANSACTIONS OF 1000 ROWS ON ERROR 
CONTINUE"));
+    assertTrue(
+        neo4j.isRequiringAutoCommit(
+            "CALL (row) { CREATE (:A {text: '}'}) } IN CONCURRENT 
TRANSACTIONS"));
+    assertTrue(
+        neo4j.isRequiringAutoCommit(
+            "// batches\nCALL () { CREATE (:A) } /* of rows */ IN TRANSACTIONS 
OF 5 ROWS"));
+    assertTrue(
+        neo4j.isRequiringAutoCommit(
+            "USING PERIODIC COMMIT 500 LOAD CSV FROM 'file:///a.csv' AS line 
CREATE (:A)"));
+
+    // Statements which run in a transaction as before
+    assertFalse(neo4j.isRequiringAutoCommit(null));
+    assertFalse(neo4j.isRequiringAutoCommit("SHOW INDEXES"));
+    assertFalse(neo4j.isRequiringAutoCommit("MATCH (n) CALL (n) { SET n.x = 1 
} RETURN n"));
+    assertFalse(neo4j.isRequiringAutoCommit("CALL db.labels() YIELD label 
RETURN label"));
+    assertFalse(
+        neo4j.isRequiringAutoCommit(
+            "CALL apoc.periodic.iterate('MATCH (n) RETURN n', 'SET n.x = 1', 
{batchSize: 100})"));
+    assertFalse(
+        neo4j.isRequiringAutoCommit(
+            "MATCH (n) WHERE n.note = 'CALL { } IN TRANSACTIONS' RETURN n"));
+    assertFalse(neo4j.isRequiringAutoCommit("// CALL () { } IN 
TRANSACTIONS\nMATCH (n) RETURN n"));
+    assertFalse(neo4j.isRequiringAutoCommit("MATCH (n) WHERE {id: n.id} IN 
transactions RETURN n"));
+    assertFalse(
+        neo4j.isRequiringAutoCommit(
+            "MATCH (n) WHERE EXISTS { MATCH (n)--() } AND n.id IN transactions 
RETURN n"));
+    assertFalse(neo4j.isRequiringAutoCommit("MATCH (n {call: 1}) RETURN 
n.call"));
+    assertFalse(neo4j.isRequiringAutoCommit("MATCH (n:`CALL {} IN 
TRANSACTIONS`) RETURN n"));
+
+    // The other dialects are unchanged
+    assertFalse(
+        NeptuneGraphDialect.INSTANCE.isRequiringAutoCommit(
+            "CALL () { CREATE (:A) } IN TRANSACTIONS"));
+    assertFalse(
+        MemgraphGraphDialect.INSTANCE.isRequiringAutoCommit(
+            "CALL () { CREATE (:A) } IN TRANSACTIONS"));
+  }
+
   /** Memgraph has no IF [NOT] EXISTS: its errors about existing or missing 
schema are tolerated. */
   @Test
   void testMemgraphExistingOrMissing() {
diff --git 
a/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/transforms/cypher/CypherRetryTest.java
 
b/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/transforms/cypher/CypherRetryTest.java
index ec82c0602b..27f5774fc1 100644
--- 
a/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/transforms/cypher/CypherRetryTest.java
+++ 
b/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/transforms/cypher/CypherRetryTest.java
@@ -24,8 +24,12 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.contains;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import java.util.ArrayList;
@@ -212,6 +216,102 @@ class CypherRetryTest {
     assertArrayEquals(new Object[] {"b"}, cypher.rows.get(1));
   }
 
+  @Test
+  void boltInTransactionsStatementRunsInAutoCommit() throws Exception {
+    data.attempts = 1;
+    setUpBolt(tx -> {}, 1);
+    when(data.session.run(any(String.class), anyMap())).thenAnswer(invocation 
-> emptyResult());
+    String inTransactions = "CALL () { CREATE (:C) } IN TRANSACTIONS";
+    data.cypherStatements.add(1, new CypherStatement(new Object[] {"c"}, 
inTransactions, Map.of()));
+
+    cypher.batchComplete();
+
+    // CREATE (:A) and CREATE (:B) in a transaction each, around the 
auto-commit statement
+    verify(data.session).run(eq(inTransactions), anyMap());
+    verify(data.session, times(2)).executeWrite(any());
+    assertEquals(3, cypher.rows.size());
+    assertArrayEquals(new Object[] {"c"}, cypher.rows.get(1));
+  }
+
+  @Test
+  void boltUnwindInTransactionsRunsInAutoCommit() throws Exception {
+    data.attempts = 1;
+    setUpBolt(tx -> {}, 1);
+    when(data.session.run(any(String.class), anyMap())).thenAnswer(invocation 
-> emptyResult());
+    meta.setUsingUnwind(true);
+    data.cypher =
+        "UNWIND $rows AS row CALL (row) { CREATE (:A {id: row.id}) } IN 
TRANSACTIONS OF 2 ROWS";
+    data.unwindMapName = "rows";
+    data.unwindList = new ArrayList<>(List.of(Map.of("id", 1), Map.of("id", 
2), Map.of("id", 3)));
+    data.outputCount = 3;
+
+    cypher.batchComplete();
+
+    verify(data.session).run(eq(data.cypher), anyMap());
+    verify(data.session, never()).executeWrite(any());
+    assertEquals(3, cypher.getLinesOutput());
+    assertTrue(data.unwindList.isEmpty());
+  }
+
+  @Test
+  void boltUnwindInTransactionsErrorAtTheEndFailsTheBatch() {
+    data.attempts = 1;
+    setUpBolt(tx -> {}, 1);
+    // The statement starts, a later batch of it fails
+    Result failing = emptyResult();
+    when(failing.consume())
+        .thenThrow(new org.neo4j.driver.exceptions.ClientException("Batch 4 
failed"));
+    when(data.session.run(any(String.class), anyMap())).thenReturn(failing);
+    unwindInTransactions();
+
+    assertThrows(HopException.class, () -> cypher.batchComplete());
+    assertEquals(0, cypher.getLinesOutput());
+    assertTrue(cypher.rows.isEmpty());
+  }
+
+  @Test
+  void boltAutoCommitRetryOfChangesIsLogged() throws Exception {
+    data.attempts = 2;
+    setUpBolt(tx -> {}, 1);
+    Result failing = emptyResult();
+    when(failing.consume())
+        .thenThrow(new org.neo4j.driver.exceptions.ClientException("Batch 4 
failed"));
+    Result succeeding = emptyResult();
+    when(data.session.run(any(String.class), anyMap())).thenReturn(failing, 
succeeding);
+    unwindInTransactions();
+
+    cypher.batchComplete();
+
+    verify(data.session, times(2)).run(eq(data.cypher), anyMap());
+    verify(helper.iLogChannel).logBasic(contains("the retry applies them 
again"));
+    assertEquals(3, cypher.getLinesOutput());
+  }
+
+  private void unwindInTransactions() {
+    meta.setUsingUnwind(true);
+    data.cypher =
+        "UNWIND $rows AS row CALL (row) { CREATE (:A {id: row.id}) } IN 
TRANSACTIONS OF 2 ROWS";
+    data.unwindMapName = "rows";
+    data.unwindList = new ArrayList<>(List.of(Map.of("id", 1), Map.of("id", 
2), Map.of("id", 3)));
+    data.outputCount = 3;
+  }
+
+  @Test
+  void boltUnwindKeepsTheManagedTransaction() throws Exception {
+    data.attempts = 1;
+    setUpBolt(tx -> {}, 1);
+    meta.setUsingUnwind(true);
+    data.cypher = "UNWIND $rows AS row CREATE (:A {id: row.id})";
+    data.unwindMapName = "rows";
+    data.unwindList = new ArrayList<>(List.of(Map.of("id", 1)));
+    data.outputCount = 1;
+
+    cypher.batchComplete();
+
+    verify(data.session).executeWrite(any());
+    verify(data.session, never()).run(any(String.class), anyMap());
+  }
+
   /**
    * A Bolt session whose managed transactions call the work the given number 
of times, as the
    * driver does when it retries a transient error.

Reply via email to