com.mongodb.jdbc.MongoDriver Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of mongodb-jdbc Show documentation
Show all versions of mongodb-jdbc Show documentation
JDBC Driver for MongoDB Atlas SQL interface
/*
* Copyright 2020-present MongoDB, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.mongodb.jdbc;
import static com.mongodb.jdbc.MongoDriver.MongoJDBCProperty.*;
import static org.bson.codecs.configuration.CodecRegistries.fromProviders;
import com.mongodb.AuthenticationMechanism;
import com.mongodb.ConnectionString;
import com.mongodb.MongoClientSettings;
import com.mongodb.client.MongoClient;
import java.io.File;
import java.io.UnsupportedEncodingException;
import java.lang.ref.WeakReference;
import java.net.URLEncoder;
import java.sql.Connection;
import java.sql.Driver;
import java.sql.DriverManager;
import java.sql.DriverPropertyInfo;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
import java.sql.SQLTimeoutException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.logging.Level;
import java.util.stream.Stream;
import org.bson.codecs.BsonValueCodecProvider;
import org.bson.codecs.ValueCodecProvider;
import org.bson.codecs.configuration.CodecRegistry;
import org.bson.codecs.pojo.PojoCodecProvider;
/**
* The MongoDriver implements the java.sql.Driver interface, which allows for opening Connections to
* MonogDB databases that have SQL support.
*
* @see DriverManager
* @see Connection
* @since 1.0.0
*/
public class MongoDriver implements Driver {
/**
* The list of connection options specific to the JDBC driver which can only be provided through
* a Properties Object.
*/
public enum MongoJDBCProperty {
DATABASE("database"),
CLIENT_INFO("clientinfo"),
LOG_LEVEL("loglevel"),
LOG_DIR("logdir"),
EXT_JSON_MODE("extjsonmode");
private final String propertyName;
MongoJDBCProperty(String propertyName) {
this.propertyName = propertyName;
}
public String getPropertyName() {
return propertyName;
}
}
/** All MongoDB SQL URLs must begin with jdbc:mongodb: */
static final String JDBC = "jdbc:";
static final String MONGODB_URL_PREFIX = JDBC + "mongodb:";
static final String MONGODB_SRV_URL_PREFIX = JDBC + "mongodb+srv:";
public static final String USER = "user";
public static final String PASSWORD = "password";
static final String MONGODB_PRODUCT_NAME = "MongoDB Atlas";
static final String MONGO_DRIVER_NAME = MONGODB_PRODUCT_NAME + " SQL interface JDBC Driver";
static final String NAME;
static final String VERSION;
static final int MAJOR_VERSION;
static final int MINOR_VERSION;
static final String LEVELS =
Arrays.toString(
new String[] {
Level.OFF.getName(),
Level.SEVERE.getName(),
Level.FINER.getName(),
Level.INFO.getName(),
Level.FINE.getName(),
Level.WARNING.getName()
});
static final String RELAXED = "RELAXED";
static final String EXTENDED = "EXTENDED";
static final String MONGODB_OIDC = AuthenticationMechanism.MONGODB_OIDC.toString();
public static final String LOG_TO_CONSOLE = "console";
protected static final String CONNECTION_ERROR_SQLSTATE = "08000";
private static ConcurrentHashMap> mongoClientCache =
new ConcurrentHashMap<>();
private static final ReadWriteLock mongoClientCacheLock = new ReentrantReadWriteLock();
public static String getVersion() {
return VERSION != null ? VERSION : MAJOR_VERSION + "." + MINOR_VERSION;
}
static CodecRegistry registry =
fromProviders(
new BsonValueCodecProvider(),
new ValueCodecProvider(),
MongoClientSettings.getDefaultCodecRegistry(),
PojoCodecProvider.builder().automatic(true).build());
static {
MongoDriver unit = new MongoDriver();
try {
DriverManager.registerDriver(unit);
} catch (SQLException e) {
throw new RuntimeException(e);
}
VERSION = unit.getClass().getPackage().getImplementationVersion();
if (VERSION != null) {
String[] verSp = VERSION.split("[.]");
if (verSp.length < 2) {
throw new RuntimeException(
new SQLException(
"version was not specified correctly, must contain at least major and minor parts"));
}
MAJOR_VERSION = Integer.parseInt(verSp[0]);
MINOR_VERSION = Integer.parseInt(verSp[1]);
} else {
// final requires this.
MAJOR_VERSION = 0;
MINOR_VERSION = 0;
}
String name = unit.getClass().getPackage().getImplementationTitle();
NAME = (name != null) ? name : "mongodb-jdbc";
Runtime.getRuntime().addShutdownHook(new Thread(MongoDriver::closeAllClients));
}
@Override
public Connection connect(String url, Properties info) throws SQLException {
Properties lowerCaseprops = new Properties();
// Normalize all properties key to lower case to make all connection settings case-insensitive
if (info != null) {
Enumeration keys = info.propertyNames();
while (keys.hasMoreElements()) {
String key = keys.nextElement().toString();
lowerCaseprops.put(key.toLowerCase(), info.getProperty(key));
}
}
MongoConnection conn = getUnvalidatedConnection(url, lowerCaseprops);
// the jdbc spec requires that null be returned if a Driver cannot handle the specified URL
// (cases where multiple jdbc drivers are present and the program is checking which driver
// to use), so it is possible for conn to be null at this point.
if (conn != null) {
try {
conn.testConnection(conn.getDefaultConnectionValidationTimeoutSeconds());
} catch (TimeoutException te) {
throw new SQLTimeoutException(
"Couldn't connect due to a timeout. Please check your hostname and port. If necessary, set a "
+ "longer connection timeout in the MongoDB URI.");
} catch (Exception e) {
throw new SQLException("Connection failed.", e);
}
}
return conn;
}
protected MongoConnection getUnvalidatedConnection(String url, Properties info)
throws SQLException {
if (!acceptsURL(url)) {
return null;
}
if (info == null) {
info = new Properties();
}
// Ensure that the ConnectionString and Properties are consistent.
// Reuse the code getPropertyInfo to make sure the URI is properly set wrt the passed
// Properties info value.
Pair p = getConnectionSettings(url, info);
// Since the user is calling connect, we should throw a SQLException if we get a prompt back.
if (p.right().length != 0) {
// Inspect the return value to format the SQLException and throw the connection error
throw new SQLException(reportMissingProperties(p.right()), CONNECTION_ERROR_SQLSTATE);
}
return createConnection(p.left(), info);
}
/**
* Provides feedback regarding the missing properties.
*
* @param missingRequiredProperties The list of required properties a connection needs to be
* successful.
* @return the format error message to return to the user.
*/
private String reportMissingProperties(DriverPropertyInfo[] missingRequiredProperties) {
List propertyNames = new ArrayList<>();
StringBuilder sb = new StringBuilder();
sb.append("There are issues with your connection settings : ");
for (DriverPropertyInfo info : missingRequiredProperties) {
if (info.name.equals(USER)) {
sb.append("Password specified without user. Please provide '");
sb.append(USER);
sb.append("' property value.\n");
} else if (info.name.equals(PASSWORD)) {
sb.append("User specified without password. Please provide '");
sb.append(PASSWORD);
sb.append("' property value.\n");
} else if (info.name.equals(DATABASE.getPropertyName())) {
sb.append("Mandatory property '");
sb.append(DATABASE.getPropertyName());
sb.append("' is missing.\n");
} else {
propertyNames.add(info.name);
}
}
if (!propertyNames.isEmpty()) {
sb.append("Unexpected driver property info : ");
sb.append(String.join(", ", propertyNames));
sb.append("\n");
}
return sb.toString();
}
private MongoConnection createConnection(ConnectionString cs, Properties info)
throws SQLException {
// Database from the properties must be present
String database = info.getProperty(DATABASE.getPropertyName());
// Default log level is OFF
String logLevelVal = info.getProperty(LOG_LEVEL.getPropertyName(), Level.OFF.getName());
Level logLevel;
try {
logLevel = Level.parse(logLevelVal.toUpperCase());
} catch (IllegalArgumentException e) {
throw new SQLException(
"Invalid "
+ LOG_LEVEL.getPropertyName()
+ " property value : "
+ logLevelVal
+ ". Valid values are : "
+ LEVELS
+ ".");
}
String logDirVal = info.getProperty(LOG_DIR.getPropertyName());
if ((logDirVal != null) && LOG_TO_CONSOLE.equalsIgnoreCase(logDirVal.trim())) {
// If logDir is "console" then remove the value since the logger
// will default to a console handler if no logDir is specified
logDirVal = null;
}
File logDir = (logDirVal == null) ? null : new File(logDirVal);
if (logDir != null && !logDir.isDirectory()) {
throw new SQLException(
"Invalid "
+ LOG_DIR.getPropertyName()
+ " property value : "
+ logDirVal
+ ". It must be a directory.");
}
String clientInfo = info.getProperty(CLIENT_INFO.getPropertyName());
if (clientInfo != null && clientInfo.split("\\+").length != 2) {
throw new SQLException(
"Invalid "
+ CLIENT_INFO.getPropertyName()
+ " property value : "
+ clientInfo
+ ". Expected format +.");
}
String extJsonModeVal = info.getProperty(EXT_JSON_MODE.getPropertyName());
boolean extJsonMode = false;
if (extJsonModeVal != null) {
extJsonModeVal = extJsonModeVal.toUpperCase().trim();
if (extJsonModeVal == EXTENDED) {
extJsonMode = true;
} else if (extJsonModeVal != RELAXED) {
throw new SQLException("Invalid JSON mode: " + extJsonModeVal);
}
}
MongoConnectionProperties mongoConnectionProperties =
new MongoConnectionProperties(
cs, database, logLevel, logDir, clientInfo, extJsonMode);
Integer key = mongoConnectionProperties.generateKey();
mongoClientCacheLock.readLock().lock();
try {
WeakReference clientRef = mongoClientCache.get(key);
MongoClient client = (clientRef != null) ? clientRef.get() : null;
if (client != null) {
return new MongoConnection(client, mongoConnectionProperties);
}
} finally {
mongoClientCacheLock.readLock().unlock();
}
// Acquire write lock to create and cache a new MongoClient if it wasn't found
mongoClientCacheLock.writeLock().lock();
try {
WeakReference clientRef = mongoClientCache.get(key);
MongoClient client = (clientRef != null) ? clientRef.get() : null;
// Check for client again to handle race conditions
if (client != null) {
return new MongoConnection(client, mongoConnectionProperties);
}
MongoConnection newConnection = new MongoConnection(mongoConnectionProperties);
mongoClientCache.put(key, new WeakReference<>(newConnection.getMongoClient()));
return newConnection;
} finally {
mongoClientCacheLock.writeLock().unlock();
}
}
public static void closeAllClients() {
mongoClientCacheLock.writeLock().lock();
try {
for (WeakReference clientRef : mongoClientCache.values()) {
MongoClient client = clientRef.get();
if (client != null) {
client.close();
}
}
mongoClientCache.clear();
} finally {
mongoClientCacheLock.writeLock().unlock();
}
}
@Override
public boolean acceptsURL(String url) throws SQLException {
return url.startsWith(MONGODB_URL_PREFIX) || url.startsWith(MONGODB_SRV_URL_PREFIX);
}
@Override
public DriverPropertyInfo[] getPropertyInfo(String url, java.util.Properties info)
throws SQLException {
Pair p = getConnectionSettings(url, info);
return p.right();
}
@Override
public int getMajorVersion() {
return 1;
}
@Override
public int getMinorVersion() {
return 0;
}
@Override
public boolean jdbcCompliant() {
return false;
}
// ------------------------- JDBC 4.1 -----------------------------------
@Override
public java.util.logging.Logger getParentLogger() throws SQLFeatureNotSupportedException {
throw new SQLFeatureNotSupportedException();
}
// removePrefix removes a prefix from a String.
private static String removePrefix(String prefix, String s) {
if (s != null && prefix != null && s.startsWith(prefix)) {
return s.substring(prefix.length());
}
return s;
}
private static class ParseResult {
String user;
char[] password;
String authMechanism;
Properties normalizedOptions;
ParseResult(String u, char[] p, String a, Properties options) {
user = u;
password = p;
authMechanism = a;
normalizedOptions = options;
}
}
// getConnectionString constructs a valid MongoDB connection string which will be used as an input to the mongoClient.
// If there are required fields missing, those fields will be returned in DriverPropertyInfo[] with a null connectionString
public static Pair getConnectionSettings(
String url, Properties info) throws SQLException {
if (info == null) {
info = new Properties();
}
String actualURL = removePrefix(JDBC, url);
ConnectionString originalConnectionString;
try {
originalConnectionString = new ConnectionString(actualURL);
} catch (Exception e) {
throw new SQLException(e);
}
ParseResult result = normalizeConnectionOptions(originalConnectionString, info);
String user = result.user;
char[] password = result.password;
List mandatoryConnectionProperties = new ArrayList<>();
// A database to connect to is required. If they have not specified one, look in the connection string for a
// database. The specified database in the connect window will always override the uri database.
if ((!info.containsKey(DATABASE.getPropertyName())
|| info.getProperty(DATABASE.getPropertyName()).isEmpty())
&& originalConnectionString.getDatabase() != null) {
info.setProperty(DATABASE.getPropertyName(), originalConnectionString.getDatabase());
}
if (!info.containsKey(DATABASE.getPropertyName())
|| info.getProperty(DATABASE.getPropertyName()).isEmpty()) {
mandatoryConnectionProperties.add(
new DriverPropertyInfo(DATABASE.getPropertyName(), null));
}
String authDatabase = info.getProperty(DATABASE.getPropertyName());
if (user == null && password != null) {
// user is null, but password is not, we must prompt for the user.
// Note: The convention is actually to return DriverPropertyInfo objects
// with null values, this is not a bug.
mandatoryConnectionProperties.add(new DriverPropertyInfo(USER, null));
}
if (password == null
&& user != null
&& !MONGODB_OIDC.equalsIgnoreCase(result.authMechanism)) {
// password is null, but user is not, we must prompt for the password.
mandatoryConnectionProperties.add(new DriverPropertyInfo(PASSWORD, null));
}
// If mandatoryConnectionProperties is not empty, we stop here because we are missing connection information
if (mandatoryConnectionProperties.size() > 0) {
return new Pair<>(
null,
mandatoryConnectionProperties.toArray(
new DriverPropertyInfo[mandatoryConnectionProperties.size()]));
}
// If we are here, we must have all the required connection information. So we have a valid URI state,
// go ahead and construct it and prompt for nothing.
ConnectionString c =
new ConnectionString(
buildNewURI(
originalConnectionString.getHosts(),
user,
password,
authDatabase,
result.authMechanism,
result.normalizedOptions));
return new Pair<>(c, new DriverPropertyInfo[] {});
}
private static interface NullCoalesce {
T coalesce(T left, T right);
}
// private helper function to abstract checking consistency between properties and the URI, and
// grabbing the username, password and the consolidated connection arguments
//
// throws SQLException if url and options properties disagree on the value
private static ParseResult normalizeConnectionOptions(
ConnectionString clientURI, Properties info) throws SQLException {
String user = null;
char[] password = null;
if (info == null) {
info = new Properties();
}
// The coalesce function takes the first non-null argument, returning null only
// if both arguments are null. The java type system requires us to write this twice,
// once for each type we care about, unless we prefer to use Objects and cast, but I avoid
// that.
NullCoalesce s =
(left, right) -> {
if (left == null) {
return right;
}
return left;
};
NullCoalesce c =
(left, right) -> {
if (left == null) {
return right;
}
return left;
};
String authMechanism =
clientURI.getCredential() != null ? clientURI.getCredential().getMechanism() : null;
// grab the user and password from the URI.
String uriUser = clientURI.getUsername();
char[] uriPWD = clientURI.getPassword();
String propertyUser = info.getProperty(USER);
String propertyPWDStr = info.getProperty(PASSWORD);
char[] propertyPWD = propertyPWDStr != null ? propertyPWDStr.toCharArray() : null;
// handle disagreements on user.
if (MONGODB_OIDC.equalsIgnoreCase(authMechanism)) {
if (uriPWD != null || propertyPWD != null) {
throw new SQLException(
"Password should not be specified when using MONGODB-OIDC authentication");
}
user = s.coalesce(uriUser, propertyUser);
} else {
if (uriUser != null && propertyUser != null && !uriUser.equals(propertyUser)) {
throw new SQLException(
"uri and properties disagree on user: '"
+ uriUser
+ ", and "
+ propertyUser
+ " respectively");
}
// set the user
user = s.coalesce(uriUser, propertyUser);
if (user != null) {
// Make sure the `info` reflects the URL for USER because MongoDatabaseMetaData needs to
// know this.
info.setProperty(USER, user);
}
// handle disagreements on password.
if (uriPWD != null && propertyPWD != null && !Arrays.equals(uriPWD, propertyPWD)) {
throw new SQLException("uri and properties disagree on password");
}
// set the password
password = c.coalesce(uriPWD, propertyPWD);
}
String optionString = null;
String[] optionSplit =
clientURI
.getConnectionString()
.split("[?]"); // split takes a regexp and '?' is a metachar.
if (optionSplit.length > 1) {
optionString = optionSplit[1];
}
Properties options = new Properties();
if (optionString != null) {
String[] optionStrs = optionString.split("&");
for (String optionStr : optionStrs) {
String[] kv = optionStr.split("=");
if (kv.length != 2) {
throw new SQLException("Option String is not valid");
}
String normalizedKey = kv[0].toLowerCase();
if (normalizedKey.equals(USER) || normalizedKey.equals(PASSWORD)) {
continue;
}
options.put(normalizedKey, kv[1]);
}
}
for (String key : info.stringPropertyNames()) {
String normalizedKey = key.toLowerCase();
if (normalizedKey.equals(USER) || normalizedKey.equals(PASSWORD)) {
continue;
}
String val = info.getProperty(key);
if (options.containsKey(normalizedKey)) {
if (!options.getProperty(normalizedKey).equals(val)) {
throw new SQLException("uri and properties disagree on " + key);
}
} else {
options.setProperty(normalizedKey, val);
}
}
return new ParseResult(user, password, authMechanism, options);
}
// This is just a clean abstraction around URLEncode.
private static String sqlURLEncode(String item) throws SQLException {
try {
return URLEncoder.encode(item, "utf-8");
} catch (Exception e) {
throw new SQLException(e);
}
}
/**
* Return true if the given key is a JDBC specific property, false otherwise.
*
* @param key The key to check.
* @return true if the given key is a JDBC specific property, false otherwise.
*/
private static boolean isMongoJDBCProperty(String key) {
return Stream.of(MongoJDBCProperty.values())
.anyMatch(v -> v.getPropertyName().equalsIgnoreCase(key));
}
/**
* This function builds a new uri from the original clientURI, adding user, password, options,
* and database, if necessary.
*
* @param hosts the list of MongoDB host names
* @param user the auth username
* @param password the auth password
* @param authDatabase the authentication database to use if authSource option is not specified
* @param options the consolidated tag/value pairs from the url query string and connection
* arguments user provided,
*/
private static String buildNewURI(
List hosts,
String user,
char[] password,
String authDatabase,
String authMechanism,
Properties options)
throws SQLException {
// The returned URI should be of the following format:
//"mongodb://[user:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[authDatabase][?options]]")
String ret = "mongodb://";
if (user != null) {
ret += sqlURLEncode(user);
if (password != null) {
ret += ":" + sqlURLEncode(String.valueOf(password));
}
ret += "@";
}
// Now add hosts.
ret += String.join(",", hosts);
// Now add authDatabase, if necessary.
if (authDatabase != null) {
ret += "/" + sqlURLEncode(authDatabase);
} else {
ret += "/";
}
StringBuilder buff = new StringBuilder();
if (options != null) {
for (String key : options.stringPropertyNames()) {
// Only add keys which are part of the standard MongoDB URI (except user and password) and skip JDBC
// //specific properties which can't be specified via the connection string
if (!key.equals(USER) && !key.equals(PASSWORD) && !isMongoJDBCProperty(key)) {
if (buff.length() > 0) {
buff.append("&");
}
try {
buff.append(key)
.append("=")
.append(URLEncoder.encode(options.getProperty(key), "utf-8"));
} catch (UnsupportedEncodingException e) {
throw new SQLException(e);
}
}
}
}
if (buff.length() > 0) {
ret += "?" + buff;
}
return ret;
}
}