diff --git a/README.md b/README.md
index 2686c9b08..38a438265 100644
--- a/README.md
+++ b/README.md
@@ -105,6 +105,7 @@ Sometimes, to achieve some specific purposes, such as pausing or creating a new
| -- @skip:issue#{IssueNo.} | If set, the whole script file will be skipped, and not be executed any more for issue{IssueNo.} |
| -- @bvt:issue#{IssueNo.}
-- @bvt:issue | The sql statements between those two tags will be not executed for issue{IssueNo.} |
| -- @sleep:{time} | The mo-tester will wait for {time} s |
+| -- @capture:{name} | Execute the following scalar query, store its first non-null value as `{name}`, and replace later `{{name}}` placeholders before execution. The capture query itself is not result-compared. |
| -- @wait_expect({interval}, {timeout}) | Intelligent polling mechanism that repeatedly executes the SQL until the result matches the expected output or timeout is reached. `{interval}` is the polling interval in seconds (how often to retry), and `{timeout}` is the maximum wait time in seconds. This replaces fixed sleep times with smart waiting.
**Example:**
`-- @wait_expect(1, 10)`
`SELECT * FROM orders WHERE status = 'completed';`
This will poll every 1 second for up to 10 seconds until the query returns the expected result. Useful for testing asynchronous operations, data replication, or eventual consistency scenarios.
**Modes:**
- **run mode**: Polls until result matches or timeout
- **genrs mode**: Sleeps for timeout seconds, then executes once
**Features:**
- Supports query results (SELECT)
- Supports error matching (waiting for errors to appear/disappear)
- Detailed logging of retry attempts and timing |
| -- @session:id=2&user=root&password=111
-- @session | The mo-tester will create a new connetion to execute sql statements between those two tags.
Default value of id is 1, max is 10.
Defualt value of user and password is configured in `mo.yml`. |
| -- @sortkey: | If the result is sorted, need set this tag for the sql statement. e.g.
-- @sortkey:0,1: means sort keys are first column and second colum. |
diff --git a/src/main/java/io/mo/cases/SqlCommand.java b/src/main/java/io/mo/cases/SqlCommand.java
index 569db42cc..147d950e9 100755
--- a/src/main/java/io/mo/cases/SqlCommand.java
+++ b/src/main/java/io/mo/cases/SqlCommand.java
@@ -124,6 +124,11 @@ public class SqlCommand {
@Setter
private int waitExpectTimeout = 20; // wait_expect 超时时间(秒)
+ /** Name used by @capture to expose this query's first scalar value. */
+ @Getter
+ @Setter
+ private String captureName = null;
+
public SqlCommand() {
command = new StringBuffer();
testResult = new TestResult();
diff --git a/src/main/java/io/mo/constant/COMMON.java b/src/main/java/io/mo/constant/COMMON.java
index 30d24967c..8f7ec2a92 100755
--- a/src/main/java/io/mo/constant/COMMON.java
+++ b/src/main/java/io/mo/constant/COMMON.java
@@ -28,6 +28,7 @@ public class COMMON {
public static String COLUMN_SEPARATOR_FLAG = "-- @separator:";
public static String WAIT_FLAG = "-- @wait:";
public static String WAIT_EXPECT_FLAG = "-- @wait_expect";
+ public static String CAPTURE_FLAG = "-- @capture:";
// if result is type of error, and not unique, can use this flag to regular
// match
@@ -90,4 +91,4 @@ public class COMMON {
public static double SCALE_TOLERABLE_ERROR = 0.0000009;
public static double INT_TOLERABLE_ERROR = 0.000000000000001;
-}
\ No newline at end of file
+}
diff --git a/src/main/java/io/mo/db/Executor.java b/src/main/java/io/mo/db/Executor.java
index ee5719b73..11321a7c8 100755
--- a/src/main/java/io/mo/db/Executor.java
+++ b/src/main/java/io/mo/db/Executor.java
@@ -18,8 +18,10 @@
import java.sql.Connection;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -64,6 +66,12 @@ public Executor(String userName, String password) {
this.logger = Logger.getLogger(Executor.class.getName()+"2");
}
+ /** Visible for execution tests that provide an in-memory JDBC boundary. */
+ Executor(ConnectionManager connectionManager) {
+ this.connectionManager = connectionManager;
+ this.logger = Logger.getLogger(Executor.class.getName());
+ }
+
/**
* run test file function
*/
@@ -106,6 +114,7 @@ public void run(TestScript script) {
long start = System.currentTimeMillis();
boolean[] transformed = new boolean[1];
long waitExpectDeadline = 0;
+ Map capturedValues = new HashMap<>();
// for (SqlCommand command : commands) {
for (int i = 0; i < commands.size(); i++) {
@@ -147,7 +156,7 @@ public void run(TestScript script) {
continue;
}
- String sqlCmd = transformFlushSql(cmd, transformed);
+ String sqlCmd = transformFlushSql(replaceCapturedValues(cmd, capturedValues), transformed);
if (transformed[0]) {
connection = connectionManager.getConnectionForSys();
} else {
@@ -223,6 +232,16 @@ public void run(TestScript script) {
}
ResultSet resultSet = statement.getResultSet();
+ if (command.getCaptureName() != null) {
+ captureScalar(command, resultSet, capturedValues);
+ StmtResult captureResult = new StmtResult();
+ command.setActResult(captureResult);
+ command.getTestResult().setActResult(captureResult.toString());
+ script.addSuccessCmd(command);
+ command.getTestResult().setResult(RESULT.RESULT_TYPE_PASS);
+ statement.close();
+ continue;
+ }
if (resultSet != null) {
RSSet rsSet = new RSSet(resultSet, command);
StmtResult actResult = new StmtResult(rsSet);
@@ -264,6 +283,14 @@ public void run(TestScript script) {
} catch (SQLException e) {
waitExpectDeadline = 0;
+ // @capture controls subsequent SQL expansion. It must never be
+ // compared as an ordinary expected SQL error, otherwise an
+ // unresolved {{name}} can make a broken script look successful.
+ if (command.getCaptureName() != null) {
+ failCapture(command, script, e);
+ closeStatement(statement);
+ continue;
+ }
try {
if (connection.isClosed() || !connection.isValid(10)) {
logger.error("[" + script.getFileName() + "][row:" + command.getPosition() + "]["
@@ -365,7 +392,8 @@ public boolean genRS(TestScript script) {
connectionManager.reset();
Connection connection = connectionManager.getConnection();
- Statement statement;
+ Statement statement = null;
+ boolean captureFailed = false;
// check whether the result file exists
File rsf = new File(script.getFileName().replaceAll("\\.[A-Za-z]+", COMMON.R_FILE_SUFFIX));
@@ -383,6 +411,7 @@ public boolean genRS(TestScript script) {
try (BufferedWriter rs_writer = new BufferedWriter(new FileWriter(rsf.getPath()))) {
ArrayList commands = script.getCommands();
+ Map capturedValues = new HashMap<>();
for (int j = 0; j < commands.size(); j++) {
SqlCommand command = null;
@@ -420,7 +449,8 @@ public boolean genRS(TestScript script) {
connection = getConnection(command);
statement = connection.createStatement();
- String sqlCmd = command.getCommand().replaceAll("\\$resources", COMMON.RESOURCE_PATH);
+ String sqlCmd = replaceCapturedValues(command.getCommand(), capturedValues)
+ .replaceAll("\\$resources", COMMON.RESOURCE_PATH);
if (command.isNeedWait()) {
execWaitOperation(command);
// In genRS mode, wait for the condition to be met before executing
@@ -462,6 +492,13 @@ public boolean genRS(TestScript script) {
}
}
ResultSet resultSet = statement.getResultSet();
+ if (command.getCaptureName() != null) {
+ captureScalar(command, resultSet, capturedValues);
+ rs_writer.write(command.getCommand().trim());
+ if (j < commands.size() - 1) rs_writer.newLine();
+ statement.close();
+ continue;
+ }
if (resultSet != null) {
RSSet rsSet = new RSSet(resultSet, command);
StmtResult actResult = new StmtResult(rsSet);
@@ -486,6 +523,12 @@ public boolean genRS(TestScript script) {
}
statement.close();
} catch (SQLException e) {
+ if (command != null && command.getCaptureName() != null) {
+ logger.error("@capture failed; refusing to generate a result file entry", e);
+ closeStatement(statement);
+ captureFailed = true;
+ break;
+ }
rs_writer.write(command.getCommand().trim());
rs_writer.newLine();
writeRegexPatterns(rs_writer, command);
@@ -501,21 +544,68 @@ public boolean genRS(TestScript script) {
throw new RuntimeException(e);
}
}
- rs_writer.newLine();
- rs_writer.flush();
- // drop the test db
- dropTestDB(connection, script.getUseDB());
+ if (!captureFailed) {
+ rs_writer.newLine();
+ rs_writer.flush();
+ }
} catch (IOException e) {
e.printStackTrace();
return false;
+ } finally {
+ closeStatement(statement);
+ dropTestDB(connection, script.getUseDB());
}
- return true;
+ return !captureFailed;
}
public Connection getConnection(SqlCommand command) {
return connectionManager.getConnection(command.getConn_id(), getConnUser(command), getConnPswd(command));
}
+ private static String replaceCapturedValues(String sql, Map capturedValues) {
+ String result = sql;
+ for (Map.Entry entry : capturedValues.entrySet()) {
+ result = result.replace("{{" + entry.getKey() + "}}", entry.getValue());
+ }
+ return result;
+ }
+
+ private static void captureScalar(SqlCommand command, ResultSet resultSet,
+ Map capturedValues) throws SQLException {
+ if (resultSet == null) {
+ throw new SQLException("@capture requires a query returning one non-null scalar value");
+ }
+ try {
+ if (!resultSet.next() || resultSet.getObject(1) == null) {
+ throw new SQLException("@capture requires a query returning one non-null scalar value");
+ }
+ capturedValues.put(command.getCaptureName(), resultSet.getString(1));
+ } finally {
+ resultSet.close();
+ }
+ }
+
+ private void failCapture(SqlCommand command, TestScript script, SQLException error) {
+ StmtResult result = new StmtResult();
+ result.setType(RESULT.STMT_RESULT_TYPE_ERROR);
+ result.setErrorMessage(error.getMessage());
+ command.setActResult(result);
+ command.getTestResult().setActResult(result.toString());
+ command.getTestResult().setResult(RESULT.RESULT_TYPE_FAILED);
+ script.addFailedCmd(command);
+ logger.error("[" + script.getFileName() + "][row:" + command.getPosition()
+ + "] @capture failed: " + error.getMessage());
+ }
+
+ private static void closeStatement(Statement statement) {
+ if (statement == null) return;
+ try {
+ statement.close();
+ } catch (SQLException ignored) {
+ // The capture failure is the actionable error.
+ }
+ }
+
private String getConnUser(SqlCommand command) {
return command.getConn_user() == null ? connectionManager.getDefaultUserName() : command.getConn_user();
}
diff --git a/src/main/java/io/mo/util/ScriptParser.java b/src/main/java/io/mo/util/ScriptParser.java
index 5f64933cc..aee0f82d7 100755
--- a/src/main/java/io/mo/util/ScriptParser.java
+++ b/src/main/java/io/mo/util/ScriptParser.java
@@ -122,6 +122,12 @@ private boolean processCommentLine(String trimmedLine, String path, SqlCommand c
parseRegexFlag(trimmedLine.substring(COMMON.REGEX_FLAG.length()), command);
} else if (trimmedLine.startsWith(COMMON.WAIT_EXPECT_FLAG)) {
parseWaitExpectFlag(trimmedLine, command);
+ } else if (trimmedLine.startsWith(COMMON.CAPTURE_FLAG)) {
+ String name = trimmedLine.substring(COMMON.CAPTURE_FLAG.length()).trim();
+ if (!name.matches("[A-Za-z_][A-Za-z0-9_]*")) {
+ throw new RuntimeException("Invalid @capture name: " + name);
+ }
+ command.setCaptureName(name);
}
return false;
@@ -573,4 +579,3 @@ public static void main(String[] args){
}
}
-
diff --git a/src/test/java/io/mo/db/ExecutorCaptureTest.java b/src/test/java/io/mo/db/ExecutorCaptureTest.java
new file mode 100644
index 000000000..e0bfecd98
--- /dev/null
+++ b/src/test/java/io/mo/db/ExecutorCaptureTest.java
@@ -0,0 +1,166 @@
+package io.mo.db;
+
+import io.mo.cases.SqlCommand;
+import io.mo.cases.TestScript;
+import org.junit.Test;
+
+import java.io.File;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Proxy;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/** Exercises capture failures through Executor.genRS, not just parser state. */
+public class ExecutorCaptureTest {
+
+ @Test
+ public void captureQueryErrorAbortsResultGeneration() throws Exception {
+ assertCaptureFailure(FailureMode.QUERY_ERROR);
+ }
+
+ @Test
+ public void captureEmptyResultAbortsResultGeneration() throws Exception {
+ assertCaptureFailure(FailureMode.EMPTY_RESULT);
+ }
+
+ @Test
+ public void captureNullResultAbortsResultGeneration() throws Exception {
+ assertCaptureFailure(FailureMode.NULL_VALUE);
+ }
+
+ private void assertCaptureFailure(FailureMode mode) throws Exception {
+ File sql = File.createTempFile("capture-failure", ".sql");
+ File result = new File(sql.getPath().replaceAll("\\.[A-Za-z]+", ".result"));
+ try {
+ TestScript script = new TestScript();
+ script.setFileName(sql.getPath());
+ SqlCommand command = new SqlCommand();
+ command.append("select capture_value();\n");
+ command.setCaptureName("value");
+ script.addCommand(command);
+
+ Tracker tracker = new Tracker();
+ boolean generated = new Executor(new StubConnectionManager(mode, tracker)).genRS(script);
+
+ assertFalse("a control-directive failure must not become expected output", generated);
+ assertTrue("capture failure must not be written to the result file",
+ !result.exists() || result.length() == 0);
+ assertTrue("capture statement must be closed", tracker.captureStatementClosed);
+ assertTrue("test database must be dropped", tracker.testDatabaseDropped);
+ if (mode != FailureMode.QUERY_ERROR) {
+ assertTrue("capture result set must be closed", tracker.captureResultSetClosed);
+ }
+ } finally {
+ sql.delete();
+ result.delete();
+ }
+ }
+
+ private enum FailureMode { QUERY_ERROR, EMPTY_RESULT, NULL_VALUE }
+
+ private static final class Tracker {
+ boolean captureStatementClosed;
+ boolean captureResultSetClosed;
+ boolean testDatabaseDropped;
+ }
+
+ private static final class StubConnectionManager extends ConnectionManager {
+ private final Connection connection;
+
+ StubConnectionManager(FailureMode mode, Tracker tracker) {
+ connection = proxy(Connection.class, new ConnectionHandler(mode, tracker));
+ }
+
+ @Override public Connection getConnection() { return connection; }
+ @Override public Connection getConnection(int index) { return connection; }
+ @Override public Connection getConnection(int index, String user, String password) { return connection; }
+ @Override public void reset() { }
+ }
+
+ private static final class ConnectionHandler implements InvocationHandler {
+ private final FailureMode mode;
+ private final Tracker tracker;
+ ConnectionHandler(FailureMode mode, Tracker tracker) {
+ this.mode = mode;
+ this.tracker = tracker;
+ }
+
+ @Override public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) {
+ String name = method.getName();
+ if ("createStatement".equals(name)) return proxy(Statement.class, new StatementHandler(mode, tracker));
+ if ("isClosed".equals(name)) return false;
+ if ("isValid".equals(name)) return true;
+ return defaultValue(method.getReturnType());
+ }
+ }
+
+ private static final class StatementHandler implements InvocationHandler {
+ private final FailureMode mode;
+ private final Tracker tracker;
+ private boolean captureStatement;
+ StatementHandler(FailureMode mode, Tracker tracker) {
+ this.mode = mode;
+ this.tracker = tracker;
+ }
+
+ @Override public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) throws Throwable {
+ String name = method.getName();
+ if ("execute".equals(name)) {
+ String sql = (String) args[0];
+ if (sql.contains("capture_value")) {
+ captureStatement = true;
+ if (mode == FailureMode.QUERY_ERROR) throw new SQLException("capture query failed");
+ return true;
+ }
+ if (sql.startsWith("drop database")) tracker.testDatabaseDropped = true;
+ return false;
+ }
+ if ("getResultSet".equals(name)) {
+ return proxy(ResultSet.class, new ResultSetHandler(mode, tracker));
+ }
+ if ("close".equals(name) && captureStatement) tracker.captureStatementClosed = true;
+ return defaultValue(method.getReturnType());
+ }
+ }
+
+ private static final class ResultSetHandler implements InvocationHandler {
+ private final FailureMode mode;
+ private final Tracker tracker;
+ private boolean advanced;
+ ResultSetHandler(FailureMode mode, Tracker tracker) {
+ this.mode = mode;
+ this.tracker = tracker;
+ }
+
+ @Override public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) {
+ if ("next".equals(method.getName())) {
+ if (mode == FailureMode.EMPTY_RESULT || advanced) return false;
+ advanced = true;
+ return true;
+ }
+ if ("getObject".equals(method.getName())) return null;
+ if ("close".equals(method.getName())) tracker.captureResultSetClosed = true;
+ return defaultValue(method.getReturnType());
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static T proxy(Class type, InvocationHandler handler) {
+ return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class>[]{type}, handler);
+ }
+
+ private static Object defaultValue(Class> type) {
+ if (!type.isPrimitive()) return null;
+ if (type == Boolean.TYPE) return false;
+ if (type == Character.TYPE) return '\0';
+ if (type == Byte.TYPE || type == Short.TYPE || type == Integer.TYPE || type == Long.TYPE) return 0;
+ if (type == Float.TYPE) return 0F;
+ if (type == Double.TYPE) return 0D;
+ return null;
+ }
+}
diff --git a/src/test/java/io/mo/util/ScriptParserTest.java b/src/test/java/io/mo/util/ScriptParserTest.java
index cbde0cb79..2b9a78696 100644
--- a/src/test/java/io/mo/util/ScriptParserTest.java
+++ b/src/test/java/io/mo/util/ScriptParserTest.java
@@ -193,6 +193,20 @@ public void testSystemCommandFlag() {
// 系统命令标记应该被解析
assertTrue("Should have parsed system commands", foundSystemCommand);
}
+
+ @Test
+ public void testCaptureFlag() throws Exception {
+ File script = File.createTempFile("capture-flag", ".sql");
+ script.deleteOnExit();
+ try (PrintWriter writer = new PrintWriter(new FileWriter(script))) {
+ writer.println("-- @capture:restore_ts");
+ writer.println("select '2026-08-25 00:00:00';");
+ }
+
+ TestScript testScript = new ScriptParser().parseScript(script.getAbsolutePath());
+ assertEquals(1, testScript.getCommands().size());
+ assertEquals("restore_ts", testScript.getCommands().get(0).getCaptureName());
+ }
/**
* 测试 Session 连接标记
@@ -877,4 +891,3 @@ public void testMixedMetacmpFlags() throws Exception {
Boolean.TRUE, testScript.getCommands().get(5).getCompareMeta());
}
}
-