changeset 587:fca22b72ce7e

Merge onclient into default
author Joeri van Ruth <joeri.van.ruth@monetdbsolutions.com>
date Fri, 05 Nov 2021 16:17:55 +0100 (2021-11-05)
parents ac5e97fc812d (current diff) 1c5e59760ff8 (diff)
children 9f658a97666f
files tests/JDBC_API_Tester.java
diffstat 20 files changed, 2438 insertions(+), 171 deletions(-) [+]
line wrap: on
line diff
--- a/build.xml
+++ b/build.xml
@@ -108,6 +108,7 @@ Copyright 1997 - July 2008 CWI, August 2
         <include name="${nl-cwi-jdbc-package}/types/*.class" />
         <include name="${mcl-package}/**/*.class" />
         <include name="${nl-cwi-mcl-package}/net/MapiSocket.class" />
+        <include name="${util-package}/FileTransferHandler.class" />
       </fileset>
     </jar>
   </target>
@@ -203,6 +204,7 @@ Copyright 1997 - July 2008 CWI, August 2
       <include name="${nl-cwi-jdbc-package}/MonetDriver.java" />
       <include name="${nl-cwi-jdbc-package}/types/*.java" />
       <include name="${nl-cwi-mcl-package}/net/MapiSocket.java" />
+      <include name="${util-package}/FileTransferHandler.java" />
       <compilerarg line="${javac.flags}" />
     </javac>
   </target>
new file mode 100644
--- /dev/null
+++ b/example/OnClientExample.java
@@ -0,0 +1,190 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0.  If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * Copyright 1997 - July 2008 CWI, August 2008 - 2021 MonetDB B.V.
+ */
+
+import org.monetdb.jdbc.MonetConnection;
+import org.monetdb.jdbc.MonetConnection.UploadHandler;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.PrintStream;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.*;
+
+public class OnClientExample {
+
+	public static void main(String[] args) {
+		int status;
+		try {
+			// Ideally this would not be hardcoded..
+			final String dbUrl = "jdbc:monetdb://localhost:50000/demo";
+			final String userName = "monetdb";
+			final String password = "monetdb";
+			final String uploadDir = "/home/jvr/mydata";
+			final boolean filesAreUtf8 = false;
+			String[] queries = {
+					"DROP TABLE IF EXISTS mytable",
+					"CREATE TABLE mytable(i INT, t TEXT)",
+					"COPY INTO mytable FROM 'generated.csv' ON CLIENT",
+					"COPY 20 RECORDS OFFSET 5 INTO mytable FROM 'generated.csv' ON CLIENT",
+					"COPY INTO mytable FROM 'nonexistentfilethatdoesnotexist.csv' ON CLIENT",
+					"SELECT COUNT(*) FROM mytable",
+			};
+
+			status = run(dbUrl, userName, password, uploadDir, filesAreUtf8, queries);
+
+		} catch (Exception e) {
+			status = 1;
+			e.printStackTrace();
+		}
+		System.exit(status);
+	}
+
+	private static int run(String dbUrl, String userName, String password, String uploadDir, boolean filesAreUtf8, String[] queries) throws ClassNotFoundException, SQLException {
+		int status = 0;
+
+		// Connect
+		Class.forName("org.monetdb.jdbc.MonetDriver");
+		Connection conn = DriverManager.getConnection(dbUrl, userName, password);
+
+		// Register upload handler
+		MyUploader handler = new MyUploader(uploadDir, filesAreUtf8);
+		conn.unwrap(MonetConnection.class).setUploadHandler(handler);
+
+		Statement stmt = conn.createStatement();
+		for (String q : queries) {
+			System.out.println(q);
+			try {
+				boolean hasResultSet = stmt.execute(q);
+				if (hasResultSet) {
+					ResultSet rs = stmt.getResultSet();
+					long count = 0;
+					while (rs.next()) {
+						count++;
+					}
+					System.out.printf("  OK, returned %d rows%n", count);
+				} else {
+					System.out.printf("  OK, updated %d rows%n", stmt.getUpdateCount());
+				}
+			} catch (SQLNonTransientException e) {
+				throw e;
+			} catch (SQLException e) {
+				System.out.println("  => SQL ERROR " + e.getMessage());
+				status = 1;
+			}
+
+		}
+
+		return status;
+	}
+
+
+	private static class MyUploader implements UploadHandler {
+		private final Path uploadDir;
+		private final boolean filesAreUtf8;
+		private boolean stopUploading = false;
+
+		public MyUploader(String uploadDir, boolean filesAreUtf8) {
+			this.uploadDir = FileSystems.getDefault().getPath(uploadDir).normalize();
+			this.filesAreUtf8 = filesAreUtf8;
+		}
+
+		@Override
+		public void uploadCancelled() {
+			System.out.println("  CANCELLATION CALLBACK: server cancelled the upload");
+			stopUploading = true;
+		}
+
+		@Override
+		public void handleUpload(MonetConnection.Upload handle, String name, boolean textMode, long linesToSkip) throws IOException {
+
+			// We can upload data read from the file system but also make up our own data
+			if (name.equals("generated.csv")) {
+				uploadGeneratedData(handle, linesToSkip);
+				return;
+			}
+
+			// Validate the path, demonstrating two ways of dealing with errors
+			Path path = securityCheck(name);
+			if (path == null || !Files.exists(path)) {
+				// This makes the COPY command fail but keeps the connection
+				// alive. Can only be used if we haven't sent any data yet
+				handle.sendError("Invalid path");
+				return;
+			}
+			if (!Files.isReadable(path)) {
+				// As opposed to handle.sendError(), we can throw an IOException
+				// at any time. Unfortunately, the file upload protocol does not
+				// provide a way to indicate to the server that the data sent so
+				// far is incomplete, so for the time being throwing an
+				// IOException from {@handleUpload} terminates the connection.
+				throw new IOException("Unreadable: " + path);
+			}
+
+			boolean binary = !textMode;
+			if (binary) {
+				uploadAsBinary(handle, path);
+			} else if (linesToSkip == 0 && filesAreUtf8) {
+				// Avoid unnecessary UTF-8 -> Java String -> UTF-8 conversions
+				// by pretending the data is binary.
+				uploadAsBinary(handle, path);
+			} else {
+				// Charset and skip handling really necessary
+				uploadAsText(handle, path, linesToSkip);
+			}
+		}
+
+		private Path securityCheck(String name) {
+			Path p = uploadDir.resolve(name).normalize();
+			if (p.startsWith(uploadDir)) {
+				return p;
+			} else {
+				return null;
+			}
+		}
+
+		private void uploadGeneratedData(MonetConnection.Upload handle, long toSkip) throws IOException {
+			// Set the chunk size to a tiny amount so we can demonstrate
+			// cancellation handling. The default chunk size is one megabyte.
+			// DO NOT DO THIS IN PRODUCTION!
+			handle.setChunkSize(50);
+
+			// Make up some data and upload it.
+			PrintStream stream = handle.getStream();
+			long n = 100;
+			System.out.printf("  HANDLER: uploading %d generated lines, numbered %d to %d%n", n - toSkip, toSkip + 1, n);
+			long i;
+			for (i = toSkip + 1; i <= n; i++) {
+				if (stopUploading) {
+					System.out.printf("  HANDLER: at line %d we noticed the server asked us to stop sending%n", i);
+					break;
+				}
+				stream.printf("%d|the number is %d%n", i, i);
+			}
+			System.out.println("  HANDLER: done uploading");
+			stream.close();
+		}
+
+		private void uploadAsText(MonetConnection.Upload handle, Path path, long toSkip) throws IOException {
+			BufferedReader reader = Files.newBufferedReader(path);// Converts from system encoding to Java text
+			for (long i = 0; i < toSkip; i++) {
+				reader.readLine();
+			}
+			handle.uploadFrom(reader); // Converts from Java text to UTF-8 as required by MonetDB
+		}
+
+		private void uploadAsBinary(MonetConnection.Upload handle, Path path) throws IOException {
+			// No charset conversion whatsoever..
+			// Use this for binary data or when you are certain the file is UTF-8 encoded.
+			InputStream stream = Files.newInputStream(path);
+			handle.uploadFrom(stream);
+		}
+	}
+}
new file mode 100644
--- /dev/null
+++ b/onclient.txt
@@ -0,0 +1,94 @@
+COPY ... ON CLIENT support in the MonetDB JDBC driver and JdbcClient program.
+
+MonetDB provides the nonstandard COPY INTO statement to perform bulk inserts and
+retrievals, see also
+https://www.monetdb.org/Documentation/ServerAdministration/LoadingBulkData/CSVBulkLoads
+https://www.monetdb.org/Documentation/ServerAdministration/ExportingBulkData
+
+By default, COPY INTO accesses files on the server but it also has a mode to
+access files on the client.  This is supported by the command line tool
+mclient(1) and now also as an extension to the MonetDB JDBC driver.
+
+This is how it works: The JDBC client automatically announces that it is capable
+of file transfers.  If you execute, for example,
+
+	COPY INTO mytable FROM 'data.csv' ON CLIENT;
+
+the server will send a request for file 'data.csv' to the JDBC driver.
+By default, the JDBC driver will refuse with an error message:
+
+	'No file upload handler has been registered with the JDBC driver'
+
+or in JdbcClient:
+	'Error [22000] data.csv: No file upload handler has been registered with the JDBC driver'
+
+This is for security reasons.  However, you can register a callback to handle
+these requests from the server:
+
+	Connection conn = DriverManager.getConnection(dbUrl, userName, password);
+	MyUploader handler = new MyUploadHandler();
+	conn.unwrap(MonetConnection.class).setUploadHandler(handler);
+
+or provide the JdbcClient startup argument: --csvdir "/path/to/csvfilesdir"
+
+Here, MyUploadHandler is an implementation of the interface MonetConnection.UploadHandler,
+which looks like this:
+
+	public interface UploadHandler {
+		/**
+		 * Called if the server sends a request to read file data.
+		 *
+		 * Use the given handle to receive data or send errors to the server.
+		 *  @param handle Handle to communicate with the server
+		 * @param name Name of the file the server would like to read. Make sure
+		 *             to validate this before reading from the file system
+		 * @param textMode Whether to open the file as text or binary data.
+		 * @param linesToSkip In text mode, number of initial lines to skip.
+		 *                    0 means upload everything, 1 means skip the first line, etc.
+		 *                    Note: this is different from the OFFSET option of the COPY INTO,
+		 *                    where both 0 and 1 mean 'upload everything'
+		 */
+		void handleUpload(Upload handle, String name, boolean textMode, long linesToSkip) throws IOException;
+
+		/**
+		 * Called when the upload is cancelled halfway by the server.
+		 *
+		 * The default implementation does nothing.
+		 */
+		default void uploadCancelled() {}
+	}
+
+In your implementation of handleUpload(), you can use the 'handle' object to
+communicate with the server, for example:
+
+- handle.getStream() to obtain a stream object to which you can write.
+  This is useful if you want to generate the data on the fly.
+
+- void uploadFrom(InputStream stream) to have the JDBC driver read data from the
+  stream and send it to the server as-is.  For text mode uploads this means the
+  text must be UTF-8 encoded.
+
+- handle.uploadFrom(Reader reader) to have the JDBC driver read text from the given
+  Reader and upload it.
+
+- handle.uploadFrom(BufferedReader reader, long linesToSkip) to have the JDBC
+  driver read from the given BufferedReader and upload the text, skipping the first
+  'linesToSkip' lines. Typically you would use the value passed to handleUpload in
+  parameter 'linesToSkip'.
+
+- handle.sendError(String errorMessage) to refuse the upload.
+
+If you use sendError to refuse the upload, the COPY INTO statement will fail but
+the connection will remain usable. On the other hand, if you implementation of
+handleUpload throws an IO Exception, the connection will be closed because there
+is currently no way to signal errors to the server once the transfer has begun.
+
+The interface for downloading is similar to that for uploading.
+
+Class org.monetdb.util.FileTransferHandler provides a default implementation
+of both MonetConnection.UploadHandler and MonetConnection.DownloadHandler for
+reading from and writing to local csv files. You pass the FileTransferHandler
+constructor a directory name and a flag indicating whether the contents of
+the csv files data are UTF-8 encoded. FileTransferHandler is intended for
+situations where you do not need to generate or transform data while uploading.
+
--- a/release.txt
+++ b/release.txt
@@ -1,6 +1,6 @@
 RELEASE NOTES
-MonetDB JDBC driver version 3.1 (Liberica/MCL-1.20)
-Release date: 2021-06-14
+MonetDB JDBC driver version 3.2 (Liberica/MCL-1.21)
+Release date: 2021-10-15
 
 The Java Database Connectivity (JDBC) API provides universal data access from
 the Java programming language.
@@ -186,6 +186,28 @@ The following java.sql.* interfaces are 
   * java.sql.Struct
 
 
+Since release 3.2 (monetdb-jdbc-3.2.jre8.jar), the MonetDB JDBC driver has
+support for the ON CLIENT clause of the COPY statement. To make use of
+this functionality you must register handlers for upload and download of data.
+The MonetConnection class has been extended with 2 methods:
+ public void setUploadHandler(UploadHandler uploadHandler)
+ public void setDownloadHandler(DownloadHandler downloadHandler)
+The API has been extended with interfaces and utility class:
+ public interface org.monetdb.jdbc.MonetConnection.UploadHandler
+ public interface org.monetdb.jdbc.MonetConnection.DownloadHandler
+ public class org.monetdb.util.FileTransferHandler
+  which implements MonetConnection.UploadHandler, MonetConnection.DownloadHandler
+See onclient.txt for more information on how to use these from Java.
+
+The JdbcClient application has also been extended to support COPY ...
+ON CLIENT functionality. However for security reasons you must provide an
+explicit new startup argument
+  --csvdir "/path/to/csvdatafiles"
+or on MS Windows
+  --csvdir "C:\\path\\to\\csvdatafiles"
+in order to allow the JdbcClient to down/up load data to/from local csv files.
+
+
 Notes and Tips for Java Programmers using MonetDB JDBC driver:
 - Close JDBC ResultSet, Statement, PreparedStatement, CallableStatement and
   Connection objects immediately (via close()) when they are no longer needed,
@@ -248,4 +270,4 @@ Warning:
 Note: as of Febr 2021 (monetdb-jdbc-3.0.jre8.jar) we compile all
  the java sources to target: Java SE 8 (profile compact2), so
  you need a JRE/JDK JVM of version 8 or higher to use it.
-
+ 
--- a/src/main/java/org/monetdb/client/JdbcClient.java
+++ b/src/main/java/org/monetdb/client/JdbcClient.java
@@ -9,8 +9,10 @@
 package org.monetdb.client;
 
 import org.monetdb.jdbc.MonetDriver;
