changeset 793:5bfe3357fb1c monetdbs

Use the new url parser
author Joeri van Ruth <joeri.van.ruth@monetdbsolutions.com>
date Wed, 06 Dec 2023 16:17:13 +0100 (16 months ago)
parents 9dea0795a926
children a418afda6b21
files src/main/java/org/monetdb/jdbc/MonetConnection.java src/main/java/org/monetdb/jdbc/MonetDriver.java src/main/java/org/monetdb/mcl/MCLException.java src/main/java/org/monetdb/mcl/io/BufferedMCLReader.java src/main/java/org/monetdb/mcl/net/MapiSocket.java src/main/java/org/monetdb/mcl/net/MonetUrlParser.java src/main/java/org/monetdb/mcl/net/Target.java tests/tests.md
diffstat 8 files changed, 576 insertions(+), 686 deletions(-) [+]
line wrap: on
line diff
--- a/src/main/java/org/monetdb/jdbc/MonetConnection.java
+++ b/src/main/java/org/monetdb/jdbc/MonetConnection.java
@@ -25,7 +25,6 @@ import java.sql.SQLWarning;
 import java.sql.Savepoint;
 import java.sql.Statement;
 import java.util.ArrayList;
-import java.util.Calendar;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Map.Entry;
@@ -36,8 +35,8 @@ 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.HandshakeOption;
 import org.monetdb.mcl.net.MapiSocket;
+import org.monetdb.mcl.net.Target;
 import org.monetdb.mcl.parser.HeaderLineParser;
 import org.monetdb.mcl.parser.MCLParseException;
 import org.monetdb.mcl.parser.StartOfHeaderParser;
