/*
* ModeShape (http://www.modeshape.org)
*
* 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.modeshape.jcr.value.binary;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.HashSet;
import java.util.Properties;
import java.util.Set;
import org.modeshape.common.database.DatabaseType;
import org.modeshape.common.database.DatabaseUtil;
import org.modeshape.common.logging.Logger;
import org.modeshape.common.util.StringUtil;
import org.modeshape.jcr.JcrI18n;
import org.modeshape.jcr.value.BinaryKey;
/**
* Helper class for manipulation with database.
* <p>
* This class looks for database SQL statements in properties files named "<code>binary_store_{type}_database.properties</code>"
* located within the "org/modeshape/jcr/database" area of the classpath, where "<code>{type}</code>" is
* {@link DatabaseUtil#determineType(java.sql.DatabaseMetaData) determined} from the connection, and matches one of the following:
* <ul>
* <li><code>mysql</code></li>
* <li><code>postgres</code></li>
* <li><code>derby</code></li>
* <li><code>hsql</code></li>
* <li><code>h2</code></li>
* <li><code>sqlite</code></li>
* <li><code>db2</code></li>
* <li><code>db2_390</code></li>
* <li><code>informix</code></li>
* <li><code>interbase</code></li>
* <li><code>firebird</code></li>
* <li><code>sqlserver</code></li>
* <li><code>access</code></li>
* <li><code>oracle</code></li>
* <li><code>sybase</code></li>
* </ul>
* If the corresponding file is not found on the classpath, then the "<code>binary_store_default_database.properties</code>" file
* is used.
* </p>
* <p>
* Each property file should contain the set of DDL and DML statements that are used by the binary store, and the
* database-specific file allows database-specific schemas and queries to be used. If the properties file that corresponds to the
* connection's database type is not found on the classpath, then the "<code>binary_store_default_database.properties</code>" file
* is used.
* </p>
* <p>
* ModeShape does not provide out-of-the-box properties files for each of the database types listed above. If you run into any
* problems, you can override the statements by providing a property file that matches the naming pattern described above, and by
* putting that file on the classpath. (If you want to override one of ModeShape's out-of-the-box properties files, then be sure
* to put your custom file first on the classpath.)
* </p>
*
* @author kulikov
* @author Horia Chiorean (hchiorea@redhat.com)
*/
public class Database {
public static final String TABLE_NAME = "CONTENT_STORE";
public static final String STATEMENTS_FILE_PATH = "org/modeshape/jcr/database/";
protected static final String STATEMENTS_FILE_PREFIX = "binary_store_";
protected static final String STATEMENTS_FILENAME_SUFFIX = "_database.properties";
protected static final String DEFAULT_STATEMENTS_FILE_PATH = STATEMENTS_FILE_PATH + STATEMENTS_FILE_PREFIX + "default"
+ STATEMENTS_FILENAME_SUFFIX;
protected static final int DEFAULT_MAX_EXTRACTED_TEXT_LENGTH = 1000;
private static final Logger LOGGER = Logger.getLogger(Database.class);
private static final String INSERT_CONTENT_STMT_KEY = "add_content";
private static final String USED_CONTENT_STMT_KEY = "get_used_content";
private static final String UNUSED_CONTENT_STMT_KEY = "get_unused_content";
private static final String MARK_UNUSED_STMT_KEY = "mark_unused";
private static final String MARK_USED_STMT_KEY = "mark_used";
private static final String REMOVE_EXPIRED_STMT_KEY = "remove_expired";
private static final String GET_MIMETYPE_STMT_KEY = "get_mimetype";
private static final String SET_MIMETYPE_STMT_KEY = "set_mimetype";
private static final String GET_EXTRACTED_TEXT_STMT_KEY = "get_extracted_text";
private static final String SET_EXTRACTED_TEXT_STMT_KEY = "set_extracted_text";
private static final String GET_BINARY_KEYS_STMT_KEY = "get_binary_keys";
private static final String CREATE_TABLE_STMT_KEY = "create_table";
private static final String TABLE_EXISTS_STMT_KEY = "table_exists_query";
private static final String EXTRACTED_TEXT_COLUMN_NAME = "ext_text";
private final String tableName;
private final int maxExtractedTextLength;
private Properties statements;
/**
* Creates new instance of the database.
*
* @param connection a {@link java.sql.Connection} instance; may not be null
* @throws java.io.IOException if the statements cannot be processed
* @throws java.sql.SQLException if the db initialization sequence fails
*/
protected Database( Connection connection ) throws IOException, SQLException {
this(connection, null);
}
/**
* Creates new instance of the database.
*
* @param connection a {@link Connection} instance; may not be null
* @param prefix the prefix for the table name; may be null or blank
* @throws java.io.IOException if the statements cannot be processed
* @throws java.sql.SQLException if the db initialization sequence fails
*/
protected Database(Connection connection,
String prefix) throws IOException, SQLException {
assert connection != null;
DatabaseMetaData metaData = connection.getMetaData();
DatabaseType databaseType = DatabaseUtil.determineType(metaData);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Discovered DBMS type for binary store as '{0}' on '{1}", databaseType, metaData.getURL());
}
String tablePrefix = prefix == null ? null : prefix.trim();
this.tableName = tablePrefix != null && tablePrefix.length() != 0 ? tablePrefix + TABLE_NAME : TABLE_NAME;
initializeStatements(databaseType);
initializeStorage(connection, databaseType);
this.maxExtractedTextLength = determineMaxExtractedTextLength(metaData);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Using max length for extracted text '{0}'", maxExtractedTextLength);
}
}
private int determineMaxExtractedTextLength( DatabaseMetaData metaData ) {
try {
String tableName = this.tableName;
if (metaData.storesLowerCaseIdentifiers()) {
tableName = tableName.toLowerCase();
} else if (metaData.storesUpperCaseIdentifiers()) {
tableName = tableName.toUpperCase();
}
try (ResultSet resultSet = metaData.getColumns(null, null, tableName, EXTRACTED_TEXT_COLUMN_NAME)) {
return resultSet.next() ? resultSet.getInt("COLUMN_SIZE") : DEFAULT_MAX_EXTRACTED_TEXT_LENGTH;
}
} catch (SQLException e) {
LOGGER.debug(e, "Cannot determine the maximum size of the column which holds the extracted text. Defaulting to {0}",
DEFAULT_MAX_EXTRACTED_TEXT_LENGTH);
return DEFAULT_MAX_EXTRACTED_TEXT_LENGTH;
}
}
private void initializeStatements(DatabaseType databaseType) throws IOException {
// Load the default statements ...
String statementsFilename = DEFAULT_STATEMENTS_FILE_PATH;
InputStream statementStream = getClass().getClassLoader().getResourceAsStream(statementsFilename);
Properties defaultStatements = new Properties();
try {
LOGGER.trace("Loading default statement from '{0}'", statementsFilename);
defaultStatements.load(statementStream);
} finally {
statementStream.close();
}
// Look for type-specific statements ...
statementsFilename = STATEMENTS_FILE_PATH + STATEMENTS_FILE_PREFIX + databaseType.nameString().toLowerCase()
+ STATEMENTS_FILENAME_SUFFIX;
statementStream = getClass().getClassLoader().getResourceAsStream(statementsFilename);
if (statementStream != null) {
// Try to read the type-specific statements ...
try {
LOGGER.trace("Loading DBMS-specific statement from '{0}'", statementsFilename);
statements = new Properties(defaultStatements);
statements.load(statementStream);
} finally {
statementStream.close();
}
} else {
// No type-specific statements, so just use the default statements ...
statements = defaultStatements;
LOGGER.trace("No DBMS-specific statement found in '{0}'", statementsFilename);
}
}
private void initializeStorage(Connection connection, DatabaseType databaseType) throws SQLException {
// First, prepare a statement to see if the table exists ...
boolean createTable = true;
try (PreparedStatement exists = prepareStatement(TABLE_EXISTS_STMT_KEY, connection)) {
execute(exists);
createTable = false;
} catch (SQLException e) {
// proceed to create the table ...
}
if (createTable) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Unable to find existing table. Attempting to create '{0}' table in {1}", tableName,
connection.getMetaData().getURL());
}
try (PreparedStatement create = prepareStatement(CREATE_TABLE_STMT_KEY, connection)) {
execute(create);
} catch (SQLException e) {
String msg = JcrI18n.errorCreatingDatabaseTable.text(tableName, databaseType);
throw new RuntimeException(msg, e);
}
}
}
protected String getTableName() {
return tableName;
}
protected PreparedStatement prepareStatement( String statementKey,
Connection connection ) throws SQLException {
String statementString = statements.getProperty(statementKey);
statementString = StringUtil.createString(statementString, tableName);
LOGGER.trace("Preparing statement: {0}", statementString);
return connection.prepareStatement(statementString);
}
protected void insertContent( BinaryKey key,
InputStream stream,
long size,
Connection connection ) throws SQLException {
try (PreparedStatement addContentSql = prepareStatement(INSERT_CONTENT_STMT_KEY, connection)) {
addContentSql.setString(1, key.toString());
addContentSql.setTimestamp(2, new java.sql.Timestamp(System.currentTimeMillis()));
addContentSql.setBinaryStream(3, stream, size);
execute(addContentSql);
try {
// it's not guaranteed that the driver will close the stream, so we mush always close it to prevent read-locks
stream.close();
} catch (IOException e) {
// ignore
}
}
}
protected boolean contentExists( BinaryKey key, boolean inUse, Connection connection ) throws SQLException {
try (PreparedStatement readContentStatement = inUse ?
prepareStatement(USED_CONTENT_STMT_KEY, connection) :
prepareStatement(UNUSED_CONTENT_STMT_KEY, connection)) {
readContentStatement.setString(1, key.toString());
ResultSet rs = executeQuery(readContentStatement);
return rs.next();
} catch (SQLException e) {
LOGGER.debug("Cannot determine if content exists under key '{0}'", key.toString());
return false;
}
}
/**
* Attempts to return the content stream for a given binary value.
* If a content stream can be read from the database, this method will not close the given connection. However, if either
* the stream cannot be found or there is an unexpected exception, this method <b>will close</b> the {@code connection}
* parameter.
*
* @param key a {@link org.modeshape.jcr.value.BinaryKey} the key of the binary value, may not be null
* @param connection a {@link java.sql.Connection} instance, may not be null
* @return either a stream that wraps the input stream of the binary value and closes the connection and the statement when it
* terminates or {@code null}, meaning that the binary was not found.
* @throws SQLException if anything unexpected fails
*/
protected InputStream readContent( BinaryKey key,
Connection connection ) throws SQLException {
try {
// first search the contents which are in use
InputStream is = readStreamFromStatement(USED_CONTENT_STMT_KEY, key, connection);
if (is != null) {
// return the stream without closing the connection
return is;
}
// then search the contents which are in the trash
is = readStreamFromStatement(UNUSED_CONTENT_STMT_KEY, key, connection);
if (is != null) {
// return the stream without closing the connection
return is;
}
// we couldn't find anything, so close the connection
tryToClose(connection);
return null;
} catch (Throwable t) {
tryToClose(connection);
throw t;
}
}
private InputStream readStreamFromStatement( String statement, BinaryKey key, Connection connection ) throws SQLException {
PreparedStatement readContentStatement = prepareStatement(statement, connection);
try {
readContentStatement.setString(1, key.toString());
ResultSet rs = executeQuery(readContentStatement);
if (!rs.next()) {
tryToClose(readContentStatement);
return null;
}
return new DatabaseBinaryStream(connection, readContentStatement, rs.getBinaryStream(1));
} catch (SQLException e) {
tryToClose(readContentStatement);
throw e;
} catch (Throwable t) {
tryToClose(readContentStatement);
throw new RuntimeException(t);
}
}
protected void markUnused( Iterable<BinaryKey> keys,
Connection connection ) throws SQLException {
try (PreparedStatement markUnusedSql = prepareStatement(MARK_UNUSED_STMT_KEY, connection)) {
Timestamp now = new Timestamp(System.currentTimeMillis());
for (BinaryKey key : keys) {
markUnusedSql.setTimestamp(1, now);
markUnusedSql.setString(2, key.toString());
executeUpdate(markUnusedSql);
}
}
}
protected void restoreContent( Connection connection,
Iterable<BinaryKey> keys ) throws SQLException {
try (PreparedStatement markUsedSql = prepareStatement(MARK_USED_STMT_KEY, connection)) {
for (BinaryKey key : keys) {
markUsedSql.setString(1, key.toString());
executeUpdate(markUsedSql);
}
}
}
protected void removeExpiredContent( long deadline,
Connection connection ) throws SQLException {
try (PreparedStatement removedExpiredSql = prepareStatement(REMOVE_EXPIRED_STMT_KEY, connection)) {
removedExpiredSql.setTimestamp(1, new java.sql.Timestamp(deadline));
execute(removedExpiredSql);
}
}
protected String getMimeType( BinaryKey key,
Connection connection ) throws SQLException {
try (PreparedStatement getMimeType = prepareStatement(GET_MIMETYPE_STMT_KEY, connection)) {
getMimeType.setString(1, key.toString());
ResultSet rs = executeQuery(getMimeType);
return rs.next() ? rs.getString(1) : null;
}
}
protected void setMimeType( BinaryKey key,
String mimeType,
Connection connection ) throws SQLException {
try (PreparedStatement setMimeTypeSQL = prepareStatement(SET_MIMETYPE_STMT_KEY, connection)) {
setMimeTypeSQL.setString(1, mimeType);
setMimeTypeSQL.setString(2, key.toString());
executeUpdate(setMimeTypeSQL);
}
}
protected String getExtractedText( BinaryKey key,
Connection connection ) throws SQLException {
try (PreparedStatement getExtractedTextSql = prepareStatement(GET_EXTRACTED_TEXT_STMT_KEY, connection)) {
getExtractedTextSql.setString(1, key.toString());
ResultSet rs = executeQuery(getExtractedTextSql);
return rs.next() ? rs.getString(1) : null;
}
}
protected void setExtractedText( BinaryKey key,
String text,
Connection connection ) throws SQLException {
if (text.length() > maxExtractedTextLength) {
LOGGER.warn(JcrI18n.warnExtractedTextTooLarge, EXTRACTED_TEXT_COLUMN_NAME, this.maxExtractedTextLength, tableName);
text = text.substring(0, maxExtractedTextLength);
}
try (PreparedStatement setExtractedTextSql = prepareStatement(SET_EXTRACTED_TEXT_STMT_KEY, connection)) {
setExtractedTextSql.setString(1, text);
setExtractedTextSql.setString(2, key.toString());
executeUpdate(setExtractedTextSql);
}
}
protected Set<BinaryKey> getBinaryKeys( Connection connection ) throws SQLException {
Set<BinaryKey> keys = new HashSet<>();
try (PreparedStatement getBinaryKeysSql = prepareStatement(GET_BINARY_KEYS_STMT_KEY, connection)) {
ResultSet rs = executeQuery(getBinaryKeysSql);
while (rs.next()) {
keys.add(new BinaryKey(rs.getString(1)));
}
return keys;
}
}
private void execute( PreparedStatement sql ) throws SQLException {
LOGGER.trace("Executing statement: {0}", sql);
sql.execute();
}
private ResultSet executeQuery( PreparedStatement sql ) throws SQLException {
LOGGER.trace("Executing query statement: {0}", sql);
return sql.executeQuery();
}
private void executeUpdate( PreparedStatement sql ) throws SQLException {
LOGGER.trace("Executing update statement: {0}", sql);
sql.executeUpdate();
}
protected class DatabaseBinaryStream extends InputStream {
private final Connection connection;
private final PreparedStatement statement;
private final InputStream jdbcBinaryStream;
protected DatabaseBinaryStream( Connection connection,
PreparedStatement statement,
InputStream jdbcBinaryStream ) {
assert connection != null;
assert statement != null;
this.connection = connection;
this.statement = statement;
// some drivers (notably Oracle) can return a null stream if the stored binary is empty (i.e. has 0 bytes)
this.jdbcBinaryStream = jdbcBinaryStream != null ? jdbcBinaryStream : new ByteArrayInputStream(new byte[0]);
}
@Override
public int read() throws IOException {
return jdbcBinaryStream.read();
}
@Override
public int read( byte[] b ) throws IOException {
return jdbcBinaryStream.read(b);
}
@Override
public int read( byte[] b,
int off,
int len ) throws IOException {
return jdbcBinaryStream.read(b, off, len);
}
@Override
public long skip( long n ) throws IOException {
return jdbcBinaryStream.skip(n);
}
@Override
public int available() throws IOException {
return jdbcBinaryStream.available();
}
@Override
public void close() {
tryToClose(statement);
tryToClose(connection);
}
@Override
public synchronized void mark( int readlimit ) {
jdbcBinaryStream.mark(readlimit);
}
@Override
public synchronized void reset() throws IOException {
jdbcBinaryStream.reset();
}
@Override
public boolean markSupported() {
return jdbcBinaryStream.markSupported();
}
}
private void tryToClose( PreparedStatement statement ) {
if (statement != null) {
try {
statement.close();
} catch (Throwable t) {
LOGGER.debug(t, "Cannot close prepared statement");
}
}
}
private void tryToClose( Connection connection ) {
if (connection != null) {
try {
connection.close();
} catch (Throwable t) {
LOGGER.debug(t, "Cannot close connection");
}
}
}
}