+import org.monetdb.jdbc.MonetConnection;
 import org.monetdb.util.CmdLineOpts;
 import org.monetdb.util.Exporter;
+import org.monetdb.util.FileTransferHandler;
 import org.monetdb.util.MDBvalidator;
 import org.monetdb.util.OptionsException;
 import org.monetdb.util.SQLExporter;
@@ -25,6 +27,7 @@ import java.io.File;
 import java.io.PrintWriter;
 import java.net.HttpURLConnection;
 import java.net.URL;
+import java.nio.charset.Charset;
 import java.sql.Connection;
 import java.sql.DatabaseMetaData;
 import java.sql.DriverManager;	// this import is required as it will trigger loading the org.monetdb.jdbc.MonetDriver class
@@ -65,7 +68,8 @@ public class JdbcClient {	/* cannot (yet
 	 *
 	 * <pre>Usage java -jar jdbcclient.jre8.jar
 	 *		[-h host[:port]] [-p port] [-f file] [-u user]
-	 *		[-l language] [-d database] [-e] [-D [table]] [-X&lt;opt&gt;]
+	 *		[-l language] [-d database] [-e] [-D [table]]
+	 *		[--csvdir /path/to/csvfiles] [-X&lt;opt&gt;]
 	 *		| [--help] | [--version]
 	 * or using long option equivalents --host --port --file --user --language
 	 * --dump --echo --database.
@@ -98,6 +102,8 @@ public class JdbcClient {	/* cannot (yet
 	 * -e --echo     Also outputs the contents of the input file, if any.
 	 * -q --quiet    Suppress printing the welcome header.
 	 * -D --dump     Dumps the given table(s), or the complete database if none given.
+	 * --csvdir      The directory path where csv data files wil be read from or
+	 ^               written to when COPY ... ON CLIENT commands are executed.
 	 * -Xoutput      The output mode when dumping.  Default is sql, xml may be used for
 	 *               an experimental XML output.
 	 * -Xhash        Use the given hash algorithm during challenge response.
@@ -142,6 +148,9 @@ public class JdbcClient {	/* cannot (yet
 				"if connecting to monetdbd).");
 		copts.addOption("l", "language", CmdLineOpts.CAR_ONE, "sql",
 				"Use the given language, defaults to 'sql'.");
+		copts.addOption(null, "csvdir", CmdLineOpts.CAR_ONE, null,
+				"The directory path where csv data files are read or " +
+				"written when using ON CLIENT clause of COPY command.");
 
 		// arguments which have no argument(s)
 		copts.addOption(null, "help", CmdLineOpts.CAR_ZERO, null,
@@ -220,7 +229,8 @@ public class JdbcClient {	/* cannot (yet
 			System.out.print(
 				"Usage java -jar jdbcclient.jre8.jar\n" +
 				"\t\t[-h host[:port]] [-p port] [-f file] [-u user]\n" +
-				"\t\t[-l language] [-d database] [-e] [-D [table]] [-X<opt>]\n" +
+				"\t\t[-l language] [-d database] [-e] [-D [table]]\n" +
+				"\t\t[--csvdir /path/to/csvfiles]] [-X<opt>]\n" +
 				"\t\t| [--help] | [--version]\n" +
 				"or using long option equivalents --host --port --file --user --language\n" +
 				"--dump --echo --database.\n" +
@@ -337,6 +347,28 @@ public class JdbcClient {	/* cannot (yet
 			dbmd = null;
 		}
 
+		oc = copts.getOption("csvdir");
+		if (oc.isPresent()) {
+			final String csvdir = oc.getArgument();
+			if (csvdir != null) {
+				// check if provided csvdir is an existing dir
+				// else a download of data into file will terminate the JDBC connection!!
+				if (java.nio.file.Files.isDirectory(java.nio.file.Paths.get(csvdir))) {
+					final FileTransferHandler FThandler = new FileTransferHandler(csvdir, Charset.defaultCharset());
+
+					// register file data uploadHandler to allow support
+					// for: COPY INTO mytable FROM 'data.csv' ON CLIENT;
+					((MonetConnection) con).setUploadHandler(FThandler);
+
+					// register file data downloadHandler to allow support
+					// for: COPY select_query INTO 'data.csv' ON CLIENT;
+					((MonetConnection) con).setDownloadHandler(FThandler);
+				} else {
+					System.err.println("Warning: provided csvdir \"" + csvdir + "\" does not exist. Ignoring csvdir setting.");
+				}
+			}
+		}
+
 		stmt = con.createStatement();	// is used by processInteractive(), processBatch(), doDump()
 
 		in = new BufferedReader(new InputStreamReader(System.in));
--- a/src/main/java/org/monetdb/jdbc/MonetCallableStatement.java
+++ b/src/main/java/org/monetdb/jdbc/MonetCallableStatement.java
@@ -77,7 +77,7 @@ public class MonetCallableStatement
 	 * which deals with most of the required stuff of this class.
 	 *
 	 * @param connection the connection that created this Statement
-	 * @param resultSetType type of {@link ResultSet} to produce
+	 * @param resultSetType type of {@link java.sql.ResultSet} to produce
 	 * @param resultSetConcurrency concurrency of ResultSet to produce
 	 * @param callQuery - an SQL CALL statement that may contain one or more '?' parameter placeholders.
 	 *	Typically this statement is specified using JDBC call escape syntax:
--- a/src/main/java/org/monetdb/jdbc/MonetConnection.java
+++ b/src/main/java/org/monetdb/jdbc/MonetConnection.java
@@ -8,10 +8,10 @@
 
 package org.monetdb.jdbc;
 
-import java.io.File;
-import java.io.IOException;
+import java.io.*;
 import java.net.SocketException;
 import java.net.SocketTimeoutException;
+import java.nio.charset.StandardCharsets;
 import java.sql.CallableStatement;
 import java.sql.Connection;
 import java.sql.DatabaseMetaData;
@@ -35,6 +35,7 @@ import java.util.concurrent.Executor;
 
 import org.monetdb.mcl.io.BufferedMCLReader;
 import org.monetdb.mcl.io.BufferedMCLWriter;
+import org.monetdb.mcl.io.LineType;
 import org.monetdb.mcl.net.HandshakeOptions;
 import org.monetdb.mcl.net.MapiSocket;
 import org.monetdb.mcl.parser.HeaderLineParser;
@@ -67,7 +68,7 @@ import org.monetdb.mcl.parser.StartOfHea
  *
  * @author Fabian Groffen
  * @author Martin van Dinther
- * @version 1.6
+ * @version 1.7
  */
 public class MonetConnection
 	extends MonetWrapper
@@ -150,7 +151,6 @@ public class MonetConnection
 	/** A cache to reduce the number of DatabaseMetaData objects created by getMetaData() to maximum 1 per connection */
 	private DatabaseMetaData dbmd;
 
-
 	/**
 	 * Constructor of a Connection for MonetDB. At this moment the
 	 * current implementation limits itself to storing the given host,
@@ -1238,7 +1238,6 @@ public class MonetConnection
 		throw newSQLFeatureNotSupportedException("createArrayOf");
 	}
 
-
 	/**
 	 * Constructs an object that implements the Clob interface. The
 	 * object returned initially contains no data. The setAsciiStream,
@@ -1674,6 +1673,43 @@ public class MonetConnection
 
 	//== internal helper methods which do not belong to the JDBC interface
 
+	/** Handler for COPY ... INTO ... FROM 'data-file-name' ON CLIENT requests */
+	private UploadHandler uploadHandler;
+	/** Handler for COPY ... INTO 'data-file-name' ON CLIENT requests */
+	private DownloadHandler downloadHandler;
+
+	/**
+	 * Registers a {@link UploadHandler} to support for example COPY INTO mytable FROM 'data.csv' ON CLIENT
+	 *
+	 * @param uploadHandler the handler to register, or null to deregister
+	 */
+	public void setUploadHandler(final UploadHandler uploadHandler) {
+		this.uploadHandler = uploadHandler;
+	}
+
+	/**
+	 * Returns the currently registerered {@link UploadHandler}, or null
+	 */
+	public UploadHandler getUploadHandler() {
+		return uploadHandler;
+	}
+
+	/**
+	 * Registers a {@link DownloadHandler} to support for example COPY select_result INTO 'data.csv' ON CLIENT
+	 *
+	 * @param downloadHandler the handler to register, or null to deregister
+	 */
+	public void setDownloadHandler(final DownloadHandler downloadHandler) {
+		this.downloadHandler = downloadHandler;
+	}
+
+	/**
+	 * Returns the currently registerered {@link DownloadHandler} handler, or null
+	 */
+	public DownloadHandler getDownloadHandler() {
+		return downloadHandler;
+	}
+
 	/**
 	 * Local helper method to test whether the Connection object is closed
 	 * When closed it throws an SQLException
@@ -2100,7 +2136,7 @@ public class MonetConnection
 		 * @return a non-null String if the line is invalid,
 		 *         or additional lines are not allowed.
 		 */
-		public abstract String addLine(String line, int linetype);
+		String addLine(String line, LineType linetype);
 
 		/**
 		 * Returns whether this Response expects more lines to be added
@@ -2108,7 +2144,7 @@ public class MonetConnection
 		 *
 		 * @return true if a next line should be added, false otherwise
 		 */
-		public abstract boolean wantsMore();
+		boolean wantsMore();
 
 		/**
 		 * Indicates that no more header lines will be added to this
@@ -2118,14 +2154,14 @@ public class MonetConnection
 		 *         consistent or sufficient.
 		 */
 		/* MvD: disabled not used/needed code
-		public abstract void complete() throws SQLException;
+		void complete() throws SQLException;
 		*/
 
 		/**
 		 * Instructs the Response implementation to close and do the
 		 * necessary clean up procedures.
 		 */
-		public abstract void close();
+		void close();
 	}
 	// }}}
 
@@ -2259,12 +2295,12 @@ public class MonetConnection
 		 */
 		// {{{ addLine
 		@Override
