/*
* Copyright 2012-2017 the original author or authors.
*
* 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 org.glowroot.agent.embedded.util;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.TreeMap;
import javax.annotation.Nullable;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import org.checkerframework.checker.nullness.qual.PolyNull;
import org.checkerframework.checker.tainting.qual.Untainted;
import org.immutables.value.Value;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static com.google.common.base.Preconditions.checkNotNull;
import static org.glowroot.agent.util.Checkers.castUntainted;
public class Schemas {
private static final Logger logger = LoggerFactory.getLogger(Schemas.class);
private static final Map<ColumnType, String> typeNames = Maps.newHashMap();
static {
// these are type mappings for H2
typeNames.put(ColumnType.VARCHAR, "varchar");
typeNames.put(ColumnType.BIGINT, "bigint");
typeNames.put(ColumnType.BOOLEAN, "boolean");
typeNames.put(ColumnType.VARBINARY, "varbinary");
typeNames.put(ColumnType.DOUBLE, "double");
typeNames.put(ColumnType.AUTO_IDENTITY, "bigint identity");
}
private Schemas() {}
static void syncTable(@Untainted String tableName, List<Column> columns, Connection connection)
throws SQLException {
if (!tableExists(tableName, connection)) {
createTable(tableName, columns, connection);
} else if (tableNeedsUpgrade(tableName, columns, connection)) {
logger.warn(
"upgrading table {}, which unfortunately at this point just means dropping and"
+ " re-create the table (losing existing data)",
tableName);
execute("drop table " + tableName, connection);
createTable(tableName, columns, connection);
}
}
static void syncIndexes(@Untainted String tableName, ImmutableList<Index> indexes,
Connection connection) throws SQLException {
ImmutableSet<Index> desiredIndexes = ImmutableSet.copyOf(indexes);
Set<Index> existingIndexes = getIndexes(tableName, connection);
for (Index index : Sets.difference(existingIndexes, desiredIndexes)) {
execute("drop index " + index.name(), connection);
}
for (Index index : Sets.difference(desiredIndexes, existingIndexes)) {
createIndex(tableName, index, connection);
}
// test the logic
existingIndexes = getIndexes(tableName, connection);
if (!existingIndexes.equals(desiredIndexes)) {
logger.error("the logic in syncIndexes() needs fixing");
}
}
// useful for upgrades
static boolean tableExists(String tableName, Connection connection) throws SQLException {
logger.debug("tableExists(): tableName={}", tableName);
ResultSet resultSet = getMetaDataTables(connection, tableName);
ResultSetCloser closer = new ResultSetCloser(resultSet);
try {
return resultSet.next();
} catch (Throwable t) {
throw closer.rethrow(t);
} finally {
closer.close();
}
}
// useful for upgrades
static boolean columnExists(String tableName, String columnName, Connection connection)
throws SQLException {
logger.debug("columnExists(): tableName={}, columnName={}", tableName, columnName);
ResultSet resultSet = getMetaDataColumns(connection, tableName, columnName);
ResultSetCloser closer = new ResultSetCloser(resultSet);
try {
return resultSet.next();
} catch (Throwable t) {
throw closer.rethrow(t);
} finally {
closer.close();
}
}
private static void createTable(@Untainted String tableName, List<Column> columns,
Connection connection) throws SQLException {
StringBuilder sql = new StringBuilder();
sql.append("create table ");
sql.append(tableName);
sql.append(" (");
for (int i = 0; i < columns.size(); i++) {
if (i > 0) {
sql.append(", ");
}
String sqlTypeName = typeNames.get(columns.get(i).type());
checkNotNull(sqlTypeName, "Unexpected sql type: %s", columns.get(i).type());
sql.append(columns.get(i).name());
sql.append(" ");
sql.append(sqlTypeName);
}
sql.append(")");
execute(castUntainted(sql.toString()), connection);
if (tableNeedsUpgrade(tableName, columns, connection)) {
logger.warn("table {} thinks it still needs to be upgraded, even after it was just"
+ " upgraded", tableName);
}
}
private static boolean tableNeedsUpgrade(String tableName, List<Column> columns,
Connection connection) throws SQLException {
// can't use Maps.newTreeMap() because of OpenJDK6 type inference bug
// see https://code.google.com/p/guava-libraries/issues/detail?id=635
Map<String, Column> columnMap = new TreeMap<String, Column>(String.CASE_INSENSITIVE_ORDER);
for (Column column : columns) {
columnMap.put(column.name(), column);
}
ResultSet resultSet = getMetaDataColumns(connection, tableName, null);
ResultSetCloser closer = new ResultSetCloser(resultSet);
try {
return !columnNamesAndTypesMatch(resultSet, columnMap, connection);
} catch (Throwable t) {
throw closer.rethrow(t);
} finally {
closer.close();
}
}
private static boolean columnNamesAndTypesMatch(ResultSet resultSet,
Map<String, Column> columnMap, Connection connection) throws SQLException {
while (resultSet.next()) {
Column column = columnMap.remove(resultSet.getString("COLUMN_NAME"));
if (column == null) {
return false;
}
String typeName = typeNames.get(column.type());
if (typeName == null) {
return false;
}
// this is just to deal with "bigint identity"
int index = typeName.indexOf(' ');
if (index != -1) {
typeName = typeName.substring(0, index);
}
typeName = convert(connection.getMetaData(), typeName);
if (!typeName.equals(resultSet.getString("TYPE_NAME"))) {
return false;
}
}
return columnMap.isEmpty();
}
@VisibleForTesting
static ImmutableSet<Index> getIndexes(String tableName, Connection connection)
throws SQLException {
ListMultimap</*@Untainted*/ String, /*@Untainted*/ String> indexColumns =
ArrayListMultimap.create();
ResultSet resultSet = getMetaDataIndexInfo(connection, tableName);
ResultSetCloser closer = new ResultSetCloser(resultSet);
try {
while (resultSet.next()) {
String indexName = checkNotNull(resultSet.getString("INDEX_NAME"));
String columnName = checkNotNull(resultSet.getString("COLUMN_NAME"));
// hack-ish to skip over primary key constraints which seem to be always
// prefixed in H2 by PRIMARY_KEY_
if (!indexName.startsWith("PRIMARY_KEY_")) {
indexColumns.put(castUntainted(indexName), castUntainted(columnName));
}
}
} catch (Throwable t) {
throw closer.rethrow(t);
} finally {
closer.close();
}
ImmutableSet.Builder<Index> indexes = ImmutableSet.builder();
for (Entry</*@Untainted*/ String, Collection</*@Untainted*/ String>> entry : indexColumns
.asMap().entrySet()) {
String name = entry.getKey().toLowerCase(Locale.ENGLISH);
List<String> columns = Lists.newArrayList();
for (String column : entry.getValue()) {
columns.add(column.toLowerCase(Locale.ENGLISH));
}
indexes.add(ImmutableIndex.of(name, columns));
}
return indexes.build();
}
private static void createIndex(String tableName, Index index, Connection connection)
throws SQLException {
StringBuilder sql = new StringBuilder();
sql.append("create index ");
sql.append(index.name());
sql.append(" on ");
sql.append(tableName);
sql.append(" (");
for (int i = 0; i < index.columns().size(); i++) {
if (i > 0) {
sql.append(", ");
}
sql.append(index.columns().get(i));
}
sql.append(")");
execute(castUntainted(sql.toString()), connection);
}
private static void execute(@Untainted String sql, Connection connection) throws SQLException {
Statement statement = connection.createStatement();
try {
statement.execute(sql);
} finally {
statement.close();
}
}
private static ResultSet getMetaDataTables(Connection connection, String tableName)
throws SQLException {
DatabaseMetaData metaData = connection.getMetaData();
return metaData.getTables(null, null, convert(metaData, tableName), null);
}
private static ResultSet getMetaDataColumns(Connection connection, String tableName,
@Nullable String columnName) throws SQLException {
DatabaseMetaData metaData = connection.getMetaData();
return metaData.getColumns(null, null, convert(metaData, tableName),
convert(metaData, columnName));
}
private static ResultSet getMetaDataIndexInfo(Connection connection, String tableName)
throws SQLException {
DatabaseMetaData metaData = connection.getMetaData();
return metaData.getIndexInfo(null, null, convert(metaData, tableName), false, false);
}
private static @PolyNull String convert(DatabaseMetaData metaData, @PolyNull String name)
throws SQLException {
if (name == null) {
return null;
}
if (metaData.storesUpperCaseIdentifiers()) {
return name.toUpperCase(Locale.ENGLISH);
} else {
return name;
}
}
public enum ColumnType {
VARCHAR, BIGINT, BOOLEAN, DOUBLE, VARBINARY, AUTO_IDENTITY;
}
@Value.Immutable
public abstract static class Column {
@Value.Parameter
abstract String name();
@Value.Parameter
abstract ColumnType type();
}
@Value.Immutable
public abstract static class Index {
@Value.Parameter
abstract @Untainted String name();
@Value.Parameter
abstract ImmutableList</*@Untainted*/ String> columns();
}
}