@@ -74,17 +73,8 @@ public class MonetConnection
 	extends MonetWrapper
 	implements Connection, AutoCloseable
 {
-	/** The hostname to connect to */
-	private final String hostname;
-	/** The port to connect on the host to */
-	private int port;
-	/** The database to use (currently not used) */
-	private final String database;
-	/** The username to use when authenticating */
-	private final String username;
-	/** The password to use when authenticating */
-	private final String password;
-
+	/** All connection parameters */
+	Target target;
 	/** A connection to mserver5 using a TCP socket */
 	private final MapiSocket server;
 	/** The Reader from the server */
@@ -120,6 +110,9 @@ public class MonetConnection
 
 	/** The number of results we receive from the server at once */
 	private int curReplySize = 100;	// server default
+	private boolean sizeHeaderEnabled = false; // used during handshake
+	private boolean timeZoneSet = false; // used during handshake
+
 
 	/** A template to apply to each query (like pre and post fixes), filled in constructor */
 	// note: it is made public to the package as queryTempl[2] is used from MonetStatement
@@ -137,11 +130,6 @@ public class MonetConnection
 	/** The language which is used */
 	private final int lang;
 
-	/** Whether or not BLOB is mapped to Types.VARBINARY instead of Types.BLOB within this connection */
-	private boolean treatBlobAsVarBinary = true;	// turned on by default for optimal performance (from JDBC Driver release 3.0 onwards)
-	/** Whether or not CLOB is mapped to Types.VARCHAR instead of Types.CLOB within this connection */
-	private boolean treatClobAsVarChar = true;	// turned on by default for optimal performance (from JDBC Driver release 3.0 onwards)
-
 	/** The last set query timeout on the server as used by Statement, PreparedStatement and CallableStatement */
 	protected int lastSetQueryTimeout = 0;	// 0 means no timeout, which is the default on the server
 
@@ -155,137 +143,23 @@ public class MonetConnection
 	 * createStatement() call.  This constructor is only accessible to
 	 * classes from the jdbc package.
 	 *
-	 * @param props a Property hashtable holding the properties needed for connecting
+	 * @param target a Target object holding the connection parameters
 	 * @throws SQLException if a database error occurs
 	 * @throws IllegalArgumentException is one of the arguments is null or empty
 	 */
-	MonetConnection(final Properties props)
+	MonetConnection(Target target)
 		throws SQLException, IllegalArgumentException
 	{
-		HandshakeOption.AutoCommit autoCommitSetting = new HandshakeOption.AutoCommit(true);
-		HandshakeOption.ReplySize replySizeSetting = new HandshakeOption.ReplySize(DEF_FETCHSIZE);
-		HandshakeOption.SizeHeader sizeHeaderSetting = new HandshakeOption.SizeHeader(true);
-		HandshakeOption.TimeZone timeZoneSetting = new HandshakeOption.TimeZone(0);
-
-		// for debug: System.out.println("New connection object. Received properties are: " + props.toString());
-		// get supported property values from the props argument.
-		this.hostname = props.getProperty("host");
-
-		final String port_prop = props.getProperty("port");
-		if (port_prop != null) {
-			try {
-				this.port = Integer.parseInt(port_prop);
-			} catch (NumberFormatException e) {
-				addWarning("Unable to parse port number from: " + port_prop, "M1M05");
-			}
-		}
-
-		this.database = props.getProperty("database");
-		this.username = props.getProperty("user");
-		this.password = props.getProperty("password");
-		String language = props.getProperty("language");
-
-		boolean debug = false;
-		String debug_prop = props.getProperty("debug");
-		if (debug_prop != null) {
-			debug = Boolean.parseBoolean(debug_prop);
-		}
-
-		final String hash = props.getProperty("hash");
-
-		String autocommit_prop = props.getProperty("autocommit");
-		if (autocommit_prop != null) {
-			boolean ac = Boolean.parseBoolean(autocommit_prop);
-			autoCommitSetting.set(ac);
-		}
-
-		final String fetchsize_prop = props.getProperty("fetchsize");
-		if (fetchsize_prop != null) {
-			try {
-				int fetchsize = Integer.parseInt(fetchsize_prop);
-				if (fetchsize > 0 || fetchsize == -1) {
-					replySizeSetting.set(fetchsize);
-				} else {
-					addWarning("Fetch size must either be positive or -1. Value " + fetchsize + " ignored", "M1M05");
-				}
-			} catch (NumberFormatException e) {
-				addWarning("Unable to parse fetch size number from: " + fetchsize_prop, "M1M05");
-			}
-		}
-
-		final String treatBlobAsVarBinary_prop = props.getProperty("treat_blob_as_binary");
-		if (treatBlobAsVarBinary_prop != null) {
-			treatBlobAsVarBinary = Boolean.parseBoolean(treatBlobAsVarBinary_prop);
-			if (treatBlobAsVarBinary)
-				typeMap.put("blob", Byte[].class);
-		}
-
-		final String treatClobAsVarChar_prop = props.getProperty("treat_clob_as_varchar");
-		if (treatClobAsVarChar_prop != null) {
-			treatClobAsVarChar = Boolean.parseBoolean(treatClobAsVarChar_prop);
-			if (treatClobAsVarChar)
-				typeMap.put("clob", String.class);
-		}
-
-		int sockTimeout = 0;
-		final String so_timeout_prop = props.getProperty("so_timeout");
-		if (so_timeout_prop != null) {
-			try {
-				sockTimeout = Integer.parseInt(so_timeout_prop);
-				if (sockTimeout < 0) {
-					addWarning("Negative socket timeout not allowed. Value ignored", "M1M05");
-					sockTimeout = 0;
-				}
-			} catch (NumberFormatException e) {
-				addWarning("Unable to parse socket timeout number from: " + so_timeout_prop, "M1M05");
-			}
-		}
-
-		// check mandatory input arguments
-		if (hostname == null || hostname.isEmpty())
-			throw new IllegalArgumentException("Missing or empty host name");
-		if (port <= 0 || port > 65535)
-			throw new IllegalArgumentException("Invalid port number: " + port
-					+ ". It should not be " + (port < 0 ? "negative" : (port > 65535 ? "larger than 65535" : "0")));
-		if (username == null || username.isEmpty())
-			throw new IllegalArgumentException("Missing or empty user name");
-		if (password == null || password.isEmpty())
-			throw new IllegalArgumentException("Missing or empty password");
-		if (language == null || language.isEmpty()) {
-			// fallback to default language: sql
-			language = "sql";
-			addWarning("No language specified, defaulting to 'sql'", "M1M05");
-		}
-
-		// warn about unrecognized property names
-		for (Entry<Object,Object> e: props.entrySet()) {
-			checkValidProperty(e.getKey().toString(), "MonetConnection");
-		}
-
+		this.target = target;
 		server = new MapiSocket();
-		if (hash != null)
-			server.setHash(hash);
-		if (database != null)
-			server.setDatabase(database);
-		server.setLanguage(language);
-
-		// calculate our time zone offset
-		final Calendar cal = Calendar.getInstance();
-		final int offsetMillis = cal.get(Calendar.ZONE_OFFSET) + cal.get(Calendar.DST_OFFSET);
-		final int offsetSeconds = offsetMillis / 1000;
-		timeZoneSetting.set(offsetSeconds);
-
-		server.setHandshakeOptions(new HandshakeOption<?>[] {
-				autoCommitSetting,
-				replySizeSetting,
-				sizeHeaderSetting,
-				timeZoneSetting,
-		});
 
 		// we're debugging here... uhm, should be off in real life
-		if (debug) {
+		if (target.isDebug()) {
 			try {
-				final String fname = props.getProperty("logfile", "monet_" + System.currentTimeMillis() + ".log");
+				String fname = target.getLogfile();
+				if (fname == null)
+					fname = "monet_" + System.currentTimeMillis() + ".log";
+
 				File f = new File(fname);
 
 				int ext = fname.lastIndexOf('.');
@@ -304,26 +178,45 @@ public class MonetConnection
 			}
 		}
 
+		SqlOptionsCallback callback = null;
+		switch (target.getLanguage()) {
+			case "sql":
+				lang = LANG_SQL;
+				queryTempl[0] = "s";		// pre
+				queryTempl[1] = "\n;";		// post
+				queryTempl[2] = "\n;\n";	// separator
+				commandTempl[0] = "X";		// pre
+				commandTempl[1] = "";		// post
+				callback = new SqlOptionsCallback();
+				break;
+			case "mal":
+				lang = LANG_MAL;
+				queryTempl[0] = "";		// pre
+				queryTempl[1] = ";\n";		// post
+				queryTempl[2] = ";\n";		// separator
+				commandTempl[0] = "";		// pre
+				commandTempl[1] = "";		// post
+				break;
+			default:
+				lang = LANG_UNKNOWN;
+				break;
+		}
+
 		try {
-			final java.util.List<String> warnings = server.connect(hostname, port, username, password);
+
+			final java.util.List<String> warnings = server.connect(target, callback);
 			for (String warning : warnings) {
 				addWarning(warning, "01M02");
 			}
 
-			// apply NetworkTimeout value from legacy (pre 4.1) driver
-			// so_timeout calls
-			server.setSoTimeout(sockTimeout);
-
 			in = server.getReader();
 			out = server.getWriter();
 
 			final String error = in.discardRemainder();
 			if (error != null)
 				throw new SQLNonTransientConnectionException((error.length() > 6) ? error.substring(6) : error, "08001");
-		} catch (java.net.UnknownHostException e) {
-			throw new SQLNonTransientConnectionException("Unknown Host (" + hostname + "): " + e.getMessage(), "08006");
 		} catch (IOException e) {
-			throw new SQLNonTransientConnectionException("Unable to connect (" + hostname + ":" + port + "): " + e.getMessage(), "08006");
+			throw new SQLNonTransientConnectionException("Cannot connect: " + e.getMessage(), "08006");
 		} catch (MCLParseException e) {
 			throw new SQLNonTransientConnectionException(e.getMessage(), "08001");
 		} catch (org.monetdb.mcl.MCLException e) {
@@ -335,53 +228,17 @@ public class MonetConnection
 			throw sqle;
 		}
 
-		// we seem to have managed to log in, let's store the
-		// language used and language specific query templates
-		if ("sql".equals(language)) {
-			lang = LANG_SQL;
-
-			queryTempl[0] = "s";		// pre
-			queryTempl[1] = "\n;";		// post
-			queryTempl[2] = "\n;\n";	// separator
-
-			commandTempl[0] = "X";		// pre
-			commandTempl[1] = "";		// post
-			//commandTempl[2] = "\nX";	// separator (is not used)
-		} else if ("mal".equals(language)) {
-			lang = LANG_MAL;
-
-			queryTempl[0] = "";		// pre
-			queryTempl[1] = ";\n";		// post
-			queryTempl[2] = ";\n";		// separator
-
-			commandTempl[0] = "";		// pre
-			commandTempl[1] = "";		// post
-			//commandTempl[2] = "";		// separator (is not used)
-		} else {
-			lang = LANG_UNKNOWN;
-		}
-
 		// Now take care of any handshake options not handled during the handshake
-		if (replySizeSetting.isSent()) {
-			this.curReplySize = replySizeSetting.get();
-		}
-		this.defaultFetchSize = replySizeSetting.get();
+		curReplySize = defaultFetchSize;
 		if (lang == LANG_SQL) {
-			if (autoCommitSetting.mustSend(autoCommit)) {
-				setAutoCommit(autoCommitSetting.get());
-			} else {
-				// update bookkeeping
-				autoCommit = autoCommitSetting.get();
+			if (autoCommit != target.isAutocommit()) {
+				setAutoCommit(target.isAutocommit());
 			}
-			if (sizeHeaderSetting.mustSend(false)) {
+			if (!sizeHeaderEnabled) {
 				sendControlCommand("sizeheader 1");
-			} else {
-				// no bookkeeping to update
 			}
-			if (timeZoneSetting.mustSend(0)) {
-				setTimezone(timeZoneSetting.get());
-			} else {
-				// no bookkeeping to update
+			if (!timeZoneSet) {
+				setTimezone(target.getTimezone());
 			}
 		}
 
@@ -1780,7 +1637,7 @@ public class MonetConnection
 	 * @return whether the JDBC BLOB type should be mapped to VARBINARY type.
 	 */
 	boolean mapBlobAsVarBinary() {
-		return treatBlobAsVarBinary;
+		return target.isTreatBlobAsBinary();
 	}
 
 	/**
@@ -1791,7 +1648,7 @@ public class MonetConnection
 	 * @return whether the JDBC CLOB type should be mapped to VARCHAR type.
 	 */
 	boolean mapClobAsVarChar() {
-		return treatClobAsVarChar;
+		return target.isTreatClobAsVarchar();
 	}
 
 	/**
@@ -1800,13 +1657,7 @@ public class MonetConnection
 	 * @return the MonetDB JDBC Connection URL (without user name and password).
 	 */
 	String getJDBCURL() {
-		final StringBuilder sb = new StringBuilder(128);
-		sb.append("jdbc:monetdb://").append(hostname)
-			.append(':').append(port)
-			.append('/').append(database);
-		if (lang == LANG_MAL)
-			sb.append("?language=mal");
-		return sb.toString();
+		return target.buildUrl();
 	}
 
 	/**
@@ -3887,4 +3738,48 @@ public class MonetConnection
 			super.close();
 		}
 	}
+
+	public static enum SqlOption {
+		Autocommit(1, "auto_commit"),
+		ReplySize(2, "reply_size"),
+		SizeHeader(3, "size_header"),
+		// NOTE: 4 has been omitted on purpose
+		TimeZone(5, "time_zone"),
+		;
+		final int level;
+		final String field;
+
+		SqlOption(int level, String field) {
+			this.level = level;
+			this.field = field;
+		}
+	}
+
+
+	private class SqlOptionsCallback extends MapiSocket.OptionsCallback {
+		private int level;
+		@Override
+		public void addOptions(String lang, int level) {
+			if (!lang.equals("sql"))
+				return;
+			this.level = level;
+
+			// Try to add options and record that this happened if it succeeds.
+			if (contribute(SqlOption.Autocommit, target.isAutocommit() ? 1 : 0))
+				autoCommit = target.isAutocommit();
+			if (contribute(SqlOption.ReplySize, target.getReplysize()))
+				defaultFetchSize = target.getReplysize();
+			if (contribute(SqlOption.SizeHeader, 1))
+				sizeHeaderEnabled = true;
+			if (contribute(SqlOption.TimeZone, target.getTimezone()))
+				timeZoneSet = true;
+		}
+
+		private boolean contribute(SqlOption opt, int value) {
+			if (this.level <= opt.level)
+				return false;
+			contribute(opt.field, value);
+			return true;
+		}
+	}
 }
--- a/src/main/java/org/monetdb/jdbc/MonetDriver.java
+++ b/src/main/java/org/monetdb/jdbc/MonetDriver.java
@@ -8,7 +8,12 @@
 
 package org.monetdb.jdbc;
 
-import java.net.URI;
+import org.monetdb.mcl.net.MonetUrlParser;
+import org.monetdb.mcl.net.Parameter;
+import org.monetdb.mcl.net.Target;
+import org.monetdb.mcl.net.ValidationError;
+
+import java.net.URISyntaxException;
 import java.sql.Connection;
 import java.sql.Driver;
 import java.sql.DriverManager;
@@ -97,56 +102,24 @@ public final class MonetDriver implement
 		if (!acceptsURL(url))
 			return null;
 
-		final Properties props = new Properties();
-		// set the optional properties and their defaults here
-		props.put("port", "50000");
-		props.put("debug", "false");
-		props.put("language", "sql");	// mal, sql, <future>
-		props.put("so_timeout", "0");
+		Target target = new Target();
 
-		if (info != null)
-			props.putAll(info);
-		info = props;
-
-		// remove leading "jdbc:" so the rest is a valid hierarchical URI
-		final URI uri;
 		try {
-			uri = new URI(url.substring(5));
-		} catch (java.net.URISyntaxException e) {
-			return null;
+			if (info != null) {
+				for (String key : info.stringPropertyNames()) {
+					String value = info.getProperty(key);
+					if (key.equals(Parameter.HOST.name))
+						value = Target.unpackHost(value);
+					target.setString(key, value);
+				}
+			}
+			MonetUrlParser.parse(target, url.substring(5));
+		} catch (ValidationError | URISyntaxException e) {
+			throw new SQLException(e.getMessage());
 		}
 
-		final String uri_host = uri.getHost();
-		if (uri_host == null)
-			return null;
-		info.put("host", uri_host);
-
-		int uri_port = uri.getPort();
-		if (uri_port > 0)
-			info.put("port", Integer.toString(uri_port));
-
-		// check the database
-		String uri_path = uri.getPath();
-		if (uri_path != null && !uri_path.isEmpty()) {
-			uri_path = uri_path.substring(1).trim();
-			if (!uri_path.isEmpty())
-				info.put("database", uri_path);
-		}
-
-		final String uri_query = uri.getQuery();
-		if (uri_query != null) {
-			int pos;
-			// handle additional connection properties separated by the & character
-			final String args[] = uri_query.split("&");
-			for (int i = 0; i < args.length; i++) {
-				pos = args[i].indexOf('=');
-				if (pos > 0)
-					info.put(args[i].substring(0, pos), args[i].substring(pos + 1));
-			}
-		}
-
-		// finally return the Connection object as requested
-		return new MonetConnection(info);
+        // finally return the Connection object as requested
+		return new MonetConnection(target);
 	}
 
 	/**
--- a/src/main/java/org/monetdb/mcl/MCLException.java
+++ b/src/main/java/org/monetdb/mcl/MCLException.java
@@ -15,7 +15,11 @@ package org.monetdb.mcl;
 public final class MCLException extends Exception {
 	private static final long serialVersionUID = 1L;
 
-	public MCLException(String e) {
-		super(e);
+	public MCLException(String message) {
+		super(message);
+	}
+
+	public MCLException(String message, Exception cause) {
+		super(message, cause);
 	}
 }
--- a/src/main/java/org/monetdb/mcl/io/BufferedMCLReader.java
+++ b/src/main/java/org/monetdb/mcl/io/BufferedMCLReader.java
@@ -95,6 +95,14 @@ public final class BufferedMCLReader {
 	}
 
 	/**
+	 * Return a substring of the current line, or null if we're at the end or before the beginning.
+	 * @return the current line or null
+	 */
+	public String getLine(int start) {
+		return getLine().substring(start);
+	}
+
+	/**
 	 * getLineType returns the type of the current line.
 	 *
 	 * @return Linetype representing the kind of line this is, one of the
--- a/src/main/java/org/monetdb/mcl/net/MapiSocket.java
+++ b/src/main/java/org/monetdb/mcl/net/MapiSocket.java
@@ -17,16 +17,17 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.io.Writer;
-import java.net.Socket;
-import java.net.SocketException;
-import java.net.UnknownHostException;
-import java.net.URI;
+import java.net.*;
 import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
 
 import org.monetdb.mcl.MCLException;
 import org.monetdb.mcl.io.BufferedMCLReader;
@@ -85,10 +86,21 @@ import org.monetdb.mcl.parser.MCLParseEx
  * @see org.monetdb.mcl.io.BufferedMCLWriter
  */
 public final class MapiSocket {
+	private static final String[][] KNOWN_ALGORITHMS = new String[][] {
+			{"SHA512", "SHA-512"},
+			{"SHA384", "SHA-384"},
+			{"SHA256", "SHA-256"},
+			// should we deprecate this by now?
+			{"SHA1", "SHA-1"},
+	};
+
+	// MUST be lowercase!
+	private static final char[] HEXDIGITS = "0123456789abcdef".toCharArray();
+
+	/** Connection parameters */
+	private Target target;
 	/** The TCP Socket to mserver */
 	private Socket con;
-	/** 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 BlockInputStream fromMonet;
 	/** Stream from the Socket for writing */
@@ -100,36 +112,25 @@ public final class MapiSocket {
 	/** protocol version of the connection */
 	private int version;
 
-	/** The database to connect to */
-	private String database = null;
-	/** The language to connect with */
-	private String language = "sql";
-	/** The hash methods to use (null = default) */
-	private String hash = null;
-
 	/** Whether we should follow redirects */
 	private boolean followRedirects = true;
 	/** How many redirections do we follow until we're fed up with it? */
 	private int ttl = 10;
 
-	/** Whether we are debugging or not */
-	private boolean debug = false;
 	/** The Writer for the debug log-file */
 	private Writer log;
 
 	/** The blocksize (hardcoded in compliance with MonetDB common/stream/stream.h) */
-	public final static int BLOCK = 8 * 1024 - 2;
+	public final static int BLOCK = 8190;
 
 	/** A short in two bytes for holding the block size in bytes */
 	private final byte[] blklen = new byte[2];
 
-	/** Options that can be sent during the auth handshake if the server supports it */
-	private HandshakeOption<?>[] handshakeOptions;
-
 	/**
 	 * Constructs a new MapiSocket.
 	 */
 	public MapiSocket() {
+		target = new Target();
 		con = null;
 	}
 
@@ -141,7 +142,7 @@ public final class MapiSocket {
 	 * @param db the database
 	 */
 	public void setDatabase(final String db) {
-		this.database = db;
+		target.setDatabase(db);
 	}
 
 	/**
@@ -150,7 +151,7 @@ public final class MapiSocket {
 	 * @param lang the language
 	 */
 	public void setLanguage(final String lang) {
-		this.language = lang;
+		target.setLanguage(lang);
 	}
 
 	/**
@@ -163,7 +164,7 @@ public final class MapiSocket {
 	 * @param hash the hash method to use
 	 */
 	public void setHash(final String hash) {
-		this.hash = hash;
+		target.setHash(hash);
 	}
 
 	/**
@@ -208,7 +209,7 @@ public final class MapiSocket {
 		if (s < 0) {
 			throw new IllegalArgumentException("timeout can't be negative");
 		}
-		this.soTimeout = s;
+		target.setSoTimeout(s);
 		// limit time to wait on blocking operations
 		if (con != null) {
 			con.setSoTimeout(s);
@@ -222,10 +223,7 @@ public final class MapiSocket {
 	 * @throws SocketException Issue with the socket
 	 */
 	public int getSoTimeout() throws SocketException {
-		if (con != null) {
-			this.soTimeout = con.getSoTimeout();
-		}
-		return this.soTimeout;
+		return target.getSoTimeout();
 	}
 
 	/**
@@ -234,7 +232,7 @@ public final class MapiSocket {
 	 * @param debug Value to set
 	 */
 	public void setDebug(final boolean debug) {
-		this.debug = debug;
+		target.setDebug(debug);
 	}
 
 	/**
@@ -257,375 +255,299 @@ public final class MapiSocket {
 	public List<String> connect(final String host, final int port, final String user, final String pass)
 		throws IOException, SocketException, UnknownHostException, MCLParseException, MCLException
 	{
-		// Wrap around the internal connect that needs to know if it
-		// should really make a TCP connection or not.
-		return connect(host, port, user, pass, true);
+		target.setHost(host);
+		target.setPort(port);
+		target.setUser(user);
+		target.setPassword(pass);
+		return connect(target, null);
 	}
 
-	/**
-	 * Connects to the given host and port, logging in as the given
-	 * user.  If followRedirect is false, a RedirectionException is
-	 * thrown when a redirect is encountered.
-	 *
-	 * @param host the hostname, or null for the loopback address
-	 * @param port the port number (must be between 0 and 65535, inclusive)
-	 * @param user the username
-	 * @param pass the password
-	 * @param makeConnection whether a new socket connection needs to be created
-	 * @return A List with informational (warning) messages. If this
-	 *		list is empty; then there are no warnings.
-	 * @throws IOException if an I/O error occurs when creating the socket
-	 * @throws SocketException - if there is an error in the underlying protocol, such as a TCP error.
-	 * @throws UnknownHostException if the IP address of the host could not be determined
-	 * @throws MCLParseException if bogus data is received
-	 * @throws MCLException if an MCL related error occurs
-	 */
-	private List<String> connect(final String host, final int port, final String user, final String pass, final boolean makeConnection)
-		throws IOException, SocketException, UnknownHostException, MCLParseException, MCLException
-	{
-		if (ttl-- <= 0)
-			throw new MCLException("Maximum number of redirects reached, aborting connection attempt.");
+	public List<String> connect(Target target, OptionsCallback callback) throws MCLException, MCLParseException, IOException {
+		// get rid of any earlier connection state, including the existing target
+		close();
+		this.target = target;
 
-		if (makeConnection) {
-			con = new Socket(host, port);
-			con.setSoTimeout(this.soTimeout);
-			// set nodelay, as it greatly speeds up small messages (like we often do)
-			con.setTcpNoDelay(true);
-			con.setKeepAlive(true);
-
-			fromMonet = new BlockInputStream(con.getInputStream());
-			toMonet = new BlockOutputStream(con.getOutputStream());
-			reader = new BufferedMCLReader(fromMonet, StandardCharsets.UTF_8);
-			writer = new BufferedMCLWriter(toMonet, StandardCharsets.UTF_8);
-			writer.registerReader(reader);
+		Target.Validated validated;
+		try {
+			validated = target.validate();
+		} catch (ValidationError e) {
+			throw new MCLException(e.getMessage());
 		}
 
-		reader.advance();
-		final String c = reader.getLine();
-		reader.discardRemainder();
-		writer.writeLine(getChallengeResponse(c, user, pass, language, database, hash));
-
-		// read monetdb mserver response till prompt
-		final ArrayList<String> redirects = new ArrayList<String>();
-		final List<String> warns = new ArrayList<String>();
-		String err = "", tmp;
-		do {
-			reader.advance();
-			tmp = reader.getLine();
-			if (tmp == null)
-				throw new IOException("Read from " +
-						con.getInetAddress().getHostName() + ":" +
-						con.getPort() + ": End of stream reached");
-			if (reader.getLineType() == LineType.ERROR) {
-				err += "\n" + tmp.substring(7);
-			} else if (reader.getLineType() == LineType.INFO) {
-				warns.add(tmp.substring(1));
-			} else if (reader.getLineType() == LineType.REDIRECT) {
-				redirects.add(tmp.substring(1));
-			}
-		} while (reader.getLineType() != LineType.PROMPT);
-
-		if (err.length() > 0) {
-			close();
-			throw new MCLException(err);
+		if (validated.connectScan()) {
+			return scanUnixSockets(callback);
 		}
 
-		if (!redirects.isEmpty()) {
-			if (followRedirects) {
-				// Ok, server wants us to go somewhere else.  The list
-				// might have multiple clues on where to go.  For now we
-				// don't support anything intelligent but trying the
-				// first one.  URI should be in form of:
-				// "mapi:monetdb://host:port/database?arg=value&..."
-				// or
-				// "mapi:merovingian://proxy?arg=value&..."
-				// note that the extra arguments must be obeyed in both
-				// cases
-				final String suri = redirects.get(0).toString();
-				if (!suri.startsWith("mapi:"))
-					throw new MCLException("unsupported redirect: " + suri);
+		ArrayList<String> warnings = new ArrayList<>();
+		int attempts = 0;
+		do {
+			boolean ok = false;
+			try {
+				boolean done = tryConnect(callback, warnings);
+				ok = true;
+				if (done) {
+					return warnings;
+				}
+			} finally {
+				if (!ok)
+					close();
+			}
+		} while (attempts++ < this.ttl);
+		throw new MCLException("max redirect count exceeded");
+	}
 
-				final URI u;
-				try {
-					u = new URI(suri.substring(5));
-				} catch (java.net.URISyntaxException e) {
-					throw new MCLParseException(e.toString());
-				}
+	private List<String> scanUnixSockets(OptionsCallback callback) throws MCLException, MCLParseException, IOException {
+		// Because we do not support Unix Domain sockets, we just go back to connect().
+		// target.connectScan() will now return false;
+		target.setHost("localhost");
+		return connect(target, callback);
+	}
 
-				tmp = u.getQuery();
-				if (tmp != null) {
-					final String args[] = tmp.split("&");
-					for (int i = 0; i < args.length; i++) {
-						int pos = args[i].indexOf('=');
-						if (pos > 0) {
-							tmp = args[i].substring(0, pos);
-							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]);
-						}
-					}
-				}
+	private boolean tryConnect(OptionsCallback callback, ArrayList<String> warningBuffer) throws MCLException, IOException {
+		try {
+			// We need a valid target
+			Target.Validated validated = target.validate();
+			// con will be non-null if the previous attempt ended in a redirect to mapi:monetdb://proxy
+			if (con == null)
+				connectSocket(validated);
+			return handshake(validated, callback, warningBuffer);
+		} catch (IOException | MCLException e) {
+			close();
+			throw e;
+		} catch (ValidationError e) {
+			close();
+            throw new MCLException(e.getMessage());
+        }
+    }
 
-				if (u.getScheme().equals("monetdb")) {
-					// this is a redirect to another (monetdb) server,
-					// which means a full reconnect
-					// avoid the debug log being closed
-					if (debug) {
-						debug = false;
-						close();
-						debug = true;
-					} else {
-						close();
-					}
-					tmp = u.getPath();
-					if (tmp != null && tmp.length() > 0) {
-						tmp = tmp.substring(1).trim();
-						if (!tmp.isEmpty() && !tmp.equals(database)) {
-							warns.add("redirect points to different database: " + tmp);
-							setDatabase(tmp);
-						}
-					}
-					final int p = u.getPort();
-					warns.addAll(connect(u.getHost(), p == -1 ? port : p, user, pass, true));
-					warns.add("Redirect by " + host + ":" + port + " to " + suri);
-				} else if (u.getScheme().equals("merovingian")) {
-					// reuse this connection to inline connect to the
-					// right database that Merovingian proxies for us
-					reader.resetLineType();
-					warns.addAll(connect(host, port, user, pass, false));
-				} else {
-					throw new MCLException("unsupported scheme in redirect: " + suri);
-				}
-			} else {
-				final StringBuilder msg = new StringBuilder("The server sent a redirect for this connection:");
-				for (String it : redirects) {
-					msg.append(" [" + it + "]");
-				}
-				throw new MCLException(msg.toString());
-			}
+	private void connectSocket(Target.Validated validated) throws MCLException, IOException {
+		// This method performs steps 2-6 of the procedure outlined in the URL spec
+		String tcpHost = validated.connectTcp();
+		if (tcpHost.isEmpty()) {
+			throw new MCLException("Unix domain sockets are not supported, only TCP");
 		}
-		return warns;
+		int port = validated.connectPort();
+		Socket sock = new Socket(tcpHost, port);
+		sock.setSoTimeout(validated.getSoTimeout());
+		sock.setTcpNoDelay(true);
+		sock.setKeepAlive(true);
+
+		sock = wrapTLS(sock, validated);
+
+		fromMonet = new BlockInputStream(sock.getInputStream());
+		toMonet = new BlockOutputStream(sock.getOutputStream());
+		reader = new BufferedMCLReader(fromMonet, StandardCharsets.UTF_8);
+		writer = new BufferedMCLWriter(toMonet, StandardCharsets.UTF_8);
+		writer.registerReader(reader);
+		reader.advance();
+
+		// Only assign to sock when everything went ok so far
+		con = sock;
+	}
+
+	private Socket wrapTLS(Socket sock, Target.Validated validated) throws MCLException {
+		if (validated.getTls())
+			throw new MCLException("TLS connections (monetdbs://) are not supported yet");
+		return sock;
 	}
 
-	/**
-	 * A little helper function that processes a challenge string, and
-	 * returns a response string for the server.  If the challenge
-	 * string is null, a challengeless response is returned.
-	 *
-	 * @param chalstr the challenge string
-	 *	for example: H8sRMhtevGd:mserver:9:PROT10,RIPEMD160,SHA256,SHA1,COMPRESSION_SNAPPY,COMPRESSION_LZ4:LIT:SHA512:
-	 * @param username the username to use
-	 * @param password the password to use
-	 * @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
-	 * @throws MCLParseException when parsing failed
-	 * @throws MCLException if an MCL related error occurs
-	 * @throws IOException when IO exception occurred
-	 */
-	private String getChallengeResponse(
-			final String chalstr,
-			String username,
-			String password,
-			final String language,
-			final String database,
-			final String hash
-	) throws MCLParseException, MCLException, IOException {
-		// parse the challenge string, split it on ':'
-		final String[] chaltok = chalstr.split(":");
-		if (chaltok.length <= 5)
-			throw new MCLParseException("Server challenge string unusable! It contains too few (" + chaltok.length + ") tokens: " + chalstr);
+	private boolean handshake(Target.Validated validated, OptionsCallback callback, ArrayList<String> warnings) throws IOException, MCLException {
+		String challenge = reader.getLine();
+		reader.advance();
+		if (reader.getLineType() != LineType.PROMPT)
+			throw new MCLException("Garbage after server challenge: " + reader.getLine());
+		String response = challengeResponse(validated, challenge, callback);
+		writer.writeLine(response);
+		reader.advance();
 
-		try {
-			version = Integer.parseInt(chaltok[2]);	// protocol version
-		} catch (NumberFormatException e) {
-			throw new MCLParseException("Protocol version (" + chaltok[2] + ") unparseable as integer.");
+		// Process the response lines.
+		String redirect = null;
+		StringBuilder errors = new StringBuilder();
+		while (reader.getLineType() != LineType.PROMPT) {
+			switch (reader.getLineType()) {
+				case REDIRECT:
+					if (redirect == null)
+						redirect = reader.getLine(1);
+					break;
+				case ERROR:
+					if (errors.length() > 0)
+						errors.append("\n");
+					errors.append(reader.getLine(7));  // 7 not 1!
+					break;
+				case INFO:
+					warnings.add(reader.getLine(1));
+					break;
+				default:
+					// ignore??!!
+					break;
+			}
+			reader.advance();
 		}
+		if (errors.length() > 0)
+			throw new MCLException(errors.toString());
+
+		if (redirect == null)
+			return true;   // we're happy
 
-		// handle the challenge according to the version it is
-		switch (version) {
-			case 9:
-				// proto 9 is like 8, but uses a hash instead of the plain password
-				// the server tells us (in 6th token) which hash in the
-				// challenge after the byte-order token
+		// process redirect
+		try {
+			MonetUrlParser.parse(target, redirect);
+		} catch (URISyntaxException | ValidationError e) {
+			throw new MCLException("While processing redirect " + redirect + ": " + e.getMessage(), e);
+		}
+        if (redirect.startsWith("mapi:merovingian://proxy")) {
+			// The reader is stuck at LineType.PROMPT but actually the
+			// next challenge is already there.
+            reader.resetLineType();
+			reader.advance();
+        } else {
+            close();
+        }
+
+        return false;   // we need another go
+    }
 
-				String algo;
-				String pwhash = chaltok[5];
-				/* NOTE: Java doesn't support RIPEMD160 :( */
-				/* see: https://docs.oracle.com/javase/8/docs/technotes/guides/security/StandardNames.html#MessageDigest */
-				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(StandardCharsets.UTF_8));
-					password = toHex(md.digest());
-				} catch (NoSuchAlgorithmException e) {
-					throw new MCLException("This JVM does not support password hash: " + pwhash + "\n" + e);
-				}
+	private String challengeResponse(
+			Target.Validated validated, final String challengeLine,
+			OptionsCallback callback
+	) throws MCLException {
+		// The challengeLine looks like this:
+		//
+		// 45IYyVyRnbgEnK92ad:merovingian:9:RIPEMD160,SHA512,SHA384,SHA256,SHA224,SHA1:LIT:SHA512:
+		// WgHIibSyH:mserver:9:RIPEMD160,SHA512,SHA384,SHA256,SHA224,SHA1:LIT:SHA512:sql=6:BINARY=1:
+		// 0         1       2 3                                          4   5      6     7
+
+		String parts[] = challengeLine.split(":");
+		if (parts.length < 3)
+			throw new MCLException("Invalid challenge: expect at least 3 fields");
+		String challengePart = parts[0];
+		String serverTypePart = parts[1];
+		String versionPart = parts[2];
+		int version;
+		if (versionPart.equals("9"))
+			version = 9;
+		else
+			throw new MCLException("Protocol versions other than 9 are note supported: " + versionPart);
+		if (parts.length < 6)
+			throw new MCLException("Protocol version " + version + " requires at least 6 fields, found " + parts.length + ": " + challengeLine);
+		String serverHashesPart = parts[3];
+//		String endianPart = parts[4];
+		String passwordHashPart = parts[5];
+		String optionsPart = parts.length > 6 ? parts[6] : null;
+//		String binaryPart = parts.length > 7 ? parts[7] : null;
 
-				// proto 7 (finally) used the challenge and works with a
-				// password hash.  The supported implementations come
-				// from the server challenge.  We chose the best hash
-				// we can find, in the order SHA512, SHA1, MD5, plain.
-				// Also the byte-order is reported in the challenge string,
-				// which makes sense, since only blockmode is supported.
-				// proto 8 made this obsolete, but retained the
-				// byte-order report for future "binary" transports.
-				// In proto 8, the byte-order of the blocks is always little
-				// endian because most machines today are.
-				final String hashes = (hash == null || hash.isEmpty()) ? chaltok[3] : hash;
-				final HashSet<String> hashesSet = new HashSet<String>(java.util.Arrays.asList(hashes.toUpperCase().split("[, ]")));	// split on comma or space
+		String userResponse;
+		String password = target.getPassword();
+		if (serverTypePart.equals("merovingian") && !target.getLanguage().equals("control")) {
+			userResponse = "merovingian";
+			password = "merovingian";
+		} else {
+			userResponse = target.getUser();
+		}
+		String passwordResponse = hashPassword(challengePart, password, passwordHashPart, validated.getHash(), serverHashesPart);
 
-				// if we deal with merovingian, mask our credentials
-				if (chaltok[1].equals("merovingian") && !language.equals("control")) {
-					username = "merovingian";
-					password = "merovingian";
-				}
+		String optionsResponse = handleOptions(callback, optionsPart);
 
-				// reuse variables algo and pwhash
-				algo = null;
-				pwhash = null;
-				if (hashesSet.contains("SHA512")) {
-					algo = "SHA-512";
-					pwhash = "{SHA512}";
-				} else if (hashesSet.contains("SHA384")) {
-					algo = "SHA-384";
-					pwhash = "{SHA384}";
-				} else if (hashesSet.contains("SHA256")) {
-					algo = "SHA-256";
-					pwhash = "{SHA256}";
-				} else if (hashesSet.contains("SHA1")) {
-					algo = "SHA-1";
-					pwhash = "{SHA1}";
-				} else {
-					/* Note: MD5 has been deprecated by security experts and support is removed from Oct 2020 release */
-					throw new MCLException("no supported hash algorithms found in " + hashes);
-				}
-				try {
-					final MessageDigest md = MessageDigest.getInstance(algo);
-					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);
-				}
+		// Response looks like this:
+		//
+		// LIT:monetdb:{RIPEMD160}f2236256e5a9b20a5ecab4396e36c14f66c3e3c5:sql:demo
+		// :FILETRANS:auto_commit=1,reply_size=1000,size_header=0,columnar_protocol=0,time_zone=3600:
+		StringBuilder response = new StringBuilder(80);
+		response.append("BIG:");
+		response.append(userResponse).append(":");
+		response.append(passwordResponse).append(":");
+		response.append(validated.getLanguage()).append(":");
+		response.append(validated.getDatabase()).append(":");
+		response.append("FILETRANS:");
+		response.append(optionsResponse).append(":");
+
+		return response.toString();
+	}
+
+	// challengePart, passwordHashPart, supportedHashesPart, target.getPassword()
+	private String hashPassword(String challenge, String password, String passwordAlgo, String configuredHashes, String serverSupportedAlgos) throws MCLException {
+		int maxHashLength = 512;
+
+		StringBuilder output = new StringBuilder(10 + maxHashLength / 4);
+		MessageDigest passwordDigest = pickBestAlgorithm(Collections.singleton(passwordAlgo), output);
 
-				// TODO: some day when we need this, we should store this
-				if (chaltok[4].equals("BIG")) {
-					// byte-order of server is big-endian
-				} else if (chaltok[4].equals("LIT")) {
-					// byte-order of server is little-endian
-				} else {
-					throw new MCLParseException("Invalid byte-order: " + chaltok[4]);
-				}
+		Set<String> algoSet  = new HashSet(Arrays.asList(serverSupportedAlgos.split(",")));
+		if (!configuredHashes.isEmpty()) {
+			Set<String> keep = new HashSet(Arrays.asList(configuredHashes.toUpperCase().split("[, ]")));
+			algoSet.retainAll(keep);
+			if (algoSet.isEmpty()) {
+				throw new MCLException("None of the hash algorithms <" + configuredHashes + "> are supported, server only supports <" + serverSupportedAlgos + ">");
+			}
+		}
+		MessageDigest challengeDigest = pickBestAlgorithm(algoSet, null);
+
+		// First we use the password algo to hash the password.
+		// Then we use the challenge algo to hash the combination of the resulting hash digits and the challenge.
+		StringBuilder intermediate = new StringBuilder(maxHashLength / 4 + challenge.length());
+		hexhash(intermediate, passwordDigest, password);
+		intermediate.append(challenge);
+		hexhash(output, challengeDigest, intermediate.toString());
+		return output.toString();
+	}
 
-				// compose and return response
-				String response = "BIG:"    // JVM byte-order is big-endian
-						+ username + ":"
-						+ pwhash + ":"
-						+ language + ":"
-						+ (database == null ? "" : database) + ":"
-						+ "FILETRANS:";	 // this capability is added in monetdb-jdbc-3.2.jre8.jar
-				if (chaltok.length > 6) {
-					// if supported, send handshake options
-					for (String part : chaltok[6].split(",")) {
-						if (part.startsWith("sql=") && handshakeOptions != null) {
-							int level;
-							try {
-								level = Integer.parseInt(chaltok[6].substring(4));
-							} catch (NumberFormatException e) {
-								throw new MCLParseException("Invalid handshake level: " + chaltok[6]);
-							}
-							boolean first = true;
-							for (HandshakeOption<?> opt: handshakeOptions) {
-								if (opt.getLevel() < level) {
-									// server supports it
-									if (first) {
-										first = false;
-									} else {
-										response += ",";
-									}
-									response += opt.getFieldName() + "=" + opt.numericValue();
-									opt.setSent(true);
-								}
-							}
-							break;
-						}
-					}
-					// this ':' delimits the handshake options field.
-					response += ":";
-				}
-				return response;
-			default:
-				throw new MCLException("Unsupported protocol version: " + version);
+	private MessageDigest pickBestAlgorithm(Set<String> algos, StringBuilder appendPrefixHere) throws MCLException {
+		for (String[] choice: KNOWN_ALGORITHMS) {
+            String mapiName = choice[0];
+            String algoName = choice[1];
+            MessageDigest digest;
+            if (!algos.contains(mapiName))
+                continue;
+            try {
+                digest = MessageDigest.getInstance(algoName);
+            } catch (NoSuchAlgorithmException e) {
+                continue;
+            }
+            // we found a match
+            if (appendPrefixHere != null) {
+                appendPrefixHere.append('{');
+                appendPrefixHere.append(mapiName);
+                appendPrefixHere.append('}');
+            }
+            return digest;
+        }
+		String algoNames = algos.stream().collect(Collectors.joining());
+		throw new MCLException("No supported hash algorithm: " + algoNames);
+	}
+
+	private void hexhash(StringBuilder buffer, MessageDigest digest, String text) {
+		byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
+		digest.update(bytes);
+		byte[] output = digest.digest();
+		for (byte b: output) {
+			int hi = (b & 0xF0) >> 4;
+			int lo = b & 0x0F;
+			buffer.append(HEXDIGITS[hi]);
+			buffer.append(HEXDIGITS[lo]);
 		}
 	}
 
-	/**
-	 * Small helper method to convert a byte string to a hexadecimal
-	 * string representation.
-	 *
-	 * @param digest the byte array to convert
-	 * @return the byte array as hexadecimal string
-	 */
-	private final static String toHex(final byte[] digest) {
-		final char[] result = new char[digest.length * 2];
-		int pos = 0;
-		for (int i = 0; i < digest.length; i++) {
-			result[pos++] = hexChar((digest[i] & 0xf0) >> 4);
-			result[pos++] = hexChar(digest[i] & 0x0f);
-		}
-		return new String(result);
-	}
+	private String handleOptions(OptionsCallback callback, String optionsPart) throws MCLException {
+		if (callback == null || optionsPart == null || optionsPart.isEmpty())
+			return "";
 
-	private final static char hexChar(final int n) {
-		return (n > 9)
-			? (char) ('a' + (n - 10))
-			: (char) ('0' + n);
-	}
+		StringBuilder buffer = new StringBuilder();
+		callback.setBuffer(buffer);
+        for (String optlevel: optionsPart.split(",")) {
+            int eqindex = optlevel.indexOf('=');
+            if (eqindex < 0)
+                throw new MCLException("Invalid options part in server challenge: " + optionsPart);
+            String lang = optlevel.substring(0, eqindex);
+            int level;
+            try {
+                level = Integer.parseInt(optlevel.substring(eqindex + 1));
+            } catch (NumberFormatException e) {
+                throw new MCLException("Invalid option level in server challenge: " + optlevel);
+            }
+            callback.addOptions(lang, level);
+        }
+
+		return buffer.toString();
+    }
 
 	/**
 	 * Returns an InputStream that reads from this open connection on
@@ -716,7 +638,7 @@ public final class MapiSocket {
 	 */
 	public void debug(final Writer out) {
 		log = out;
-		debug = true;
+		setDebug(true);
 	}
 
 	/**
@@ -748,15 +670,6 @@ public final class MapiSocket {
 	}
 
 	/**
-	 * Set the HandshakeOptions
-	 *
-	 * @param handshakeOptions the options array
-	 */
-	public void setHandshakeOptions(HandshakeOption<?>[] handshakeOptions) {
-		this.handshakeOptions = handshakeOptions;
-	}
-
-	/**
 	 * For internal use
 	 *
 	 * @param b to enable/disable insert 'fake' newline and prompt
@@ -766,6 +679,10 @@ public final class MapiSocket {
 		return fromMonet.setInsertFakePrompts(b);
 	}
 
+	public boolean isDebug() {
+		return target.isDebug();
+	}
+
 
 	/**
 	 * Inner class that is used to write data on a normal stream as a
@@ -805,7 +722,7 @@ public final class MapiSocket {
 			// it's a bit nasty if an exception is thrown from the log,
 			// but ignoring it can be nasty as well, so it is decided to
 			// let it go so there is feedback about something going wrong
-			if (debug) {
+			if (isDebug()) {
 				log.flush();
 			}
 		}
@@ -839,7 +756,7 @@ public final class MapiSocket {
 			// write the actual block
 			out.write(block, 0, writePos);
 
-			if (debug) {
+			if (isDebug()) {
 				if (last) {
 					log("TD ", "write final block: " + writePos + " bytes", false);
 				} else {
@@ -964,7 +881,7 @@ public final class MapiSocket {
 					// if we have read something before, we should have been
 					// able to read the whole, so make this fatal
 					if (off > 0) {
-						if (debug) {
+						if (isDebug()) {
 							log("RD ", "the following incomplete block was received:", false);
 							log("RX ", new String(b, 0, off, StandardCharsets.UTF_8), true);
 						}
@@ -972,7 +889,7 @@ public final class MapiSocket {
 								con.getInetAddress().getHostName() + ":" +
 								con.getPort() + ": Incomplete block read from stream");
 					}
-					if (debug)
+					if (isDebug())
 						log("RD ", "server closed the connection (EOF)", true);
 					return false;
 				}
@@ -1023,7 +940,7 @@ public final class MapiSocket {
 
 			readPos = 0;
 
-			if (debug) {
+			if (isDebug()) {
 				if (wasEndBlock) {
 					log("RD ", "read final block: " + blockLen + " bytes", false);
 				} else {
@@ -1039,7 +956,7 @@ public final class MapiSocket {
 			if (!_read(block, blockLen))
 				return -1;
 
-			if (debug)
+			if (isDebug())
 				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
@@ -1054,7 +971,7 @@ public final class MapiSocket {
 						block[blockLen++] = b;
 					}
 					block[blockLen++] = '\n';
-					if (debug) {
+					if (isDebug()) {
 						log("RD ", "inserting prompt", true);
 					}
 				}
@@ -1070,7 +987,7 @@ public final class MapiSocket {
 					return -1;
 			}
 
-			if (debug)
+			if (isDebug())
 				log("RX ", new String(block, readPos, 1, StandardCharsets.UTF_8), true);
 
 			return (int)block[readPos++];
@@ -1209,7 +1126,7 @@ public final class MapiSocket {
 				con = null;
 			} catch (IOException e) { /* ignore it */ }
 		}
-		if (debug && log != null && log instanceof FileWriter) {
+		if (isDebug() && log != null && log instanceof FileWriter) {
 			try {
 				log.close();
 				log = null;
@@ -1520,4 +1437,22 @@ public final class MapiSocket {
 				return off - origOff;
 		}
 	}
+
+	public static abstract class OptionsCallback {
+		private StringBuilder buffer;
+
+		protected void contribute(String field, int value) {
+			if (buffer.length() > 0)
+				buffer.append(',');
+			buffer.append(field);
+			buffer.append('=');
+			buffer.append(value);
+		}
+
+		public abstract void addOptions(String lang, int level);
+
+		void setBuffer(StringBuilder buf) {
+			buffer = buf;
+		}
+	}
 }
--- a/src/main/java/org/monetdb/mcl/net/MonetUrlParser.java
+++ b/src/main/java/org/monetdb/mcl/net/MonetUrlParser.java
@@ -27,31 +27,24 @@ public class MonetUrlParser {
     }
 
     public static void parse(Target target, String url) throws URISyntaxException, ValidationError {
-        boolean modern = true;
-        if (url.startsWith("mapi:")) {
-            modern = false;
-            url = url.substring(5);
-            if (url.equals("monetdb://")) {
-                // deal with peculiarity of Java's URI parser
-                url = "monetdb:///";
-            }
+        if (url.equals("monetdb://")) {
+            // deal with peculiarity of Java's URI parser
+            url = "monetdb:///";
         }
 
         target.barrier();
-        try {
-            MonetUrlParser parser = new MonetUrlParser(target, url);
-            if (modern) {
-                parser.parseModern();
-            } else {
+        if (url.startsWith("mapi:")) {
+            try {
+                MonetUrlParser parser = new MonetUrlParser(target, url.substring(5));
                 parser.parseClassic();
+            } catch (URISyntaxException e) {
+                URISyntaxException exc = new URISyntaxException(e.getInput(), e.getReason(), -1);
+                exc.setStackTrace(e.getStackTrace());
+                throw exc;
             }
-        } catch (URISyntaxException e) {
-            int idx = e.getIndex();
-            if (idx >= 0 && !modern) {
-                // "mapi:"
-                idx += 5;
-            }
-            throw new URISyntaxException(e.getInput(), e.getReason(), idx);
+        } else {
+            MonetUrlParser parser = new MonetUrlParser(target, url);
+            parser.parseModern();
         }
         target.barrier();
     }
@@ -89,7 +82,6 @@ public class MonetUrlParser {
         String host;
         String remainder;
         int pos;
-        String raw = url.getRawSchemeSpecificPart();
         if (authority == null) {
             if (!url.getRawSchemeSpecificPart().startsWith("//")) {
                 throw new URISyntaxException(urlText, "expected //");
@@ -173,22 +165,48 @@ public class MonetUrlParser {
     }
 
     private void parseClassic() throws URISyntaxException, ValidationError {
+        if (!url.getRawSchemeSpecificPart().startsWith("//")) {
+            throw new URISyntaxException(urlText, "expected //");
+        }
+
         String scheme = url.getScheme();
-        if (scheme == null) throw new URISyntaxException(urlText, "URL scheme must be mapi:monetdb:// or mapi:merovingian://");
+        if (scheme == null)
+            scheme = "";
         switch (scheme) {
             case "monetdb":
-                clearBasic();
+                parseClassicAuthorityAndPath();
                 break;
             case "merovingian":
-                throw new IllegalStateException("mapi:merovingian: not supported yet");
+                String authority = url.getRawAuthority();
+                // authority must be "proxy" ignore authority and path
+                boolean valid = urlText.startsWith("merovingian://proxy?") || urlText.equals("merovingian://proxy");
+                if (!valid)
+                    throw new URISyntaxException(urlText, "with mapi:merovingian:, only //proxy is supported");
+                break;
             default:
                 throw new URISyntaxException(urlText, "URL scheme must be mapi:monetdb:// or mapi:merovingian://");
         }
 
-        if (!url.getRawSchemeSpecificPart().startsWith("//")) {
-            throw new URISyntaxException(urlText, "expected //");
+        final String query = url.getRawQuery();
+        if (query != null) {
+            final String args[] = query.split("&");
+            for (int i = 0; i < args.length; i++) {
+                String arg = args[i];
+                if (arg.startsWith("language=")) {
+                    String language = arg.substring(9);
+                    target.setString(Parameter.LANGUAGE, language);
+                } else if (arg.startsWith("database=")) {
+                    String database = arg.substring(9);
+                    target.setString(Parameter.DATABASE, database);
+                } else {
+                    // ignore
+                }
+            }
         }
+    }
 
+    private void parseClassicAuthorityAndPath() throws URISyntaxException, ValidationError {
+        clearBasic();
         String authority = url.getRawAuthority();
         String host;
         String portStr;
@@ -220,15 +238,12 @@ public class MonetUrlParser {
         }
 
         String path = url.getRawPath();
-        boolean isUnix;
         if (host.isEmpty() && portStr.isEmpty()) {
             // socket
-            isUnix = true;
             target.clear(Parameter.HOST);
             target.setString(Parameter.SOCK, path != null ? path : "");
         } else {
             // tcp
-            isUnix = false;
             target.clear(Parameter.SOCK);
             target.setString(Parameter.HOST, host);
             if (path == null || path.isEmpty()) {
@@ -240,29 +255,12 @@ public class MonetUrlParser {
                 target.setString(Parameter.DATABASE, database);
             }
         }
-
-        final String query = url.getRawQuery();
-        if (query != null) {
-            final String args[] = query.split("&");
-            for (int i = 0; i < args.length; i++) {
-                String arg = args[i];
-                if (arg.startsWith("language=")) {
-                    String language = arg.substring(9);
-                    target.setString(Parameter.LANGUAGE, language);
-                } else if (arg.startsWith("database=")) {
-                    String database = arg.substring(9);
-                    target.setString(Parameter.DATABASE, database);
-                } else {
-                    // ignore
-                }
-            }
-        }
     }
 
     private void clearBasic() {
+        target.clear(Parameter.TLS);
         target.clear(Parameter.HOST);
         target.clear(Parameter.PORT);
-        target.clear(Parameter.SOCK);
         target.clear(Parameter.DATABASE);
     }
 }
--- a/src/main/java/org/monetdb/mcl/net/Target.java
+++ b/src/main/java/org/monetdb/mcl/net/Target.java
@@ -35,6 +35,7 @@ public class Target {
     private boolean userWasSet = false;
     private boolean passwordWasSet = false;
     protected static final Target defaults = new Target();
+    private Validated validated = null;
 
     private static Pattern namePattern = Pattern.compile("^[a-zzA-Z_][-a-zA-Z0-9_.]*$");
     private static Pattern hashPattern = Pattern.compile("^sha256:[0-9a-fA-F:]*$");
@@ -171,6 +172,7 @@ public class Target {
 
     public void setTls(boolean tls) {
         this.tls = tls;
+        validated = null;
     }
 
     public String getHost() {
@@ -179,6 +181,7 @@ public class Target {
 
     public void setHost(String host) {
         this.host = host;
+        validated = null;
     }
 
     public int getPort() {
@@ -187,6 +190,7 @@ public class Target {
 
     public void setPort(int port) {
         this.port = port;
+        validated = null;
     }
 
     public String getDatabase() {
@@ -195,6 +199,7 @@ public class Target {
 
     public void setDatabase(String database) {
         this.database = database;
+        validated = null;
     }
 
     public String getTableschema() {
@@ -203,6 +208,7 @@ public class Target {
 
     public void setTableschema(String tableschema) {
         this.tableschema = tableschema;
+        validated = null;
     }
 
     public String getTable() {
@@ -211,6 +217,7 @@ public class Target {
 
     public void setTable(String table) {
         this.table = table;
+        validated = null;
     }
 
     public String getSock() {
@@ -219,6 +226,7 @@ public class Target {
 
     public void setSock(String sock) {
         this.sock = sock;
+        validated = null;
     }
 
     public String getSockdir() {
@@ -227,6 +235,7 @@ public class Target {
 
     public void setSockdir(String sockdir) {
         this.sockdir = sockdir;
+        validated = null;
     }
 
     public String getCert() {
@@ -235,6 +244,7 @@ public class Target {
 
     public void setCert(String cert) {
         this.cert = cert;
+        validated = null;
     }
 
     public String getCerthash() {
@@ -243,6 +253,7 @@ public class Target {
 
     public void setCerthash(String certhash) {
         this.certhash = certhash;
+        validated = null;
     }
 
     public String getClientkey() {
@@ -251,6 +262,7 @@ public class Target {
 
     public void setClientkey(String clientkey) {
         this.clientkey = clientkey;
+        validated = null;
     }
 
     public String getClientcert() {
@@ -259,6 +271,7 @@ public class Target {
 
     public void setClientcert(String clientcert) {
         this.clientcert = clientcert;
+        validated = null;
     }
 
     public String getUser() {
@@ -268,6 +281,7 @@ public class Target {
     public void setUser(String user) {
         this.user = user;
         this.userWasSet = true;
+        validated = null;
     }
 
     public String getPassword() {
@@ -277,6 +291,7 @@ public class Target {
     public void setPassword(String password) {
         this.password = password;
         this.passwordWasSet = true;
+        validated = null;
     }
 
     public String getLanguage() {
@@ -285,6 +300,7 @@ public class Target {
 
     public void setLanguage(String language) {
         this.language = language;
+        validated = null;
     }
 
     public boolean isAutocommit() {
@@ -293,6 +309,7 @@ public class Target {
 
     public void setAutocommit(boolean autocommit) {
         this.autocommit = autocommit;
+        validated = null;
     }
 
     public String getSchema() {
@@ -301,6 +318,7 @@ public class Target {
 
     public void setSchema(String schema) {
         this.schema = schema;
+        validated = null;
     }
 
     public int getTimezone() {
@@ -309,6 +327,7 @@ public class Target {
 
     public void setTimezone(int timezone) {
         this.timezone = timezone;
+        validated = null;
     }
 
     public String getBinary() {
@@ -317,6 +336,7 @@ public class Target {
 
     public void setBinary(String binary) {
         this.binary = binary;
+        validated = null;
     }
 
     public int getReplysize() {
@@ -325,6 +345,7 @@ public class Target {
 
     public void setReplysize(int replysize) {
         this.replysize = replysize;
+        validated = null;
     }
 
     public String getHash() {
@@ -333,6 +354,7 @@ public class Target {
 
     public void setHash(String hash) {
         this.hash = hash;
+        validated = null;
     }
 
     public boolean isDebug() {
@@ -341,6 +363,7 @@ public class Target {
 
     public void setDebug(boolean debug) {
         this.debug = debug;
+        validated = null;
     }
 
     public String getLogfile() {
@@ -349,6 +372,7 @@ public class Target {
 
     public void setLogfile(String logfile) {
         this.logfile = logfile;
+        validated = null;
     }
 
     public int getSoTimeout() {
@@ -358,10 +382,12 @@ public class Target {
 
     public void setSoTimeout(int soTimeout) {
         this.soTimeout = soTimeout;
+        validated = null;
     }
 
     public void setTreatClobAsVarchar(boolean treatClobAsVarchar) {
         this.treatClobAsVarchar = treatClobAsVarchar;
+        validated = null;
     }
 
     public boolean isTreatClobAsVarchar() {
@@ -374,10 +400,23 @@ public class Target {
 
     public void setTreatBlobAsBinary(boolean treatBlobAsBinary) {
         this.treatBlobAsBinary = treatBlobAsBinary;
+        validated = null;
     }
 
     public Validated validate() throws ValidationError {
-        return new Validated();
+        if (validated == null)
+            validated = new Validated();
+        return validated;
+    }
+
+    public String buildUrl() {
+        final StringBuilder sb = new StringBuilder(128);
+        sb.append("jdbc:monetdb://").append(host)
+                .append(':').append(port)
+                .append('/').append(database);
+        if (!language.equals("sql"))
+            sb.append("?language=").append(language);
+        return sb.toString();
     }
 
     public class Validated {
@@ -457,6 +496,10 @@ public class Target {
             // 9. If **clientcert** is set, **clientkey** must also be set.
             if (!clientcert.isEmpty() && clientkey.isEmpty())
                 throw new ValidationError("clientcert= is only valid in combination with clientkey=");
+
+            // JDBC specific
+            if (soTimeout < 0)
+                throw new ValidationError("so_timeout= must not be negative");
         }
 
         public boolean getTls() {
--- a/tests/tests.md
+++ b/tests/tests.md
@@ -1457,6 +1457,40 @@ EXPECT language=sql
 ACCEPT mapi:monetdb://localhost:12345/db?_l%61nguage=mal
 ```
 
+
+## Merovingian URLs
+
+These occur in redirects.
+Leave host, port and database are not cleared the way the are
+for `monetdb:`, `monetdbs:` and `mapi:monetdb:` URLs.
+
+```test
+SET host=banana
+SET port=123
+SET tls=on
+SET sock=/tmp/sock
+SET database=dummy
+PARSE mapi:merovingian://proxy
+EXPECT host=banana
+EXPECT port=123
+EXPECT tls=on
+EXPECT sock=/tmp/sock
+EXPECT database=dummy
+PARSE mapi:merovingian://proxy?
+EXPECT host=banana
+EXPECT port=123
+EXPECT tls=on
+EXPECT sock=/tmp/sock
+EXPECT database=dummy
+PARSE mapi:merovingian://proxy?database=yeah&unknown=unknown
+EXPECT host=banana
+EXPECT port=123
+EXPECT tls=on
+EXPECT sock=/tmp/sock
+EXPECT database=yeah
+```
+
+
 # lalala Java
 
 ```test