-		public String addLine(final String tmpLine, final int linetype) {
+		public String addLine(final String tmpLine, final LineType linetype) {
 			if (isSet[LENS] && isSet[TYPES] && isSet[TABLES] && isSet[NAMES]) {
 				return resultBlocks[0].addLine(tmpLine, linetype);
 			}
 
-			if (linetype != BufferedMCLReader.HEADER)
+			if (linetype != LineType.HEADER)
 				return "header expected, got: " + tmpLine;
 
 			// depending on the name of the header, we continue
@@ -2634,8 +2670,8 @@ public class MonetConnection
 		 *         or additional lines are not allowed.
 		 */
 		@Override
-		public String addLine(final String line, final int linetype) {
-			if (linetype != BufferedMCLReader.RESULT)
+		public String addLine(final String line, final LineType linetype) {
+			if (linetype != LineType.RESULT)
 				return "protocol violation: unexpected line in data block: " + line;
 			// add to the backing array
 			data[++pos] = line;
@@ -2725,7 +2761,7 @@ public class MonetConnection
 		}
 
 		@Override
-		public String addLine(final String line, final int linetype) {
+		public String addLine(final String line, final LineType linetype) {
 			return "Header lines are not supported for an UpdateResponse";
 		}
 
@@ -2762,7 +2798,7 @@ public class MonetConnection
 		public final int state = Statement.SUCCESS_NO_INFO;
 
 		@Override
-		public String addLine(final String line, final int linetype) {
+		public String addLine(final String line, final LineType linetype) {
 			return "Header lines are not supported for a SchemaResponse";
 		}
 
@@ -2946,7 +2982,7 @@ public class MonetConnection
 		 * Internal executor of queries.
 		 *
 		 * @param templ the template to fill in
-		 * @param the query to execute
+		 * @param query the query to execute
 		 * @throws SQLException if a database error occurs
 		 */
 		@SuppressWarnings("fallthrough")
@@ -2989,12 +3025,12 @@ public class MonetConnection
 
 					// go for new results
 					String tmpLine = in.readLine();
-					int linetype = in.getLineType();
+					LineType linetype = in.getLineType();
 					Response res = null;
-					while (linetype != BufferedMCLReader.PROMPT) {
+					while (linetype != LineType.PROMPT) {
 						// each response should start with a start of header (or error)
 						switch (linetype) {
-						case BufferedMCLReader.SOHEADER:
+						case SOHEADER:
 							// make the response object, and fill it
 							try {
 								switch (sohp.parse(tmpLine)) {
@@ -3014,7 +3050,7 @@ public class MonetConnection
 									if (rowcount < tuplecount) {
 										if (rsresponses == null)
 											rsresponses = new HashMap<Integer, ResultSetResponse>();
-										rsresponses.put(Integer.valueOf(id), (ResultSetResponse) res);
+										rsresponses.put(id, (ResultSetResponse) res);
 									}
 								} break;
 								case StartOfHeaderParser.Q_UPDATE:
@@ -3042,7 +3078,7 @@ public class MonetConnection
 									final int offset = sohp.getNextAsInt();
 									final ResultSetResponse t;
 									if (rsresponses != null)
-										t = rsresponses.get(Integer.valueOf(id));
+										t = rsresponses.get(id);
 									else
 										t = null;
 									if (t == null) {
@@ -3099,18 +3135,37 @@ public class MonetConnection
 							tmpLine = in.readLine();
 							linetype = in.getLineType();
 							break;
-						case BufferedMCLReader.INFO:
+						case INFO:
 							addWarning(tmpLine.substring(1), "01000");
 							// read the next line (can be prompt, new result, error, etc.)
 							// before we start the loop over
 							tmpLine = in.readLine();
 							linetype = in.getLineType();
 							break;
+						case FILETRANSFER:
+							// Consume the command
+							String transferCommand = in.readLine();
+							// Consume the fake prompt inserted by MapiSocket.
+							String dummy = in.readLine();
+							// Handle the request
+							if (transferCommand != null)
+								error = handleTransfer(transferCommand);
+							else
+								error = "Protocol violation, expected transfer command, got nothing";
+							// Then prepare for the next iteration
+							if (error != null) {
+								out.writeLine(error + "\n");
+								error = in.waitForPrompt();
+							} else {
+								tmpLine = in.readLine();
+							}
+							linetype = in.getLineType();
+							break;
 						default:	// Yeah... in Java this is correct!
 							// we have something we don't expect/understand, let's make it an error message
-							tmpLine = "!M0M10!protocol violation, unexpected line: " + tmpLine;
+							tmpLine = "!M0M10!protocol violation, unexpected " + linetype + " line: " + tmpLine;
 							// don't break; fall through...
-						case BufferedMCLReader.ERROR:
+						case ERROR:
 							// read everything till the prompt (should be
 							// error) we don't know if we ignore some
 							// garbage here... but the log should reveal that
@@ -3154,4 +3209,362 @@ public class MonetConnection
 		}
 	}
 	// }}}
+
+	private String handleTransfer(final String transferCommand) throws IOException {
+		if (transferCommand.startsWith("r ")) {
+			final String[] parts = transferCommand.split(" ", 3);
+			if (parts.length == 3) {
+				final long offset;
+				try {
+					offset = Long.parseLong(parts[1]);
+				} catch (NumberFormatException e) {
+					return e.toString();
+				}
+				return handleUpload(parts[2], true, offset);
+			}
+		} else if (transferCommand.startsWith("rb ")) {
+			return handleUpload(transferCommand.substring(3), false, 0);
+		} else if (transferCommand.startsWith("w ")) {
+			return handleDownload(transferCommand.substring(2));
+		}
+		return "JDBC does not support this file transfer yet: " + transferCommand;
+	}
+
+	private String handleUpload(final String path, final boolean textMode, final long offset) throws IOException {
+		if (uploadHandler == null) {
+			return "No file upload handler has been registered with the JDBC driver";
+		}
+
+		final long linesToSkip = offset >= 1 ? offset - 1 : 0;
+		final Upload handle = new Upload(server, uploadHandler::uploadCancelled);
+		final boolean wasFaking = server.setInsertFakePrompts(false);
+		try {
+			uploadHandler.handleUpload(handle, path, textMode, linesToSkip);
+			if (!handle.hasBeenUsed()) {
+				throw new IOException("Call to " + uploadHandler.getClass().getCanonicalName() + ".handleUpload for path '" + path + "' sent neither data nor an error message");
+			}
+			handle.close();
+		} finally {
+			server.setInsertFakePrompts(wasFaking);
+		}
+		return handle.getError();
+	}
+
+	private String handleDownload(final String path) throws IOException {
+		if (downloadHandler == null) {
+			return "No file download handler has been registered with the JDBC driver";
+		}
+
+		final Download handle = new Download(server);
+		try {
+			downloadHandler.handleDownload(handle, path, true);
+			if (!handle.hasBeenUsed()) {
+				handle.sendError("Call to " + downloadHandler.getClass().getSimpleName() + ".handleDownload sent neither data nor error");
+			}
+		} finally {
+			handle.close();
+		}
+		return handle.getError();
+	}
+
+	/**
+	 * Callback for sending files for COPY INTO "table" FROM 'file-name' ON CLIENT commands
+	 *
+	 * To be registered with {@link MonetConnection#setUploadHandler(UploadHandler)}
+	 *
+	 * An example implementation can be found at ../util/FileTransferHandler.java
+	 */
+
+	public interface UploadHandler {
+		/**
+		 * Called if the server sends a request to read file data.
+		 *
+		 * Use the given handle to receive data or send errors to the server.
+		 *  @param handle Handle to communicate with the server
+		 * @param name Name of the file the server would like to read. Make sure
+		 *             to validate this before reading from the file system
+		 * @param textMode Whether to open the file as text or binary data.
+		 * @param linesToSkip In text mode, number of initial lines to skip.
+		 *                    0 means upload everything, 1 means skip the first line, etc.
+		 *                    Note: this is different from the OFFSET option of the COPY INTO,
+		 *                    where both 0 and 1 mean 'upload everything'
+		 */
+		void handleUpload(Upload handle, String name, boolean textMode, long linesToSkip) throws IOException;
+
+		/**
+		 * Called when the upload is cancelled halfway by the server.
+		 *
+		 * The default implementation does nothing.
+		 */
+		default void uploadCancelled() {}
+	}
+
+	/**
+	 * Callback for receiving files from COPY .. INTO 'file-name' ON CLIENT commands
+	 *
+	 * To be registered with {@link MonetConnection#setDownloadHandler(DownloadHandler)}
+	 *
+	 * An example implementation can be found at ../util/FileTransferHandler.java
+	 */
+	public interface DownloadHandler {
+		/**
+		 * Called if the server sends a request to write a file.
+		 *
+		 * Use the given handle to send data or errors to the server.
+		 *
+		 * @param handle Handle to communicate with the server
+		 * @param name Name of the file the server would like to write. Make sure to validate this before writing to
+		 *             the file system
+		 * @param textMode Whether this is text or binary data.
+		 */
+		void handleDownload(Download handle, String name, boolean textMode) throws IOException;
+	}
+
+	/**
+	 * Handle passed to {@link UploadHandler} to allow communication with the server
+	 */
+	public static class Upload {
+		private final MapiSocket server;
+		private final Runnable cancellationCallback;
+		private PrintStream print = null;
+		private String error = null;
+		private int customChunkSize = -1;
+
+		Upload(MapiSocket server, Runnable cancellationCallback) {
+			this.server = server;
+			this.cancellationCallback = cancellationCallback;
+		}
+
+		/**
+		 * Send an error message to the server
+		 *
+		 * The server will generally let the currently executing statement fail
+		 * with this error message. The connection will remain usable.
+		 *
+		 * This method can only be sent if no data has been sent to the server
+		 * yet. After data has been sent, you can still throw an
+		 * {@link IOException} but this will terminate the connection.
+		 * @param errorMessage error message to send
+		 */
+		public void sendError(final String errorMessage) throws IOException {
+			if (error != null) {
+				throw new IOException("another error has already been sent: " + error);
+			}
+			error = errorMessage;
+		}
+
+		/**
+		 * After every {@code chunkSize} bytes, the server gets the opportunity to
+		 * terminate the upload.
+		 */
+		public void setChunkSize(final int chunkSize) {
+			this.customChunkSize = chunkSize;
+		}
+
+		/**
+		 * Get a {@link PrintStream} to write data to.
+		 *
+		 * For text mode uploads, the data MUST be validly UTF-8 encoded.
+		 */
+		public PrintStream getStream() throws IOException {
+			if (error != null) {
+				throw new IOException("Cannot send data after an error has been sent");
+			}
+			if (print == null) {
+				try {
+					final MapiSocket.UploadStream up = customChunkSize >= 0 ? server.uploadStream(customChunkSize) : server.uploadStream();
+					up.setCancellationCallback(cancellationCallback);
+					print = new PrintStream(up, false, "UTF-8");
+					up.write('\n');
+				} catch (UnsupportedEncodingException e) {
+					throw new RuntimeException("The system is guaranteed to support the UTF-8 encoding but apparently it doesn't", e);
+				}
+			}
+			return print;
+		}
+
+		/**
+		 * @return true if data or an error has been sent.
+		 */
+		public boolean hasBeenUsed() {
+			return print != null || error != null;
+		}
+
+		/**
+		 * @return the error that was sent, if any
+		 */
+		public String getError() {
+			return error;
+		}
+
+		/**
+		 * Read from the given input stream and write it to the server.
+		 *
+		 * For text mode uploads, the data MUST be validly UTF-8 encoded.
+		 */
+		public void uploadFrom(final InputStream inputStream) throws IOException {
+			final OutputStream s = getStream();
+			final byte[] buffer = new byte[64 * 1024];
+			while (true) {
+				int nread = inputStream.read(buffer);
+				if (nread < 0) {
+					break;
+				}
+				s.write(buffer, 0, nread);
+			}
+		}
+
+		/**
+		 * Read data from the given buffered reader and send it to the server
+		 * @param reader reader to read from
+		 * @param linesToSkip start uploading at line {@code offset}. Value 0 and 1
+		 * both mean upload the whole file, value 2 means skip the first line, etc.q
+		 */
+		public void uploadFrom(final BufferedReader reader, final long linesToSkip) throws IOException {
+			for (int i = 0; i < linesToSkip; i++) {
+				String line = reader.readLine();
+				if (line == null) {
+					return;
+				}
+			}
+
+			uploadFrom(reader);
+		}
+
+		/**
+		 * Read data from the given buffered reader and send it to the server
+		 * @param reader reader to read from
+		 */
+		public void uploadFrom(final Reader reader) throws IOException {
+			final OutputStream s = getStream();
+			final OutputStreamWriter writer = new OutputStreamWriter(s, StandardCharsets.UTF_8);
+			final char[] buffer = new char[64 * 1024];
+			while (true) {
+				int nread = reader.read(buffer, 0, buffer.length);
+				if (nread < 0) {
+					break;
+				}
+				writer.write(buffer, 0, nread);
+				writer.close();
+			}
+		}
+
+		/**
+		 * Close opened {@link PrintStream}.
+		 */
+		public void close() {
+			if (print != null) {
+				print.close();
+				print = null;
+			}
+		}
+	}
+
+	/**
+	 * Handle passed to {@link DownloadHandler} to allow communication with the server
+	 */
+	public static class Download {
+		private final MapiSocket server;
+		private MapiSocket.DownloadStream stream = null;
+		private String error = null;
+
+		Download(MapiSocket server) {
+			this.server = server;
+		}
+
+		/**
+		 * Send an error message to the server
+		 *
+		 * The server will generally let the currently executing statement fail
+		 * with this error message. The connection will remain usable.
+		 *
+		 * This method can only be sent if no data has been received from the server
+		 * yet. After data has been received, you can still throw an
+		 * {@link IOException} but this will terminate the connection.
+		 *
+		 * Note: as of MonetDB version Jul2021 the server always terminates the connection
+		 * when this error is used.  This will probably change in the future.
+		 */
+		public void sendError(String errorMessage) throws IOException {
+			if (error != null) {
+				throw new IOException("another error has already been sent: " + error);
+			}
+			error = errorMessage;
+		}
+
+		/**
+		 * Get an {@link InputStream} to read data from.
+		 *
+		 * Textual data is UTF-8 encoded.
+		 */
+		public InputStream getStream() throws IOException {
+			if (error != null) {
+				throw new IOException("cannot receive data after error has been sent");
+			}
+			if (stream == null) {
+				stream = server.downloadStream();
+				server.getOutputStream().flush();
+			}
+			return stream;
+		}
+
+		/**
+		 * Write the data from the server to the given {@link OutputStream}.
+		 */
+		public void downloadTo(final OutputStream stream) throws IOException {
+			final InputStream s = getStream();
+			final byte[] buffer = new byte[65536];
+			while (true) {
+				int nread = s.read(buffer);
+				if (nread < 0) {
+					break;
+				}
+				stream.write(buffer, 0, nread);
+			}
+		}
+
+		/**
+		 * Write the textual data from the server to the given {@link Writer}
+		 * @param writer
+		 */
+		public void downloadTo(final Writer writer) throws IOException {
+			final InputStream s = getStream();
+			final InputStreamReader r = new InputStreamReader(s, StandardCharsets.UTF_8);
+			final char[] buffer = new char[65536];
+			while (true) {
+				final int nread = r.read(buffer);
+				if (nread < 0)
+					break;
+				writer.write(buffer, 0, nread);
+			}
+		}
+
+		/**
+		 * @return  true if data has been received or an error has been sent.
+		 */
+		public boolean hasBeenUsed() {
+			return error != null || stream != null;
+		}
+
+		/**
+		 * @return the error that was sent, if any
+		 */
+		public String getError() {
+			return error;
+		}
+
+		/**
+		 * Close opened stream.
+		 */
+		public void close() {
+			if (stream != null) {
+				try {
+					stream.close();
+					stream = null;
+				} catch (IOException e) {
+					/* ignore close error */
+				}
+			}
+		}
+	}
 }
--- a/src/main/java/org/monetdb/jdbc/MonetPreparedStatement.java
+++ b/src/main/java/org/monetdb/jdbc/MonetPreparedStatement.java
@@ -720,7 +720,7 @@ public class MonetPreparedStatement
 			/**
 			 * Gets the designated column's name
 			 *
-			 * @param column the first column is 1, the second is 2, ...
+			 * @param colnr column number, the first column is 1, the second is 2, ...
 			 * @return the column name
 			 * @throws SQLException if there is no such column
 			 */
--- a/src/main/java/org/monetdb/jdbc/MonetResultSet.java
+++ b/src/main/java/org/monetdb/jdbc/MonetResultSet.java
@@ -1267,7 +1267,7 @@ public class MonetResultSet
 
 			/**
 			 * A private utility method to check validity of column index number
-			 * @throws an SQLDataException when invalid column index number
+			 * @throws SQLDataException when invalid column index number
 			 */
 			private final void checkColumnIndexValidity(final int column) throws SQLDataException {
 				if (column < 1 || column > columns.length)
@@ -2717,7 +2717,7 @@ public class MonetResultSet
 	 * The dates are parsed with the given Calendar.
 	 *
 	 * @param cal the Calendar to use/fill when parsing the date/time
-	 * @param col the column to parse
+	 * @param columnIndex the column to parse
 	 * @param type the corresponding java.sql.Types type of the calling function
 	 * @return the fractional seconds (nanos) or -1 if the value is NULL
 	 * @throws SQLException if a database error occurs
--- a/src/main/java/org/monetdb/mcl/io/BufferedMCLReader.java
+++ b/src/main/java/org/monetdb/mcl/io/BufferedMCLReader.java
@@ -41,27 +41,9 @@ import java.io.UnsupportedEncodingExcept
  * @see org.monetdb.mcl.io.BufferedMCLWriter
  */
 public final class BufferedMCLReader extends BufferedReader {
-	/** "there is currently no line", or the the type is unknown is represented by UNKNOWN */
-	public final static int UNKNOWN  = 0;
-	/** a line starting with ! indicates ERROR */
-	public final static int ERROR    = '!';
-	/** a line starting with % indicates HEADER */
-	public final static int HEADER   = '%';
-	/** a line starting with [ indicates RESULT */
-	public final static int RESULT   = '[';
-	/** a line which matches the pattern of prompt1 is a PROMPT */
-	public final static int PROMPT   = '.';
-	/** a line which matches the pattern of prompt2 is a MORE */
-	public final static int MORE     = ',';
-	/** a line starting with &amp; indicates the start of a header block */
-	public final static int SOHEADER = '&';
-	/** a line starting with ^ indicates REDIRECT */
-	public final static int REDIRECT = '^';
-	/** a line starting with # indicates INFO */
-	public final static int INFO     = '#';
 
 	/** The type of the last line read */
-	private int lineType = UNKNOWN;
+	private LineType lineType = LineType.UNKNOWN;
 
 	/**
 	 * Create a buffering character-input stream that uses a
@@ -107,7 +89,7 @@ public final class BufferedMCLReader ext
 	public String readLine() throws IOException {
 		String r = super.readLine();
 		setLineType(r);
-		if (lineType == ERROR && r != null && !r.matches("^![0-9A-Z]{5}!.+")) {
+		if (lineType == LineType.ERROR && r != null && !r.matches("^![0-9A-Z]{5}!.+")) {
 			r = "!22000!" + r.substring(1);
 		}
 		return r;
@@ -120,39 +102,7 @@ public final class BufferedMCLReader ext
 	 * @param line the string to examine
 	 */
 	public void setLineType(final String line) {
-		if (line == null || line.isEmpty()) {
-			lineType = UNKNOWN;
-			return;
-		}
-		switch (line.charAt(0)) {
-			case '.':
-				lineType = PROMPT;
-				break;
-			case ',':
-				lineType = MORE;
-				break;
-			case '[':	/* multi field result */
-			case '=':	/* single value result */
-				lineType = RESULT;
-				break;
-			case '%':
-				lineType = HEADER;
-				break;
-			case '&':
-				lineType = SOHEADER;
-				break;
-			case '#':
-				lineType = INFO;
-				break;
-			case '!':
-				lineType = ERROR;
-				break;
-			case '^':
-				lineType = REDIRECT;
-				break;
-			default:
-				lineType = UNKNOWN;
-		}
+		lineType = LineType.classify(line);
 	}
 
 	/**
@@ -162,7 +112,7 @@ public final class BufferedMCLReader ext
 	 *         following constants: UNKNOWN, HEADER, ERROR, PROMPT, MORE,
 	 *         RESULT, SOHEADER, REDIRECT, INFO
 	 */
-	public int getLineType() {
+	public LineType getLineType() {
 		return lineType;
 	}
 
@@ -184,11 +134,11 @@ public final class BufferedMCLReader ext
 		final StringBuilder ret = new StringBuilder(128);
 		String tmp;
 
-		while (lineType != PROMPT) {
+		while (lineType != LineType.PROMPT) {
 			tmp = readLine();
 			if (tmp == null)
 				throw new IOException("Connection to server lost!");
-			if (lineType == ERROR)
+			if (lineType == LineType.ERROR)
 				ret.append('\n').append(tmp.substring(1));
 		}
 		return ret.length() == 0 ? null : ret.toString().trim();
new file mode 100644
--- /dev/null
+++ b/src/main/java/org/monetdb/mcl/io/LineType.java
@@ -0,0 +1,115 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0.  If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * Copyright 1997 - July 2008 CWI, August 2008 - 2021 MonetDB B.V.
+ */
+
+package org.monetdb.mcl.io;
+
+/**
+ * Enumeration of the various message types used in the MAPI protocol.
+ */
+public enum LineType {
+	/** "there is currently no line", or the type is unknown is represented by UNKNOWN */
+	UNKNOWN(null),
+
+	/** a line starting with ! indicates ERROR */
+	ERROR(new byte[] { '!' }),
+
+	/** a line starting with % indicates HEADER */
+	HEADER(new byte[] { '%' }),
+
+	/** a line starting with [ indicates RESULT */
+	RESULT(new byte[] { '[' }),
+
+	/** a line which matches the pattern of prompt1 is a PROMPT */
+	PROMPT(new byte[] { 1, 1 }),
+
+	/** a line which matches the pattern of prompt2 is a MORE */
+	MORE(new byte[] { 1, 2 }),
+
+	/** a line which matches the pattern of prompt3 is a FILETRANSFER */
+	FILETRANSFER(new byte[] { 1, 3 }),
+
+	/** a line starting with &amp; indicates the start of a header block */
+	SOHEADER(new byte[] { '&' }),
+
+	/** a line starting with ^ indicates REDIRECT */
+	REDIRECT(new byte[] { '^' }),
+
+	/** a line starting with # indicates INFO */
+	INFO(new byte[] { '#' });
+
+	private final byte[] bytes;
+
+	LineType(byte[] bytes) {
+		this.bytes = bytes;
+	}
+
+	public final byte[] bytes() {
+		return this.bytes;
+	}
+
+	/**
+	 * Look at a mapi message and decide the LineType
+	 */
+	public static final LineType classify(String line) {
+		if (line != null) {
+			if (line.length() > 1) {
+				return classify(line.charAt(0), line.charAt(1));
+			} else if (line.length() == 1) {
+				return classify(line.charAt(0), 0);
+			}
+		}
+		return UNKNOWN;
+	}
+
+	/**
+	 * Look at a mapi message and decide the LineType
+	 */
+	public static final LineType classify(byte[] line) {
+		if (line != null) {
+			if (line.length > 1) {
+				return classify(line[0], line[1]);
+			} else if (line.length == 1) {
+				return classify(line[0], 0);
+			}
+		}
+		return UNKNOWN;
+	}
+
+	private static final LineType classify(int ch0, int ch1) {
+		switch (ch0) {
+			case '!':
+				return ERROR;
+			case '%':
+				return HEADER;
+			case '[':
+				return RESULT;
+			case '&':
+				return SOHEADER;
+			case '^':
+				return REDIRECT;
+			case '#':
+				return INFO;
+			case 1:
+				// prompts, see below
+				break;
+			default:
+				return UNKNOWN;
+		}
+
+		switch (ch1) {
+			case 1:
+				return PROMPT;
+			case 2:
+				return MORE;
+			case 3:
+				return FILETRANSFER;
+			default:
+				return UNKNOWN;
+		}
+	}
+}
--- a/src/main/java/org/monetdb/mcl/net/MapiSocket.java
+++ b/src/main/java/org/monetdb/mcl/net/MapiSocket.java
@@ -22,6 +22,7 @@ import java.net.Socket;
 import java.net.SocketException;
 import java.net.UnknownHostException;
 import java.net.URI;
+import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
 import java.util.ArrayList;
@@ -31,6 +32,7 @@ import java.util.List;
 import org.monetdb.mcl.MCLException;
 import org.monetdb.mcl.io.BufferedMCLReader;
 import org.monetdb.mcl.io.BufferedMCLWriter;
+import org.monetdb.mcl.io.LineType;
 import org.monetdb.mcl.parser.MCLParseException;
 
 /**
@@ -79,7 +81,7 @@ import org.monetdb.mcl.parser.MCLParseEx
  * geared towards the format of the data.
  *
  * @author Fabian Groffen
- * @version 4.1
+ * @version 4.2
  * @see org.monetdb.mcl.io.BufferedMCLReader
  * @see org.monetdb.mcl.io.BufferedMCLWriter
  */
@@ -89,7 +91,7 @@ public class MapiSocket {	/* cannot (yet
 	/** The TCP Socket timeout in milliseconds. Default is 0 meaning the timeout is disabled (i.e., timeout of infinity) */
 	private int soTimeout = 0;
 	/** Stream from the Socket for reading */
-	private InputStream fromMonet;
+	private BlockInputStream fromMonet;
 	/** Stream from the Socket for writing */
 	private OutputStream toMonet;
 	/** MCLReader on the InputStream */
@@ -293,7 +295,7 @@ public class MapiSocket {	/* cannot (yet
 		final ArrayList<String> redirects = new ArrayList<String>();
 		final List<String> warns = new ArrayList<String>();
 		String err = "", tmp;
-		int lineType;
+		LineType lineType;
 		do {
 			tmp = reader.readLine();
 			if (tmp == null)
@@ -301,14 +303,14 @@ public class MapiSocket {	/* cannot (yet
 						con.getInetAddress().getHostName() + ":" +
 						con.getPort() + ": End of stream reached");
 			lineType = reader.getLineType();
-			if (lineType == BufferedMCLReader.ERROR) {
+			if (lineType == LineType.ERROR) {
 				err += "\n" + tmp.substring(7);
-			} else if (lineType == BufferedMCLReader.INFO) {
+			} else if (lineType == LineType.INFO) {
 				warns.add(tmp.substring(1));
-			} else if (lineType == BufferedMCLReader.REDIRECT) {
+			} else if (lineType == LineType.REDIRECT) {
 				redirects.add(tmp.substring(1));
 			}
-		} while (lineType != BufferedMCLReader.PROMPT);
+		} while (lineType != LineType.PROMPT);
 
 		if (err.length() > 0) {
 			close();
@@ -344,26 +346,32 @@ public class MapiSocket {	/* cannot (yet
 						int pos = args[i].indexOf("=");
 						if (pos > 0) {
 							tmp = args[i].substring(0, pos);
-							if (tmp.equals("database")) {
-								tmp = args[i].substring(pos + 1);
-								if (!tmp.equals(database)) {
-									warns.add("redirect points to different database: " + tmp);
-									setDatabase(tmp);
-								}
-							} else if (tmp.equals("language")) {
-								tmp = args[i].substring(pos + 1);
-								warns.add("redirect specifies use of different language: " + tmp);
-								setLanguage(tmp);
-							} else if (tmp.equals("user")) {
-								tmp = args[i].substring(pos + 1);
-								if (!tmp.equals(user))
-									warns.add("ignoring different username '" + tmp + "' set by " +
-											"redirect, what are the security implications?");
-							} else if (tmp.equals("password")) {
-								warns.add("ignoring different password set by redirect, " +
-										"what are the security implications?");
-							} else {
-								warns.add("ignoring unknown argument '" + tmp + "' from redirect");
+							switch (tmp) {
+								case "database":
+									tmp = args[i].substring(pos + 1);
+									if (!tmp.equals(database)) {
+										warns.add("redirect points to different database: " + tmp);
+										setDatabase(tmp);
+									}
+									break;
+								case "language":
+									tmp = args[i].substring(pos + 1);
+									warns.add("redirect specifies use of different language: " + tmp);
+									setLanguage(tmp);
+									break;
+								case "user":
+									tmp = args[i].substring(pos + 1);
+									if (!tmp.equals(user))
+										warns.add("ignoring different username '" + tmp + "' set by " +
+												"redirect, what are the security implications?");
+									break;
+								case "password":
+									warns.add("ignoring different password set by redirect, " +
+											"what are the security implications?");
+									break;
+								default:
+									warns.add("ignoring unknown argument '" + tmp + "' from redirect");
+									break;
 							}
 						} else {
 							warns.add("ignoring illegal argument from redirect: " + args[i]);
@@ -423,6 +431,7 @@ public class MapiSocket {	/* cannot (yet
 	 * @param language the language to use
 	 * @param database the database to connect to
 	 * @param hash the hash method(s) to use, or NULL for all supported hashes
+	 * @return the response string for the server
 	 */
 	private String getChallengeResponse(
 			final String chalstr,
@@ -454,27 +463,30 @@ public class MapiSocket {	/* cannot (yet
 				String pwhash = chaltok[5];
 				/* NOTE: Java doesn't support RIPEMD160 :( */
 				/* see: https://docs.oracle.com/javase/8/docs/technotes/guides/security/StandardNames.html#MessageDigest */
-				if (pwhash.equals("SHA512")) {
-					algo = "SHA-512";
-				} else if (pwhash.equals("SHA384")) {
-					algo = "SHA-384";
-				} else if (pwhash.equals("SHA256")) {
-					algo = "SHA-256";
-				/* NOTE: Java 7 doesn't support SHA-224. Java 8 does but we have not tested it. It is also not requested yet. */
-				} else if (pwhash.equals("SHA1")) {
-					algo = "SHA-1";
-				} else {
-					/* Note: MD5 has been deprecated by security experts and support is removed from Oct 2020 release */
-					throw new MCLException("Unsupported password hash: " + pwhash);
+				switch (pwhash) {
+					case "SHA512":
+						algo = "SHA-512";
+						break;
+					case "SHA384":
+						algo = "SHA-384";
+						break;
+					case "SHA256":
+						algo = "SHA-256";
+						/* NOTE: Java 7 doesn't support SHA-224. Java 8 does but we have not tested it. It is also not requested yet. */
+						break;
+					case "SHA1":
+						algo = "SHA-1";
+						break;
+					default:
+						/* Note: MD5 has been deprecated by security experts and support is removed from Oct 2020 release */
+						throw new MCLException("Unsupported password hash: " + pwhash);
 				}
 				try {
 					final MessageDigest md = MessageDigest.getInstance(algo);
-					md.update(password.getBytes("UTF-8"));
+					md.update(password.getBytes(StandardCharsets.UTF_8));
 					password = toHex(md.digest());
 				} catch (NoSuchAlgorithmException e) {
-					throw new MCLException("This JVM does not support password hash: " + pwhash + "\n" + e.toString());
-				} catch (UnsupportedEncodingException e) {
-					throw new MCLException("This JVM does not support UTF-8 encoding\n" + e.toString());
+					throw new MCLException("This JVM does not support password hash: " + pwhash + "\n" + e);
 				}
 
 				// proto 7 (finally) used the challenge and works with a
@@ -517,13 +529,11 @@ public class MapiSocket {	/* cannot (yet
 				}
 				try {
 					final MessageDigest md = MessageDigest.getInstance(algo);
-					md.update(password.getBytes("UTF-8"));
-					md.update(chaltok[0].getBytes("UTF-8"));	// salt/key
+					md.update(password.getBytes(StandardCharsets.UTF_8));
+					md.update(chaltok[0].getBytes(StandardCharsets.UTF_8));	// salt/key
 					pwhash += toHex(md.digest());
 				} catch (NoSuchAlgorithmException e) {
-					throw new MCLException("This JVM does not support password hash: " + pwhash + "\n" + e.toString());
-				} catch (UnsupportedEncodingException e) {
-					throw new MCLException("This JVM does not support UTF-8 encoding\n" + e.toString());
+					throw new MCLException("This JVM does not support password hash: " + pwhash + "\n" + e);
 				}
 
 				// TODO: some day when we need this, we should store this
@@ -540,11 +550,9 @@ public class MapiSocket {	/* cannot (yet
 						+ username + ":"
 						+ pwhash + ":"
 						+ language + ":"
-						+ (database == null ? "" : database) + ":";
+						+ (database == null ? "" : database) + ":"
+						+ "FILETRANS:";	 // this capability is added in monetdb-jdbc-3.2.jre8.jar
 				if (chaltok.length > 6) {
-					// this ':' delimits the FILETRANS field, currently empty because we don't support it.
-					response += ":";
-
 					// if supported, send handshake options
 					for (String part : chaltok[6].split(",")) {
 						if (part.startsWith("sql=") && handshakeOptions != null) {
@@ -560,7 +568,6 @@ public class MapiSocket {	/* cannot (yet
 					}
 					// this ':' delimits the handshake options field.
 					response += ":";
-
 				}
 				return response;
 			default:
@@ -591,7 +598,6 @@ public class MapiSocket {	/* cannot (yet
 			: (char) ('0' + n);
 	}
 
-
 	/**
 	 * Returns an InputStream that reads from this open connection on
 	 * the MapiSocket.
@@ -721,6 +727,14 @@ public class MapiSocket {	/* cannot (yet
 	}
 
 	/**
+	 * For internal use
+	 */
+	public boolean setInsertFakePrompts(boolean b) {
+		return fromMonet.setInsertFakePrompts(b);
+	}
+
+
+	/**
 	 * Inner class that is used to write data on a normal stream as a
 	 * blocked stream.  A call to the flush() method will write a
 	 * "final" block to the underlying stream.  Non-final blocks are
@@ -797,7 +811,7 @@ public class MapiSocket {	/* cannot (yet
 				} else {
 					log("TD ", "write block: " + writePos + " bytes", false);
 				}
-				log("TX ", new String(block, 0, writePos, "UTF-8"), true);
+				log("TX ", new String(block, 0, writePos, StandardCharsets.UTF_8), true);
 			}
 
 			writePos = 0;
@@ -818,9 +832,8 @@ public class MapiSocket {	/* cannot (yet
 
 		@Override
 		public void write(final byte[] b, int off, int len) throws IOException {
-			int t = 0;
 			while (len > 0) {
-				t = BLOCK - writePos;
+				int t = BLOCK - writePos;
 				if (len > t) {
 					System.arraycopy(b, off, block, writePos, t);
 					off += t;
@@ -851,7 +864,9 @@ public class MapiSocket {	/* cannot (yet
 	final class BlockInputStream extends FilterInputStream {
 		private int readPos = 0;
 		private int blockLen = 0;
+		private boolean wasEndBlock = false;
 		private final byte[] block = new byte[BLOCK + 3]; // \n.\n
+		private boolean insertFakePrompts = true;
 
 		/**
 		 * Constructs this BlockInputStream, backed by the given
@@ -864,6 +879,12 @@ public class MapiSocket {	/* cannot (yet
 			super(new BufferedInputStream(in));
 		}
 
+		public boolean setInsertFakePrompts(boolean doFake) {
+			boolean old = insertFakePrompts;
+			insertFakePrompts = doFake;
+			return old;
+		}
+
 		@Override
 		public int available() {
 			return blockLen - readPos;
@@ -907,7 +928,7 @@ public class MapiSocket {	/* cannot (yet
 					if (off > 0) {
 						if (debug) {
 							log("RD ", "the following incomplete block was received:", false);
-							log("RX ", new String(b, 0, off, "UTF-8"), true);
+							log("RX ", new String(b, 0, off, StandardCharsets.UTF_8), true);
 						}
 						throw new IOException("Read from " +
 								con.getInetAddress().getHostName() + ":" +
@@ -957,10 +978,12 @@ public class MapiSocket {	/* cannot (yet
 					(blklen[0] & 0xFF) >> 1 |
 					(blklen[1] & 0xFF) << 7
 					);
+			wasEndBlock = (blklen[0] & 0x1) == 1;
+
 			readPos = 0;
 
 			if (debug) {
-				if ((blklen[0] & 0x1) == 1) {
+				if (wasEndBlock) {
 					log("RD ", "read final block: " + blockLen + " bytes", false);
 				} else {
 					log("RD ", "read new block: " + blockLen + " bytes", false);
@@ -976,19 +999,24 @@ public class MapiSocket {	/* cannot (yet
 				return -1;
 
 			if (debug)
-				log("RX ", new String(block, 0, blockLen, "UTF-8"), true);
+				log("RX ", new String(block, 0, blockLen, StandardCharsets.UTF_8), true);
 
 			// if this is the last block, make it end with a newline and prompt
-			if ((blklen[0] & 0x1) == 1) {
-				if (blockLen > 0 && block[blockLen - 1] != '\n') {
-					// to terminate the block in a Reader
+			if (wasEndBlock) {
+				// insert 'fake' newline and prompt
+				if (insertFakePrompts) {
+					if (blockLen > 0 && block[blockLen - 1] != '\n') {
+						// to terminate the block in a Reader
+						block[blockLen++] = '\n';
+					}
+					for (byte b : LineType.PROMPT.bytes()) {
+						block[blockLen++] = b;
+					}
 					block[blockLen++] = '\n';
+					if (debug) {
+						log("RD ", "inserting prompt", true);
+					}
 				}
-				// insert 'fake' flush
-				block[blockLen++] = BufferedMCLReader.PROMPT;
-				block[blockLen++] = '\n';
-				if (debug)
-					log("RD ", "inserting prompt", true);
 			}
 
 			return blockLen;
@@ -1002,7 +1030,7 @@ public class MapiSocket {	/* cannot (yet
 			}
 
 			if (debug)
-				log("RX ", new String(block, readPos, 1, "UTF-8"), true);
+				log("RX ", new String(block, readPos, 1, StandardCharsets.UTF_8), true);
 
 			return (int)block[readPos++];
 		}
@@ -1047,9 +1075,8 @@ public class MapiSocket {	/* cannot (yet
 		@Override
 		public long skip(final long n) throws IOException {
 			long skip = n;
-			int t = 0;
 			while (skip > 0) {
-				t = available();
+				int t = available();
 				if (skip > t) {
 					skip -= t;
 					readPos += t;
@@ -1061,6 +1088,47 @@ public class MapiSocket {	/* cannot (yet
 			}
 			return n;
 		}
+
+		/**
+		 * For internal use
+		 */
+		Raw getRaw() {
+			return new Raw();
+		}
+
+		/** An alternative I/O interface that exposes the block based nature of the MAPI protocol */
+		final class Raw {
+			byte[] getBytes() {
+				return block;
+			}
+
+			int getLength() {
+				return blockLen;
+			}
+
+			int getPosition() {
+				return readPos;
+			}
+
+			int consume(int delta) {
+				int pos = readPos;
+				readPos += delta;
+				return pos;
+			}
+
+			int readBlock() throws IOException {
+				boolean wasFaking = setInsertFakePrompts(false);
+				try {
+					return BlockInputStream.this.readBlock();
+				} finally {
+					setInsertFakePrompts(wasFaking);
+				}
+			}
+
+			boolean wasEndBlock() {
+				return wasEndBlock;
+			}
+		}
 	}
 
 	/**
@@ -1108,6 +1176,34 @@ public class MapiSocket {	/* cannot (yet
 	}
 
 	/**
+	 * Return an UploadStream for use with for example COPY FROM filename ON CLIENT.
+	 *
+	 * Building block for {@link org.monetdb.jdbc.MonetConnection.UploadHandler}.
+	 * @param chunkSize chunk size for the upload stream
+	 */
+	public UploadStream uploadStream(int chunkSize) {
+		return new UploadStream(chunkSize);
+	}
+
+	/**
+	 * Return an UploadStream for use with for example COPY FROM filename ON CLIENT.
+	 *
+	 * Building block for {@link org.monetdb.jdbc.MonetConnection.UploadHandler}.
+	 */
+	public UploadStream uploadStream() {
+		return new UploadStream();
+	}
+
+	/**
+	 * Return a DownloadStream for use with for example COPY INTO filename ON CLIENT
+	 *
+	 * Building block for {@link org.monetdb.jdbc.MonetConnection.DownloadHandler}.
+	 */
+	public DownloadStream downloadStream() {
+		return new DownloadStream(fromMonet.getRaw(), toMonet);
+	}
+
+	/**
 	 * Destructor called by garbage collector before destroying this
 	 * object tries to disconnect the MonetDB connection if it has not
 	 * been disconnected already.
@@ -1120,4 +1216,233 @@ public class MapiSocket {	/* cannot (yet
 		close();
 		super.finalize();
 	}
+
+
+	/**
+	 * Stream of data sent to the server
+	 *
+	 * Building block for {@link org.monetdb.jdbc.MonetConnection.UploadHandler}.
+	 *
+	 * An UploadStream has a chunk size. Every chunk size bytes, the server gets
+	 * the opportunity to abort the upload.
+	 */
+	public class UploadStream extends FilterOutputStream {
+		public final static int DEFAULT_CHUNK_SIZE = 1024 * 1024;
+		private final int chunkSize;
+		private boolean closed = false;
+		private boolean serverCancelled = false;
+		private int chunkLeft;
+		private byte[] promptBuffer;
+		private Runnable cancellationCallback = null;
+
+		/** Create an UploadStream with the given chunk size */
+		UploadStream(final int chunkSize) {
+			super(toMonet);
+			if (chunkSize <= 0) {
+				throw new IllegalArgumentException("chunk size must be positive");
+			}
+			this.chunkSize = chunkSize;
+			assert LineType.MORE.bytes().length == LineType.FILETRANSFER.bytes().length;
+			int promptLen = LineType.MORE.bytes().length;
+			promptBuffer = new byte[promptLen + 1];
+			chunkLeft = this.chunkSize;
+		}
+
+		/** Create an UploadStream with the default chunk size */
+		UploadStream() {
+			this(DEFAULT_CHUNK_SIZE);
+		}
+
+		/** Set a callback to be invoked if the server cancels the upload
+		 */
+		public void setCancellationCallback(final Runnable cancellationCallback) {
+			this.cancellationCallback = cancellationCallback;
+		}
+
+		@Override
+		public void write(final int b) throws IOException {
+			if (serverCancelled) {
+				// We have already thrown an exception and apparently that has been ignored.
+				// Probably because they're calling print methods instead of write.
+				// Throw another one, maybe they'll catch this one.
+				throw new IOException("Server aborted the upload");
+			}
+			handleChunking();
+			super.write(b);
+			wrote(1);
+		}
+
+		@Override
+		public void write(final byte[] b) throws IOException {
+			write(b, 0, b.length);
+		}
+
+		@Override
+		public void write(final byte[] b, int off, int len) throws IOException {
+			if (serverCancelled) {
+				// We have already thrown an exception and apparently that has been ignored.
+				// Probably because they're calling print methods instead of write.
+				// Throw another one, maybe they'll catch this one.
+				throw new IOException("Server aborted the upload");
+			}
+			while (len > 0) {
+				handleChunking();
+				int toWrite = Integer.min(len, chunkLeft);
+				super.write(b, off, toWrite);
+				off += toWrite;
+				len -= toWrite;
+				wrote(toWrite);
+			}
+		}
+
+		@Override
+		public void flush() throws IOException {
+			// suppress flushes
+		}
+
+		@Override
+		public void close() throws IOException {
+			if (closed) {
+				return;
+			}
+			closed = true;
+
+			if (serverCancelled)
+				closeAfterServerCancelled();
+			else
+				closeAfterSuccesfulUpload();
+		}
+
+		private void closeAfterSuccesfulUpload() throws IOException {
+			if (chunkLeft != chunkSize) {
+				// flush pending data
+				flushAndReadPrompt();
+			}
+			// send empty block
+			out.flush();
+			final LineType acknowledgement = readPrompt();
+			if (acknowledgement != LineType.FILETRANSFER) {
+				throw new IOException("Expected server to acknowledge end of file");
+			}
+		}
+
+		private void closeAfterServerCancelled() {
+			// nothing to do here, we have already read the error prompt.
+		}
+
+		private void wrote(final int i) {
+			chunkLeft -= i;
+		}
+
+		private void handleChunking() throws IOException {
+			if (chunkLeft > 0) {
+				return;
+			}
+			flushAndReadPrompt();
+		}
+
+		private void flushAndReadPrompt() throws IOException {
+			out.flush();
+			chunkLeft = chunkSize;
+			final LineType lineType = readPrompt();
+			switch (lineType) {
+				case MORE:
+					return;
+				case FILETRANSFER:
+					// Note, if the caller is calling print methods instead of write, the IO exception gets hidden.
+					// This is unfortunate but there's nothing we can do about it.
+					serverCancelled = true;
+					if (cancellationCallback != null) {
+						cancellationCallback.run();
+					}
+					throw new IOException("Server aborted the upload");
+				default:
+					throw new IOException("Expected MORE/DONE from server, got " + lineType);
+			}
+		}
+
+		private LineType readPrompt() throws IOException {
+			final int nread = fromMonet.read(promptBuffer);
+			if (nread != promptBuffer.length || promptBuffer[promptBuffer.length - 1] != '\n') {
+				throw new IOException("server return incomplete prompt");
+			}
+			return LineType.classify(promptBuffer);
+		}
+	}
+
+
+	/**
+	 * Stream of data received from the server
+	 *
+	 * Building block for {@link org.monetdb.jdbc.MonetConnection.DownloadHandler}.
+	 */
+	public static class DownloadStream extends InputStream {
+		private final BlockInputStream.Raw rawIn;
+		private final OutputStream out;
+		private boolean endBlockSeen = false;
+		private boolean closed = false;
+
+		DownloadStream(BlockInputStream.Raw rawIn, OutputStream out) {
+			this.rawIn = rawIn;
+			this.out = out;
+		}
+
+		void nextBlock() throws IOException {
+			if (endBlockSeen || closed)
+				return;
+			final int ret = rawIn.readBlock();
+			if (ret < 0 || rawIn.wasEndBlock()) {
+				endBlockSeen = true;
+			}
+		}
+
+		@Override
+		public void close() throws IOException {
+			if (closed)
+				return;
+			closed = true;
+			while (!endBlockSeen) {
+				nextBlock();
+			}
+			// Send acknowledgement to server
+			out.write('\n');
+			out.flush();
+			// Do whatever super has to do
+			super.close();
+		}
+
+		@Override
+		public int read() throws IOException {
+			final byte[] buf = { 0 };
+			final int nread = read(buf, 0, 1);
+			if (nread == 1)
+				return buf[0];
+			else
+				return -1;
+		}
+
+		@Override
+		public int read(final byte[] b, int off, int len) throws IOException {
+			final int origOff = off;
+			while (len > 0) {
+				int chunk = Integer.min(len, rawIn.getLength() - rawIn.getPosition());
+				if (chunk > 0) {
+					// make progress copying some bytes
+					System.arraycopy(rawIn.getBytes(), rawIn.getPosition(), b, off, chunk);
+					off += chunk;
+					rawIn.consume(chunk);
+					len -= chunk;
+				} else {
+					// make progress fetching data
+					if (endBlockSeen)
+						break;
+					nextBlock();
+				}
+			}
+			if (off == origOff && endBlockSeen)
+				return -1;
+			else
+				return off - origOff;
+		}
+	}
 }
--- a/src/main/java/org/monetdb/merovingian/Control.java
+++ b/src/main/java/org/monetdb/merovingian/Control.java
@@ -207,17 +207,17 @@ public class Control {
 		mout.writeLine(database + " " + command + "\n");
 		ArrayList<String> l = new ArrayList<String>();
 		String tmpLine = min.readLine();
-		int linetype = min.getLineType();
-		if (linetype == BufferedMCLReader.ERROR)
+		LineType linetype = min.getLineType();
+		if (linetype == LineType.ERROR)
 			throw new MerovingianException(tmpLine.substring(6));
-		if (linetype != BufferedMCLReader.RESULT)
+		if (linetype != LineType.RESULT)
 			throw new MerovingianException("unexpected line: " + tmpLine);
 		if (!tmpLine.substring(1).equals(RESPONSE_OK))
 			throw new MerovingianException(tmpLine.substring(1));
 		tmpLine = min.readLine();
 		linetype = min.getLineType();
-		while (linetype != BufferedMCLReader.PROMPT) {
-			if (linetype != BufferedMCLReader.RESULT)
+		while (linetype != LineType.PROMPT) {
+			if (linetype != LineType.RESULT)
 				throw new MerovingianException("unexpected line: " +
 						tmpLine);
 
new file mode 100644
--- /dev/null
+++ b/src/main/java/org/monetdb/util/FileTransferHandler.java
@@ -0,0 +1,107 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0.  If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * Copyright 1997 - July 2008 CWI, August 2008 - 2021 MonetDB B.V.
+ */
+
+package org.monetdb.util;
+
+import org.monetdb.jdbc.MonetConnection;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+
+/**
+ * Sample implementation of COPY ... INTO 'file-name' ON CLIENT handling
+ *
+ * Can be registered with {@link MonetConnection#setUploadHandler(MonetConnection.UploadHandler)}
+ * and {@link MonetConnection#setDownloadHandler(MonetConnection.DownloadHandler)}.
+ * Implements uploads and downloads by reading and writing files on the file system.
+ */
+public class FileTransferHandler implements MonetConnection.UploadHandler, MonetConnection.DownloadHandler {
+	private final Path root;
+	private final Charset encoding;
+
+	/**
+	 * Create a new FileTransferHandler which serves the given directory.
+	 * @param dir directory to read and write files from
+	 * @param encoding set this to true if all files in the directory are known to be utf-8 encoded.
+	 */
+	public FileTransferHandler(final Path dir, final Charset encoding) {
+		this.root = dir.toAbsolutePath().normalize();
+		this.encoding = encoding != null ? encoding: Charset.defaultCharset();
+	}
+
+	/**
+	 * Create a new FileTransferHandler which serves the given directory.
+	 *
+	 * @param dir directory to read and write files from
+	 * @param utf8Encoded set this to true if all files in the directory are known to be utf-8 encoded.
+	 */
+	public FileTransferHandler(final String dir, final Charset encoding) {
+		this(FileSystems.getDefault().getPath(dir), encoding);
+	}
+
+	public void handleUpload(final MonetConnection.Upload handle, final String name, final boolean textMode, final long linesToSkip) throws IOException {
+		final Path path = root.resolve(name).normalize();
+		if (!path.startsWith(root)) {
+			handle.sendError("File is not in upload directory");
+			return;
+		}
+		if (!Files.isReadable(path)) {
+			handle.sendError("Cannot read " + name);
+			return;
+		}
+		if (!textMode) {
+			// must upload as a byte stream
+			handle.uploadFrom(Files.newInputStream(path));
+		} else if (linesToSkip == 0 && utf8Encoded()) {
+			// more efficient to upload as a byte stream
+			handle.uploadFrom(Files.newInputStream(path));
+		} else {
+			// cannot upload as a byte stream, must deal with encoding
+			final BufferedReader reader = Files.newBufferedReader(path, encoding);
+			handle.uploadFrom(reader, linesToSkip);
+		}
+	}
+
+	public void handleDownload(final MonetConnection.Download handle, final String name, final boolean textMode) throws IOException {
+		final Path path = root.resolve(name).normalize();
+		if (!path.startsWith(root)) {
+			handle.sendError("File is not in download directory");
+			return;
+		}
+		if (Files.exists(path)) {
+			handle.sendError("File already exists: " + name);
+			return;
+		}
+		if (!textMode) {
+			// must download as a byte stream
+			final OutputStream outputStream = Files.newOutputStream(path, StandardOpenOption.CREATE_NEW);
+			handle.downloadTo(outputStream);
+		} else if (utf8Encoded()) {
+			// more efficient to download as a byte stream
+			final OutputStream outputStream = Files.newOutputStream(path, StandardOpenOption.CREATE_NEW);
+			handle.downloadTo(outputStream);
+		} else {
+			// cannot download as a byte stream, must deal with encoding
+			final BufferedWriter writer = Files.newBufferedWriter(path, encoding, StandardOpenOption.CREATE_NEW);
+			handle.downloadTo(writer);
+			writer.close();
+		}
+	}
+
+	public boolean utf8Encoded() {
+		return encoding.equals(StandardCharsets.UTF_8);
+	}
+}
--- a/src/main/java/org/monetdb/util/SQLExporter.java
+++ b/src/main/java/org/monetdb/util/SQLExporter.java
@@ -427,7 +427,6 @@ public final class SQLExporter extends E
 	 * format.
 	 *
 	 * @param rs the ResultSet to convert into INSERT INTO statements
-	 * @param absolute if true, dumps table name prepended with schema name
 	 * @throws SQLException if a database related error occurs
 	 */
 	private void resultSetToSQL(final ResultSet rs)
--- a/src/main/java/org/monetdb/util/SQLRestore.java
+++ b/src/main/java/org/monetdb/util/SQLRestore.java
@@ -15,6 +15,7 @@ import java.util.concurrent.atomic.Atomi
 
 import org.monetdb.mcl.io.BufferedMCLReader;
 import org.monetdb.mcl.io.BufferedMCLWriter;
+import org.monetdb.mcl.io.LineType;
 import org.monetdb.mcl.net.MapiSocket;
 
 /**
@@ -53,9 +54,9 @@ public final class SQLRestore {
 					final String line = _is.readLine();
 					if (line == null)
 						break;
-					final int result = _is.getLineType();
+					final LineType result = _is.getLineType();
 					switch (result) {
-					case BufferedMCLReader.ERROR:
+					case ERROR:
 						_errorMessage = line;
 						_errorState.set(true);
 						return;
--- a/tests/JDBC_API_Tester.java
+++ b/tests/JDBC_API_Tester.java
@@ -104,6 +104,11 @@ final public class JDBC_API_Tester {
 
 		if (jt.foundDifferences)
 			System.exit(-1);
+
+		OnClientTester oct = new OnClientTester(con_URL, 0, true);
+		int failures = oct.runTests("");
+		if (failures > 0)
+			System.exit(-1);
 	}
 
 	private void Test_Cautocommit(String arg0) {
new file mode 100644
--- /dev/null
+++ b/tests/OnClientTester.java
@@ -0,0 +1,576 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0.  If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * Copyright 1997 - July 2008 CWI, August 2008 - 2021 MonetDB B.V.
+ */
+
+import org.monetdb.jdbc.MonetConnection;
+import org.monetdb.jdbc.MonetConnection.UploadHandler;
+import org.monetdb.jdbc.MonetConnection.DownloadHandler;
+import org.monetdb.util.FileTransferHandler;
+
+import java.io.*;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.SQLException;
+import java.util.List;
+
+import static java.nio.file.StandardOpenOption.CREATE_NEW;
+
+public final class OnClientTester extends TestRunner {
+
+	public OnClientTester(String jdbcUrl, int verbosity, boolean watchDogEnabled) {
+		super(jdbcUrl, verbosity, watchDogEnabled);
+	}
+
+	public static void main(String[] args) throws SQLException, NoSuchMethodException, ClassNotFoundException {
+		String jdbcUrl = null;
+		String requiredPrefix = null;
+		int verbosity = 0;
+		boolean watchDogEnabled = true;
+
+		// Don't know why I need this all of a sudden.. is it only on my system?
+		Class.forName("org.monetdb.jdbc.MonetDriver");
+
+		for (String arg : args) {
+			if (arg.equals("-v"))
+				verbosity++;
+			else if (arg.equals("-vv"))
+				verbosity += 2;
+			else if (arg.equals("-w"))
+				watchDogEnabled = false;
+			else if (jdbcUrl == null)
+				jdbcUrl = arg;
+			else if (requiredPrefix == null)
+				requiredPrefix = arg;
+			else {
+				System.err.println("Unexpected argument " + arg);
+				System.exit(2);
+			}
+		}
+
+		OnClientTester tester = new OnClientTester(jdbcUrl, verbosity, watchDogEnabled);
+		int failures = tester.runTests(requiredPrefix);
+
+		if (failures > 0)
+			System.exit(1);
+	}
+
+	/// Some tests have to work limitations of the protocol or bugs in the server.
+	/// This Enum is used to indicate the possibilities.
+	public enum BugFixLevel {
+		/// Only those tests that work with older MonetDB versions
+		Baseline(0, 0, 0),
+
+		/// Connection keeps working after download request has been refused by client
+		CanRefuseDownload(11, 41, 12),
+
+		;
+
+		private final int major;
+		private final int minor;
+		private final int micro;
+
+		BugFixLevel(int major, int minor, int micro) {
+			this.major = major;
+			this.minor = minor;
+			this.micro = micro;
+		}
+
+		boolean includesVersion(int major, int minor, int micro) {
+			if (major > this.major)
+				return true;
+			if (major < this.major)
+				return false;
+			if (minor > this.minor)
+				return true;
+			if (minor < this.minor)
+				return false;
+			return micro >= this.micro;
+		}
+
+		static BugFixLevel forVersion(String version) {
+			String[] parts = version.split("[.]", 3);
+			assert parts.length == 3;
+			int major = Integer.parseInt(parts[0]);
+			int minor = Integer.parseInt(parts[1]);
+			int micro = Integer.parseInt(parts[2]);
+
+			return BugFixLevel.forVersion(major, minor, micro);
+		}
+
+		static BugFixLevel forVersion(int major, int minor, int micro) {
+			BugFixLevel lastValid = Baseline;
+			for (BugFixLevel level: BugFixLevel.values()) {
+				if (level.includesVersion(major, minor, micro))
+					lastValid = level;
+				else
+					break;
+			}
+			return lastValid;
+		}
+	}
+
+	void prepare() throws SQLException {
+		execute("DROP TABLE IF EXISTS foo");
+		execute("CREATE TABLE foo (i INT, t TEXT)");
+	}
+
+	private BugFixLevel getLevel() throws SQLException, Failure {
+		String version = queryString("SELECT value FROM environment WHERE name = 'monet_version'");
+		BugFixLevel level = BugFixLevel.forVersion(version);
+		out.println("  NOTE: version " + version + " means level = " + level);
+		return level;
+	}
+
+	public void test_BugFixLevel() throws Failure {
+		assertEq("Baseline includes 0.0.0", true, BugFixLevel.Baseline.includesVersion(0, 0, 0));
+		assertEq("Baseline includes 11.41.11", true, BugFixLevel.Baseline.includesVersion(11, 41, 11));
+		assertEq("Baseline includes 11.41.12", true, BugFixLevel.Baseline.includesVersion(11, 41, 12));
+
+		assertEq("CanRefuseDownload includes 0.0.0", false, BugFixLevel.CanRefuseDownload.includesVersion(0, 0, 0));
+
+		assertEq("CanRefuseDownload includes 11.0.0", false, BugFixLevel.CanRefuseDownload.includesVersion(11, 0, 0));
+		assertEq("CanRefuseDownload includes 12.0.0", true, BugFixLevel.CanRefuseDownload.includesVersion(12, 0, 0));
+
+		assertEq("CanRefuseDownload includes 11.41.0", false, BugFixLevel.CanRefuseDownload.includesVersion(11, 41, 0));
+		assertEq("CanRefuseDownload includes 11.42.0", true, BugFixLevel.CanRefuseDownload.includesVersion(11, 42, 0));
+
+		assertEq("CanRefuseDownload includes 11.41.11", false, BugFixLevel.CanRefuseDownload.includesVersion(11, 41, 11));
+		assertEq("CanRefuseDownload includes 11.41.12", true, BugFixLevel.CanRefuseDownload.includesVersion(11, 41, 12));
+
+		assertEq("Level for 0.0.0", BugFixLevel.Baseline, BugFixLevel.forVersion(0, 0, 0));
+		assertEq("Level for 11.0.0", BugFixLevel.Baseline, BugFixLevel.forVersion(11, 0, 0));
+		assertEq("Level for 11.41.0", BugFixLevel.Baseline, BugFixLevel.forVersion(11, 41, 0));
+		assertEq("Level for 11.41.11", BugFixLevel.Baseline, BugFixLevel.forVersion(11, 41, 11));
+		assertEq("Level for 11.41.12", BugFixLevel.CanRefuseDownload, BugFixLevel.forVersion(11, 41, 12));
+		assertEq("Level for 11.42.0", BugFixLevel.CanRefuseDownload, BugFixLevel.forVersion(11, 42, 0));
+		assertEq("Level for 12.0.0", BugFixLevel.CanRefuseDownload, BugFixLevel.forVersion(12, 0, 0));
+
+		assertEq("Level for \"11.41.11\"", BugFixLevel.Baseline, BugFixLevel.forVersion("11.41.11"));
+		assertEq("Level for \"11.41.12\"", BugFixLevel.CanRefuseDownload, BugFixLevel.forVersion("11.41.12"));
+	}
+
+	public void test_Upload() throws SQLException, Failure {
+		prepare();
+		MyUploadHandler handler = new MyUploadHandler(100);
+		conn.setUploadHandler(handler);
+		update("COPY INTO foo FROM 'banana' ON CLIENT");
+		assertEq("cancellation callback called", false, handler.isCancelled());
+		assertQueryInt("SELECT COUNT(*) FROM foo", 100);
+	}
+
+	public void test_ClientRefusesUpload() throws SQLException, Failure {
+		prepare();
+		MyUploadHandler handler = new MyUploadHandler("immediate error");
+		conn.setUploadHandler(handler);
+		expectError("COPY INTO foo FROM 'banana' ON CLIENT", "immediate error");
+		assertEq("cancellation callback called", false, handler.isCancelled());
+		assertQueryInt("SELECT COUNT(*) FROM foo", 0);
+	}
+
+	public void test_Offset0() throws SQLException, Failure {
+		prepare();
+		MyUploadHandler handler = new MyUploadHandler(100);
+		conn.setUploadHandler(handler);
+		update("COPY OFFSET 0 INTO foo FROM 'banana' ON CLIENT");
+		assertEq("cancellation callback called", false, handler.isCancelled());
+		assertQueryInt("SELECT MIN(i) FROM foo", 1);
+		assertQueryInt("SELECT MAX(i) FROM foo", 100);
+	}
+
+	public void test_Offset1() throws SQLException, Failure {
+		prepare();
+		MyUploadHandler handler = new MyUploadHandler(100);
+		conn.setUploadHandler(handler);
+		update("COPY OFFSET 1 INTO foo FROM 'banana' ON CLIENT");
+		assertEq("cancellation callback called", false, handler.isCancelled());
+		assertQueryInt("SELECT MIN(i) FROM foo", 1);
+		assertQueryInt("SELECT MAX(i) FROM foo", 100);
+	}
+
+	public void test_Offset5() throws SQLException, Failure {
+		prepare();
+		MyUploadHandler handler = new MyUploadHandler(100);
+		conn.setUploadHandler(handler);
+		update("COPY OFFSET 5 INTO foo FROM 'banana' ON CLIENT");
+		assertEq("cancellation callback called", false, handler.isCancelled());
+		assertQueryInt("SELECT MIN(i) FROM foo", 5);
+		assertQueryInt("SELECT MAX(i) FROM foo", 100);
+	}
+
+	public void test_ServerStopsReading() throws SQLException, Failure {
+		prepare();
+		MyUploadHandler handler = new MyUploadHandler(100);
+		conn.setUploadHandler(handler);
+		update("COPY 10 RECORDS INTO foo FROM 'banana' ON CLIENT");
+		assertEq("cancellation callback called", true, handler.isCancelled());
+		assertEq("handler encountered write error", true, handler.encounteredWriteError());
+		// connection is still alive
+		assertQueryInt("SELECT COUNT(i) FROM foo", 10);
+	}
+
+	public void test_Download(int n) throws SQLException, Failure {
+		prepare();
+		MyDownloadHandler handler = new MyDownloadHandler();
+		conn.setDownloadHandler(handler);
+		String q = "INSERT INTO foo SELECT value as i, 'number' || value AS t FROM sys.generate_series(0, " + n + ")";
+		update(q);
+		update("COPY (SELECT * FROM foo) INTO 'banana' ON CLIENT");
+		assertEq("download attempts", 1, handler.countAttempts());
+		assertEq("lines downloaded", n, handler.lineCount());
+		// connection is still alive
+		assertQueryInt("SELECT COUNT(*) FROM foo", n);
+	}
+
+	public void test_Download() throws SQLException, Failure {
+		test_Download(100);
+	}
+
+	public void test_ClientRefusesDownload() throws SQLException, Failure {
+		prepare();
+		BugFixLevel level = getLevel();
+		MyDownloadHandler handler = new MyDownloadHandler("download refused");
+		conn.setDownloadHandler(handler);
+		update("INSERT INTO foo SELECT value as i, 'number' || value AS t FROM sys.generate_series(0, 100)");
+		expectError("COPY (SELECT * FROM foo) INTO 'banana' ON CLIENT", "download refused");
+		// Wish it were different but the server closes the connection
+		expectError("SELECT 42 -- check if the connection still works", "Connection to server lost!");
+		if (level.compareTo(BugFixLevel.CanRefuseDownload) >= 0) {
+			// connection is still alive
+			assertQueryInt("SELECT COUNT(*) FROM foo", 100);
+		}
+	}
+
+	public void test_LargeUpload() throws SQLException, Failure {
+		watchDog.setDuration(25_000);
+		prepare();
+		int n = 4_000_000;
+		MyUploadHandler handler = new MyUploadHandler(n);
+		conn.setUploadHandler(handler);
+		handler.setChunkSize(1024 * 1024);
+		update("COPY INTO foo FROM 'banana' ON CLIENT");
+		assertEq("cancellation callback called", false, handler.isCancelled());
+		// connection is still alive
+		assertQueryInt("SELECT COUNT(DISTINCT i) FROM foo", n);
+	}
+
+	public void test_LargeDownload() throws SQLException, Failure {
+		watchDog.setDuration(25_000);
+		test_Download(4_000_000);
+	}
+
+	public void test_UploadFromStream() throws SQLException, Failure {
+		prepare();
+		UploadHandler handler = new UploadHandler() {
+			final String data = "1|one\n2|two\n3|three\n";
+
+			@Override
+			public void handleUpload(MonetConnection.Upload handle, String name, boolean textMode, long linesToSkip) throws IOException {
+				// ignoring linesToSkip as it's not used in this test
+				ByteArrayInputStream s = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8));
+				handle.uploadFrom(s);
+			}
+		};
+		conn.setUploadHandler(handler);
+		update("COPY INTO foo FROM 'banana' ON CLIENT");
+		// connection is still alive
+		assertQueryInt("SELECT i FROM foo WHERE t = 'three'", 3);
+	}
+
+	public void test_UploadFromReader() throws SQLException, Failure {
+		prepare();
+		UploadHandler handler = new UploadHandler() {
+			final String data = "1|one\n2|two\n3|three\n";
+
+			@Override
+			public void handleUpload(MonetConnection.Upload handle, String name, boolean textMode, long linesToSkip) throws IOException {
+				// ignoring linesToSkip as it's not used in this test
+				StringReader r = new StringReader(data);
+				handle.uploadFrom(r);
+			}
+		};
+		conn.setUploadHandler(handler);
+		update("COPY INTO foo FROM 'banana' ON CLIENT");
+		assertQueryInt("SELECT i FROM foo WHERE t = 'three'", 3);
+	}
+
+	public void test_UploadFromReaderOffset() throws SQLException, Failure {
+		prepare();
+		UploadHandler handler = new UploadHandler() {
+			final String data = "1|one\n2|two\n3|three\n";
+
+			@Override
+			public void handleUpload(MonetConnection.Upload handle, String name, boolean textMode, long linesToSkip) throws IOException {
+				BufferedReader r = new BufferedReader(new StringReader(data));
+				handle.uploadFrom(r, linesToSkip);
+			}
+		};
+		conn.setUploadHandler(handler);
+		update("COPY OFFSET 2 INTO foo FROM 'banana' ON CLIENT");
+		assertQueryInt("SELECT i FROM foo WHERE t = 'three'", 3);
+	}
+
+	public void test_FailUploadLate() throws SQLException, Failure {
+		prepare();
+		MyUploadHandler handler = new MyUploadHandler(100, 50, "i don't like line 50");
+		conn.setUploadHandler(handler);
+		expectError("COPY INTO foo FROM 'banana' ON CLIENT", "i don't like");
+		assertEq("cancellation callback called", false, handler.isCancelled());
+		assertEq("connection is closed", true, conn.isClosed());
+	}
+
+
+	public void test_FailUploadLate2() throws SQLException, Failure {
+		// Here we send empty lines only, to check if the server detects is properly instead
+		// of simply complaining about an incomplete file.
+		prepare();
+		UploadHandler handler = new UploadHandler() {
+			@Override
+			public void handleUpload(MonetConnection.Upload handle, String name, boolean textMode, long linesToSkip) throws IOException {
+				// ignoring linesToSkip as it's not used in this test
+				PrintStream stream = handle.getStream();
+				for (int i = 1; i <= 20_000; i++)
+					stream.println();
+				stream.flush();
+				throw new IOException("exception after all");
+			}
+		};
+		conn.setUploadHandler(handler);
+		expectError("COPY INTO foo(t) FROM 'banana'(t) ON CLIENT", "after all");
+		assertEq("connection is closed", true, conn.isClosed());
+		// Cannot check the server log, but at the time I checked, it said "prematurely stopped client", which is fine.
+	}
+
+	public void test_FailDownloadLate() throws SQLException, Failure {
+		prepare();
+		MyDownloadHandler handler = new MyDownloadHandler(200, "download refused");
+		conn.setDownloadHandler(handler);
+		update("INSERT INTO foo SELECT value as i, 'number' || value AS t FROM sys.generate_series(0, 100)");
+		expectError("COPY (SELECT * FROM sys.generate_series(0,200)) INTO 'banana' ON CLIENT", "download refused");
+		// Exception closes the connection
+		assertEq("connection is closed", conn.isClosed(), true);
+	}
+
+	public void test_FileTransferHandlerUploadUtf8() throws IOException, SQLException, Failure {
+		testFileTransferHandlerUpload(StandardCharsets.UTF_8, "UTF-8");
+	}
+
+	public void test_FileTransferHandlerUploadLatin1() throws IOException, SQLException, Failure {
+		testFileTransferHandlerUpload(Charset.forName("latin1"), "latin1");
+	}
+
+	public void test_FileTransferHandlerUploadNull() throws IOException, SQLException, Failure {
+		testFileTransferHandlerUpload(null, Charset.defaultCharset().name());
+	}
+
+	public void testFileTransferHandlerUpload(Charset handlerEncoding, String fileEncoding) throws IOException, SQLException, Failure {
+		prepare();
+		Path d = getTmpDir(currentTestName);
+		Path f = d.resolve("data.txt");
+		OutputStream s = Files.newOutputStream(f, CREATE_NEW);
+		PrintStream ps = new PrintStream(s, false, fileEncoding);
+		ps.println("1|one");
+		ps.println("2|twø");
+		ps.println("3|three");
+		ps.close();
+		conn.setUploadHandler(new FileTransferHandler(d, handlerEncoding));
+		update("COPY INTO foo FROM 'data.txt' ON CLIENT");
+		assertQueryInt("SELECT SUM(i) FROM foo", 6);
+		assertQueryString("SELECT t FROM foo WHERE i = 2", "twø");
+	}
+
+	public void test_FileTransferHandlerUploadRefused() throws IOException, SQLException, Failure {
+		prepare();
+		Path d = getTmpDir(currentTestName);
+		Path f = d.resolve("data.txt");
+		OutputStream s = Files.newOutputStream(f, CREATE_NEW);
+		PrintStream ps = new PrintStream(s, false, "UTF-8");
+		ps.println("1|one");
+		ps.println("2|two");
+		ps.println("3|three");
+		ps.close();
+
+		Path d2 = getTmpDir(currentTestName + "2");
+		conn.setUploadHandler(new FileTransferHandler(d2, StandardCharsets.UTF_8));
+		String quoted = f.toAbsolutePath().toString().replaceAll("'", "''");
+		expectError("COPY INTO foo FROM R'"+ quoted + "' ON CLIENT", "not in upload directory");
+		// connection is still alive
+		assertQueryInt("SELECT SUM(i) FROM foo", 0);
+	}
+
+	public void test_FileTransferHandlerDownloadUtf8() throws SQLException, Failure, IOException {
+		testFileTransferHandlerDownload(StandardCharsets.UTF_8, StandardCharsets.UTF_8);
+	}
+
+	public void test_FileTransferHandlerDownloadLatin1() throws SQLException, Failure, IOException {
+		Charset latin1 = Charset.forName("latin1");
+		testFileTransferHandlerDownload(latin1, latin1);
+	}
+
+	public void test_FileTransferHandlerDownloadNull() throws SQLException, Failure, IOException {
+		testFileTransferHandlerDownload(null, Charset.defaultCharset());
+	}
+
+	public void testFileTransferHandlerDownload(Charset handlerEncoding, Charset fileEncoding) throws SQLException, Failure, IOException {
+		prepare();
+		update("INSERT INTO foo VALUES (42, 'forty-twø')");
+		Path d = getTmpDir(currentTestName);
+		conn.setDownloadHandler(new FileTransferHandler(d, handlerEncoding));
+		update("COPY SELECT * FROM foo INTO 'data.txt' ON CLIENT");
+		List<String> lines = Files.readAllLines(d.resolve("data.txt"), fileEncoding);
+		assertEq("lines written", lines.size(), 1);
+		assertEq("line content", lines.get(0), "42|\"forty-twø\"");
+		// connection is still alive
+		assertQueryInt("SELECT SUM(i) FROM foo", 42);
+	}
+
+	public void test_FileTransferHandlerDownloadRefused() throws SQLException, Failure, IOException {
+		prepare();
+		BugFixLevel level = getLevel();
+		update("INSERT INTO foo VALUES (42, 'forty-two')");
+		Path d = getTmpDir(currentTestName);
+		Path d2 = getTmpDir(currentTestName + "2");
+		conn.setDownloadHandler(new FileTransferHandler(d2, StandardCharsets.UTF_8));
+		String quoted = d.resolve("data.txt").toAbsolutePath().toString().replaceAll("'", "''");
+		expectError("COPY SELECT * FROM foo INTO R'" + quoted + "' ON CLIENT", "not in download directory");
+		if (level.compareTo(BugFixLevel.CanRefuseDownload) >= 0) {
+			// connection is still alive
+			assertQueryInt("SELECT SUM(i) FROM foo", 42);
+		}
+	}
+
+	static class MyUploadHandler implements UploadHandler {
+		private final long rows;
+		private final long errorAt;
+		private final String errorMessage;
+		private boolean encounteredWriteError = false;
+		private boolean cancelled = false;
+
+		private int chunkSize = 100; // small number to trigger more bugs
+
+		MyUploadHandler(long rows, long errorAt, String errorMessage) {
+			this.rows = rows;
+			this.errorAt = errorAt;
+			this.errorMessage = errorMessage;
+		}
+
+		MyUploadHandler(long rows) {
+			this(rows, -1, null);
+		}
+
+		MyUploadHandler(String errorMessage) {
+			this(0, -1, errorMessage);
+		}
+
+		public void setChunkSize(int chunkSize) {
+			this.chunkSize = chunkSize;
+		}
+
+		@Override
+		public void uploadCancelled() {
+			cancelled = true;
+		}
+
+		@Override
+		public void handleUpload(MonetConnection.Upload handle, String name, boolean textMode, long linesToSkip) throws IOException {
+			if (errorAt == -1 && errorMessage != null) {
+				handle.sendError(errorMessage);
+				return;
+			}
+			handle.setChunkSize(chunkSize);
+			PrintStream stream = handle.getStream();
+			for (long i = linesToSkip; i < rows; i++) {
+				if (i == errorAt) {
+					throw new IOException(errorMessage);
+				}
+				stream.printf("%d|%d%n", i + 1, i + 1);
+				if (i % 25 == 0 && stream.checkError()) {
+					encounteredWriteError = true;
+					break;
+				}
+			}
+		}
+
+		public Object encounteredWriteError() {
+			return encounteredWriteError;
+		}
+
+		public boolean isCancelled() {
+			return cancelled;
+		}
+	}
+
+	static class MyDownloadHandler implements DownloadHandler {
+		private final int errorAtByte;
+		private final String errorMessage;
+		private int attempts = 0;
+		private int bytesSeen = 0;
+		private int lineEndingsSeen = 0;
+		private int startOfLine = 0;
+
+		MyDownloadHandler(int errorAtByte, String errorMessage) {
+			this.errorAtByte = errorAtByte;
+			this.errorMessage = errorMessage;
+		}
+
+		MyDownloadHandler(String errorMessage) {
+			this(-1, errorMessage);
+		}
+
+		MyDownloadHandler() {
+			this(-1, null);
+		}
+
+		@Override
+		public void handleDownload(MonetConnection.Download handle, String name, boolean textMode) throws IOException {
+			attempts++;
+			bytesSeen = 0;
+			lineEndingsSeen = 0;
+			startOfLine = 0;
+
+			if (errorMessage != null && errorAtByte < 0) {
+				handle.sendError(errorMessage);
+				return;
+			}
+
+			InputStream stream = handle.getStream();
+			byte[] buffer = new byte[1024];
+			while (true) {
+				int toRead = buffer.length;
+				if (errorMessage != null && errorAtByte >= 0) {
+					if (bytesSeen == errorAtByte) {
+						throw new IOException(errorMessage);
+					}
+					toRead = Integer.min(toRead, errorAtByte - bytesSeen);
+				}
+				int nread = stream.read(buffer, 0, toRead);
+				if (nread < 0)
+					break;
+				for (int i = 0; i < nread; i++) {
+					if (buffer[i] == '\n') {
+						lineEndingsSeen += 1;
+						startOfLine = bytesSeen + i + 1;
+					}
+				}
+				bytesSeen += nread;
+			}
+		}
+
+		public int countAttempts() {
+			return attempts;
+		}
+
+		public int lineCount() {
+			int lines = lineEndingsSeen;
+			if (startOfLine != bytesSeen)
+				lines++;
+			return lines;
+		}
+	}
+
+}
new file mode 100644
--- /dev/null
+++ b/tests/TestRunner.java
@@ -0,0 +1,425 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0.  If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * Copyright 1997 - July 2008 CWI, August 2008 - 2021 MonetDB B.V.
+ */
+
+import org.monetdb.jdbc.MonetConnection;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.sql.*;
+import java.util.ArrayList;
+
+public class TestRunner {
+	public static final int VERBOSITY_NONE = 0;
+	public static final int VERBOSITY_ON = 1;
+	public static final int VERBOSITY_SHOW_ALL = 2;
+	protected final String jdbcUrl;
+	private final int verbosity;
+	protected String currentTestName;
+	protected final WatchDog watchDog;
+	protected MonetConnection conn;
+	protected Statement stmt;
+	private StringWriter outBuffer;
+	protected PrintWriter out;
+	private Path tmpDir = null;
+
+	public TestRunner(String jdbcUrl, int verbosity, boolean watchDogEnabled) {
+		this.jdbcUrl = jdbcUrl;
+		this.verbosity = verbosity;
+		watchDog = new WatchDog();
+		if (watchDogEnabled)
+			watchDog.enable();
+		else
+			watchDog.disable();
+	}
+
+	protected int runTests(String testPrefix) throws SQLException {
+		int testCount = 0;
+		int skippedCount = 0;
+		ArrayList<String> failures = new ArrayList<>();
+
+		watchDog.stop();
+		try {
+			final String initialPrefix = "test_";
+			if (testPrefix == null)
+				testPrefix = "";
+			final String methodPrefix = initialPrefix + testPrefix;
+
+			for (Method method : this.getClass().getDeclaredMethods()) {
+				if (method.getParameterCount() != 0) {
+					continue;
+				}
+				if (!method.getName().startsWith(initialPrefix)) {
+					continue;
+				}
+				testCount++;
+				// so user can add $ to force full match
+				String augmentedMethodName = method.getName() + "$";
+				if (!augmentedMethodName.startsWith(methodPrefix)) {
+					skippedCount++;
+					continue;
+				}
+				String testName = method.getName().substring(initialPrefix.length());
+				boolean succeeded = runTest(testName, method);
+				if (!succeeded)
+					failures.add(testName);
+			}
+		} finally {
+			watchDog.stop();
+		}
+
+		if (testCount > 0 && skippedCount == testCount && !testPrefix.isEmpty()) {
+			System.err.printf("None of the %d tests matched prefix '%s'%n", testCount, testPrefix);
+			return 1;
+		}
+
+		int failureCount = failures.size();
+		if (failureCount > 0) {
+			System.out.println();
+			System.out.printf("Ran %d out of %d %s tests, %d failed: %s%n",
+					testCount - skippedCount, testCount,
+					this.getClass().getSimpleName(),
+					failureCount,
+					String.join(", ", failures)
+					);
+		} else if (verbosity >= VERBOSITY_ON) {
+			System.out.println();
+			System.out.printf("Ran %d out of %d tests, none failed%n", testCount - skippedCount, testCount);
+		}
+
+		return failureCount;
+	}
+
+	private synchronized boolean runTest(String testName, Method method) throws SQLException {
+		currentTestName = testName;
+		watchDog.setContext("test " + testName);
+		watchDog.setDuration(3_000);
+		outBuffer = new StringWriter();
+		out = new PrintWriter(outBuffer);
+
+		Connection genericConnection = DriverManager.getConnection(jdbcUrl);
+		conn = genericConnection.unwrap(MonetConnection.class);
+		stmt = conn.createStatement();
+
+		boolean failed = true;
+		try {
+			long duration;
+			try {
+				long t0 = System.currentTimeMillis();
+				method.invoke(this);
+				long t1 = System.currentTimeMillis();
+				duration = t1 - t0;
+			} catch (InvocationTargetException e) {
+				Throwable cause = e.getCause();
+				if (cause instanceof Failure)
+					throw (Failure) cause;
+				else if (cause instanceof Exception) {
+					throw (Exception) cause;
+				} else {
+					throw e;
+				}
+			}
+
+			failed = false;
+
+			if (verbosity > VERBOSITY_ON)
+				System.out.println();
+			if (verbosity >= VERBOSITY_ON)
+				System.out.println("Test " + testName + " succeeded in " + duration + "ms");
+			if (verbosity >= VERBOSITY_SHOW_ALL)
+				dumpOutput(testName);
+		} catch (Failure e) {
+			System.out.println();
+			System.out.println("Test " + testName + " failed");
+			dumpOutput(testName);
+		} catch (Exception e) {
+			System.out.println();
+			System.out.println("Test " + testName + " failed:");
+			e.printStackTrace(System.out);
+			dumpOutput(testName);
+			// Show the inner bits of the exception again, they may have scrolled off screen
+			Throwable t = e;
+			while (t.getCause() != null) {
+				t = t.getCause();
+			}
+			System.out.println("Innermost cause was " + t);
+			if (t.getStackTrace().length > 0) {
+				System.out.println("                 at " + t.getStackTrace()[0]);
+			}
+		} finally {
+			watchDog.setContext(null);
+			if (failed && verbosity == VERBOSITY_ON) {
+				// next test case will not print separator
+				System.out.println();
+			}
+			stmt.close();
+			conn.close();
+		}
+
+		return !failed;
+	}
+
+	private void dumpOutput(String testName) {
+		String output = outBuffer.getBuffer().toString();
+		if (output.isEmpty()) {
+			System.out.println("(Test did not produce any output)");
+		} else {
+			System.out.println("------ Accumulated output for test " + testName + ":");
+			boolean terminated = output.endsWith(System.lineSeparator());
+			if (terminated) {
+				System.out.print(output);
+			} else {
+				System.out.println(output);
+			}
+			System.out.println("------ End of accumulated output" + (terminated ? "" : " (no trailing newline)"));
+		}
+	}
+
+	private void fail(String message) throws Failure {
+		out.println("FAILURE: " + message);
+		throw new Failure(message);
+	}
+
+	private void checked(String quantity, Object actual) {
+		out.println("  CHECKED: " + "<" + quantity + "> is " + actual + " as expected");
+	}
+
+	protected void assertEq(String quantity, Object expected, Object actual) throws Failure {
+		if (expected.equals(actual)) {
+			checked(quantity, actual);
+		} else {
+			fail("Expected <" + quantity + "> to be " + expected + " got " + actual);
+		}
+	}
+
+	protected boolean execute(String query) throws SQLException {
+		try {
+			watchDog.start();
+			out.println("EXECUTE: " + query);
+			boolean result;
+			result = stmt.execute(query);
+			if (result) {
+				out.println("  OK");
+			} else {
+				out.println("  OK, updated " + stmt.getUpdateCount() + " rows");
+			}
+			return result;
+		} finally {
+			watchDog.stop();
+		}
+	}
+
+	protected void update(String query) throws SQLException, Failure {
+		execute(query);
+	}
+
+	protected void expectError(String query, String expectedError) throws SQLException {
+		try {
+			execute(query);
+		} catch (SQLException e) {
+			if (e.getMessage().contains(expectedError)) {
+				out.println("  GOT EXPECTED EXCEPTION: " + e.getMessage());
+			} else {
+				throw e;
+			}
+		}
+	}
+
+	protected void assertQueryInt(String query, int expected) throws SQLException, Failure {
+		if (execute(query) == false) {
+			fail("Query does not return a result set");
+		}
+		ResultSet rs = stmt.getResultSet();
+		ResultSetMetaData metaData = rs.getMetaData();
+		assertEq("column count", 1, metaData.getColumnCount());
+		if (!rs.next()) {
+			fail("Result set is empty");
+		}
+		int result = rs.getInt(1);
+		if (rs.next()) {
+			String message = "Result set has more than one row";
+			fail(message);
+		}
+		rs.close();
+		checked("row count", 1);
+		assertEq("query result", expected, result);
+	}
+
+	protected void assertQueryString(String query, String expected) throws SQLException, Failure {
+		if (execute(query) == false) {
+			fail("Query does not return a result set");
+		}
+		ResultSet rs = stmt.getResultSet();
+		ResultSetMetaData metaData = rs.getMetaData();
+		assertEq("column count", 1, metaData.getColumnCount());
+		if (!rs.next()) {
+			fail("Result set is empty");
+		}
+		String result = rs.getString(1);
+		if (rs.next()) {
+			String message = "Result set has more than one row";
+			fail(message);
+		}
+		rs.close();
+		checked("row count", 1);
+		assertEq("query result", expected, result);
+	}
+
+	protected String queryString(String query) throws SQLException, Failure {
+		if (execute(query) == false) {
+			fail("Query does not return a result set");
+		}
+		ResultSet rs = stmt.getResultSet();
+		ResultSetMetaData metaData = rs.getMetaData();
+		assertEq("column count", 1, metaData.getColumnCount());
+		if (!rs.next()) {
+			fail("Result set is empty");
+		}
+		String result = rs.getString(1);
+		if (rs.next()) {
+			String message = "Result set has more than one row";
+			fail(message);
+		}
+		rs.close();
+		checked("row count", 1);
+		return result;
+	}
+
+	protected synchronized Path getTmpDir(String name) throws IOException {
+		if (tmpDir == null) {
+			tmpDir = Files.createTempDirectory("testMonetDB");
+			Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+				try {
+					Files.walkFileTree(tmpDir, new SimpleFileVisitor<Path>() {
+						@Override
+						public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
+							Files.delete(file);
+							return FileVisitResult.CONTINUE;
+						}
+
+						@Override
+						public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
+							Files.delete(dir);
+							return FileVisitResult.CONTINUE;
+						}
+					});
+				} catch (IOException e) {
+					// we do this on a best effort basis
+				}
+			}));
+		}
+		Path p = tmpDir.resolve(name);
+		Files.createDirectory(p);
+		return p;
+	}
+
+	static class Failure extends Exception {
+
+		public Failure(String message) {
+			super(message);
+		}
+
+		public Failure(String message, Throwable cause) {
+			super(message, cause);
+		}
+
+	}
+
+	static class WatchDog {
+		private boolean enabled;
+		private long duration = 1000;
+		private long started = 0;
+		private String context = "no context";
+
+		WatchDog() {
+			Thread watchDog = new Thread(this::work);
+			watchDog.setName("watchdog_timer");
+			watchDog.setDaemon(true);
+			watchDog.start();
+		}
+
+		synchronized void enable() {
+			this.enabled = true;
+			this.notifyAll();
+		}
+
+		synchronized void disable() {
+			this.enabled = false;
+			this.notifyAll();
+		}
+
+		synchronized void setContext(String context) {
+			this.context = context;
+		}
+
+		synchronized void setDuration(long duration) {
+			if (duration <= 0)
+				throw new IllegalArgumentException("duration should be > 0");
+			this.duration = duration;
+			this.notifyAll();
+		}
+
+		synchronized void start() {
+			started = System.currentTimeMillis();
+			this.notifyAll();
+		}
+
+		synchronized void stop() {
+			started = 0;
+			this.notifyAll();
+		}
+
+		private synchronized void work() {
+			long now;
+			try {
+				while (true) {
+					now = System.currentTimeMillis();
+					final long sleepTime;
+					if (started < 0) {
+						// client asked us to go away
+						// System.err.println("++ EXIT");
+						return;
+					} else if (!enabled || started == 0) {
+						// wait for client to enable/start us
+						sleepTime = 600_000;
+					} else {
+						long deadline = started + duration;
+						sleepTime = deadline - now;
+					}
+					// System.err.printf("++ now=%d, started=now%+d, duration=%d, sleep=%d%n",
+					// 		now, started - now, duration, sleepTime
+					// 		);
+					if (sleepTime > 0) {
+						this.wait(sleepTime);
+					} else {
+						trigger();
+						return;
+					}
+				}
+			} catch (InterruptedException e) {
+				System.err.println("WATCHDOG TIMER INTERRUPTED, SHOULDN'T HAPPEN");
+				System.exit(4);
+			}
+		}
+
+		private void trigger() {
+			String c = context != null ? context : "no context";
+			System.err.println();
+			System.err.println();
+			System.err.println("WATCHDOG TIMER EXPIRED [" + c + "], KILLING TESTS");
+			System.exit(3);
+		}
+	}
+}
--- a/tests/build.xml
+++ b/tests/build.xml
@@ -49,7 +49,7 @@ Copyright 1997 - July 2008 CWI, August 2
     <javac
       srcdir="${srcdir}"
       destdir="${builddir}"
-      debug="false"
+      debug="true"
       optimize="false"
       includeantruntime="false"
       source="${jvm.version}"
@@ -64,7 +64,7 @@ Copyright 1997 - July 2008 CWI, August 2
     </javac>
   </target>
 
-  <!-- a convenience jar collectin all JDBC tests -->
+  <!-- a convenience jar collecting all JDBC tests -->
   <target name="jar_jdbctests"
     depends="compile"
     unless="uptodate.jdbctests-jar">
@@ -72,6 +72,10 @@ Copyright 1997 - July 2008 CWI, August 2
     <jar jarfile="${jdbctests-jar}">
       <fileset dir="${builddir}">
         <include name="JDBC_API_Tester.class" />
+        <include name="TestRunner.class" />
+        <include name="TestRunner$*.class" />
+        <include name="OnClientTester.class" />
+        <include name="OnClientTester$*.class" />
       </fileset>
     </jar>
   </target>
@@ -105,7 +109,8 @@ Copyright 1997 - July 2008 CWI, August 2
 
   <target name="test_class" depends="compile,jdbc">
     <echo message="Testing class ${test.class}" />
-    <java classname="${test.class}" failonerror="true">
+    <!-- fork="true" allows the test program to call System.exit() -->
+    <java classname="${test.class}" failonerror="true" fork="true">
       <classpath>
         <pathelement path="${builddir}" />
         <pathelement path="${jdbc_jar}" />
@@ -122,4 +127,10 @@ Copyright 1997 - July 2008 CWI, August 2
     </antcall>
   </target>
 
+  <target name="OnClientTester">
+    <antcall target="test_class">
+      <param name="test.class" value="OnClientTester" />
+    </antcall>
+  </target>
+
 </project>