/*
* This file is part of LanternServer, licensed under the MIT License (MIT).
*
* Copyright (c) LanternPowered <https://www.lanternpowered.org>
* Copyright (c) SpongePowered <https://www.spongepowered.org>
* Copyright (c) contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the Software), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package org.lanternpowered.server.service.sql;
import com.github.benmanes.caffeine.cache.CacheLoader;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import com.github.benmanes.caffeine.cache.RemovalCause;
import com.github.benmanes.caffeine.cache.RemovalListener;
import com.google.common.collect.ImmutableMap;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.lanternpowered.server.game.Lantern;
import org.spongepowered.api.Sponge;
import org.spongepowered.api.plugin.PluginContainer;
import org.spongepowered.api.service.sql.SqlService;
import java.io.Closeable;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Properties;
import java.util.concurrent.ExecutionException;
import java.util.function.BiFunction;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.sql.DataSource;
/**
* Implementation of a SQL-using service.
*
* <p>This implementation does a few interesting things<br>
* - It's thread-safe
* - It allows applying additional driver-specific connection
* properties -- this allows us to do some light performance tuning in
* cases where we don't want to be as conservative as the driver developers
* - Caches DataSources. This cache is currently never cleared of stale entries
* -- if some plugin makes database connections to a ton of different databases
* we may want to implement this, but it is kinda unimportant.
*/
public class LanternSqlService implements SqlService, Closeable {
private static final Map<String, Properties> PROTOCOL_SPECIFIC_PROPS;
private static final Map<String, BiFunction<PluginContainer, String, String>> PATH_CANONICALIZERS;
static {
ImmutableMap.Builder<String, Properties> build = ImmutableMap.builder();
Properties mySqlProps = new Properties();
// Config options based on:
// http://assets.en.oreilly.com/1/event/21/Connector_J%20Performance%20Gems%20Presentation.pdf
mySqlProps.setProperty("useConfigs", "maxPerformance");
build.put("com.mysql.jdbc.Driver", mySqlProps);
build.put("org.mariadb.jdbc.Driver", mySqlProps);
PROTOCOL_SPECIFIC_PROPS = build.build();
PATH_CANONICALIZERS = ImmutableMap.of("h2", (plugin, orig) -> {
// Bleh if only h2 had a better way of supplying a base directory... oh well...
org.h2.engine.ConnectionInfo h2Info = new org.h2.engine.ConnectionInfo(orig);
if (!h2Info.isPersistent() || h2Info.isRemote()) {
return orig;
}
if (orig.startsWith("file:")) {
orig = orig.substring("file:".length());
}
Path origPath = Paths.get(orig);
if (origPath.isAbsolute()) {
return origPath.toString();
} else {
return Lantern.getGame().getConfigManager().getPluginConfig(plugin)
.getDirectory().resolve(orig).toAbsolutePath().toString();
}
});
}
private final LoadingCache<ConnectionInfo, HikariDataSource> connectionCache =
Caffeine.newBuilder().removalListener((RemovalListener<ConnectionInfo, HikariDataSource>) (key, value, cause) -> {
if (value != null) {
value.close();
}
}).build(new CacheLoader<ConnectionInfo, HikariDataSource>() {
@Override
public HikariDataSource load(@Nonnull ConnectionInfo key) throws Exception {
final HikariConfig config = new HikariConfig();
config.setUsername(key.getUser());
config.setPassword(key.getPassword());
config.setDriverClassName(key.getDriverClassName());
// https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing for info on pool sizing
config.setMaximumPoolSize((Runtime.getRuntime().availableProcessors() * 2) + 1);
final Properties driverSpecificProperties = PROTOCOL_SPECIFIC_PROPS.get(key.getDriverClassName());
if (driverSpecificProperties != null) {
config.setDataSourceProperties(driverSpecificProperties);
}
config.setJdbcUrl(key.getAuthlessUrl());
return new HikariDataSource(config);
}
});
@Override
public DataSource getDataSource(String jdbcConnection) throws SQLException {
return this.getDataSource(null, jdbcConnection);
}
@Override
public DataSource getDataSource(@Nullable Object plugin, String jdbcConnection) throws SQLException {
jdbcConnection = getConnectionUrlFromAlias(jdbcConnection).orElse(jdbcConnection);
PluginContainer container = null;
if (plugin != null) {
container = Sponge.getPluginManager().fromInstance(plugin).orElseThrow(() -> new IllegalArgumentException(
"The provided plugin object does not have an associated plugin container"
+ " (in other words, is 'plugin' actually your plugin object?"));
}
final ConnectionInfo info = ConnectionInfo.fromUrl(container, jdbcConnection);
return this.connectionCache.get(info);
}
@Override
public void close() throws IOException {
this.connectionCache.invalidateAll();
}
public static class ConnectionInfo {
private static final Pattern URL_REGEX = Pattern.compile("(?:jdbc:)?([^:]+):(//)?(?:([^:]+)(?::([^@]+))?@)?(.*)");
@Nullable private final String user;
@Nullable private final String password;
private final String driverClassName;
private final String authlessUrl;
private final String fullUrl;
/**
* Create a new ConnectionInfo with the give parameters
* @param user The username to use when connecting to the database
* @param password The password to connect with. If user is not null, password must not be null
* @param driverClassName The class name of the driver to use for this connection
* @param authlessUrl A JDBC url for this driver not containing authentication information
* @param fullUrl The full jdbc url containing user, password, and database info
*/
public ConnectionInfo(@Nullable String user, @Nullable String password, String driverClassName, String authlessUrl, String fullUrl) {
this.user = user;
this.password = password;
this.driverClassName = driverClassName;
this.authlessUrl = authlessUrl;
this.fullUrl = fullUrl;
}
@Nullable
public String getUser() {
return this.user;
}
@Nullable
public String getPassword() {
return this.password;
}
public String getDriverClassName() {
return this.driverClassName;
}
public String getAuthlessUrl() {
return this.authlessUrl;
}
public String getFullUrl() {
return this.fullUrl;
}
@Override
public boolean equals(@Nullable Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
final ConnectionInfo that = (ConnectionInfo) o;
return Objects.equals(this.user, that.user)
&& Objects.equals(this.password, that.password)
&& Objects.equals(this.driverClassName, that.driverClassName)
&& Objects.equals(this.authlessUrl, that.authlessUrl)
&& Objects.equals(this.fullUrl, that.fullUrl);
}
@Override
public int hashCode() {
return Objects.hash(this.user, this.password, this.driverClassName, this.authlessUrl, this.fullUrl);
}
/**
* Extracts the connection info from a JDBC url with additional authentication information as specified in {@link SqlService}.
*
* @param container The plugin to put a path relative to
* @param fullUrl The full JDBC URL as specified in SqlService
* @return A constructed ConnectionInfo object using the info from the provided URL
* @throws SQLException If the driver for the given URL is not present
*/
public static ConnectionInfo fromUrl(@Nullable PluginContainer container, String fullUrl) throws SQLException {
Matcher match = URL_REGEX.matcher(fullUrl);
if (!match.matches()) {
throw new IllegalArgumentException("URL " + fullUrl + " is not a valid JDBC URL");
}
final String protocol = match.group(1);
final boolean hasSlashes = match.group(2) != null;
final String user = match.group(3);
final String pass = match.group(4);
String serverDatabaseSpecifier = match.group(5);
BiFunction<PluginContainer, String, String> derelativizer = PATH_CANONICALIZERS.get(protocol);
if (container != null && derelativizer != null) {
serverDatabaseSpecifier = derelativizer.apply(container, serverDatabaseSpecifier);
}
final String unauthedUrl = "jdbc:" + protocol + (hasSlashes ? "://" : ":") + serverDatabaseSpecifier;
final String driverClass = DriverManager.getDriver(unauthedUrl).getClass().getCanonicalName();
return new ConnectionInfo(user, pass, driverClass, unauthedUrl, fullUrl);
}
}
@Override
public Optional<String> getConnectionUrlFromAlias(String alias) {
return Optional.empty(); // TODO
}
}