diff --git a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/database/StoredProcedureItemReader.java b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/database/StoredProcedureItemReader.java new file mode 100644 index 000000000..687d6cdf9 --- /dev/null +++ b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/database/StoredProcedureItemReader.java @@ -0,0 +1,605 @@ +/* + * Copyright 2006-2009 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.springframework.batch.item.database; + +import java.sql.CallableStatement; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.SQLWarning; +import java.sql.Statement; +import java.sql.Types; + +import javax.sql.DataSource; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.item.ItemStream; +import org.springframework.batch.item.ReaderNotOpenException; +import org.springframework.batch.item.support.AbstractItemCountingItemStreamItemReader; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.dao.InvalidDataAccessResourceUsageException; +import org.springframework.jdbc.SQLWarningException; +import org.springframework.jdbc.core.PreparedStatementSetter; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.SqlOutParameter; +import org.springframework.jdbc.core.SqlParameter; +import org.springframework.jdbc.core.metadata.CallMetaDataContext; +import org.springframework.jdbc.datasource.DataSourceUtils; +import org.springframework.jdbc.object.SqlCall; +import org.springframework.jdbc.support.JdbcUtils; +import org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator; +import org.springframework.jdbc.support.SQLExceptionTranslator; +import org.springframework.jdbc.support.SQLStateSQLExceptionTranslator; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + *

+ * Item reader that executes a stored procedure and then reads the returned cursor and continually + * retrieves the next row in the ResultSet. + *

+ * + *

+ * By default the procedure will be executed using a separate connection. The ResultSet for the cursor + * is held open regardless of commits or roll backs in a surrounding transaction. Clients of this + * reader are responsible for buffering the items in the case that they need to be re-presented on a + * rollback. This buffering is handled by the step implementations provided and is only a concern for + * anyone writing their own step implementations. + *

+ * + *

+ * The callable statement used to open the cursor is created with the 'READ_ONLY' option as well as with the + * 'TYPE_FORWARD_ONLY' option. By default the cursor will be opened using a separate connection which means + * that it will not participate in any transactions created as part of the step processing. + *

+ * + *

+ * There is an option ({@link #setUseSharedExtendedConnection(boolean)} that will share the connection + * used for the cursor with the rest of the step processing. If you set this flag to true + * then you must wrap the DataSource in a {@link ExtendedConnectionDataSourceProxy} to prevent the + * connection from being closed and released after each commit performed as part of the step processing. + * You must also use a JDBC driver supporting JDBC 3.0 or later since the cursor will be opened with the + * additional option of 'HOLD_CUSORS_OVER_COMMIT' enabled. + *

+ * + *

+ * Each call to {@link #read()} will call the provided RowMapper, passing in the + * ResultSet. There is currently no wrapping of the ResultSet to suppress calls + * to next(). However, if the RowMapper (mistakenly) increments the current row, + * the next call to read will verify that the current row is at the expected + * position and throw a DataAccessException if it is not. The reason for such strictness on the + * ResultSet is due to the need to maintain control for transactions and + * restartability. This ensures that each call to {@link #read()} returns the + * ResultSet at the correct row, regardless of rollbacks or restarts. + *

+ * + *

+ * {@link ExecutionContext}: The current row is returned as restart data, and + * when restored from that same data, the cursor is opened and the current row + * set to the value within the restart data. See + * {@link #setDriverSupportsAbsolute(boolean)} for improving restart + * performance. + *

+ * + *

+ * Calling close on this {@link ItemStream} will cause all resources it is + * currently using to be freed. (Connection, ResultSet, etc). It is then illegal + * to call {@link #read()} again until it has been re-opened. + *

+ * + *

+ * Known limitation: when used with Derby + * {@link #setVerifyCursorPosition(boolean)} needs to be false + * because {@link ResultSet#getRow()} call used for cursor position verification + * is not available for 'TYPE_FORWARD_ONLY' result sets. + *

+ * + *

+ * This class is modeled after the similar JdbcCursorItemReader class. + *

+ * + * @author Lucas Ward + * @author Peter Zozom + * @author Robert Kasanicky + * @author Thomas Risberg + */ +public class StoredProcedureItemReader extends AbstractItemCountingItemStreamItemReader implements InitializingBean { + + private static Log log = LogFactory.getLog(StoredProcedureItemReader.class); + + public static final int VALUE_NOT_SET = -1; + + private Connection con; + + private CallableStatement callableStatement; + + private PreparedStatementSetter preparedStatementSetter; + + protected ResultSet rs; + + private DataSource dataSource; + + private String procedureName; + + private int fetchSize = VALUE_NOT_SET; + + private int maxRows = VALUE_NOT_SET; + + private int queryTimeout = VALUE_NOT_SET; + + private boolean ignoreWarnings = true; + + private boolean verifyCursorPosition = true; + + private SQLExceptionTranslator exceptionTranslator; + + private RowMapper rowMapper; + + private boolean initialized = false; + + private boolean driverSupportsAbsolute = false; + + private boolean useSharedExtendedConnection = false; + + private SqlParameter[] parameters = new SqlParameter[0]; + + private boolean function = false; + + private int refCursorPosition = 0; + + public StoredProcedureItemReader() { + setName(ClassUtils.getShortName(StoredProcedureItemReader.class)); + } + + /** + * Assert that mandatory properties are set. + * + * @throws IllegalArgumentException if either data source or sql properties + * not set. + */ + public void afterPropertiesSet() throws Exception { + Assert.notNull(dataSource, "DataSource must be provided"); + Assert.notNull(procedureName, "The name of the stored procedure must be provided"); + Assert.notNull(rowMapper, "RowMapper must be provided"); + } + + /** + * Public setter for the data source for injection purposes. + * + * @param dataSource + */ + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + /** + * Executes the provided SQL query. + */ + private void executeQuery() { + + Assert.state(dataSource != null, "DataSource must not be null."); + Assert.state(procedureName != null, "Procedure Name must not be null."); + Assert.state(refCursorPosition == 0 || refCursorPosition > parameters.length, + "refCursorPosition specified as " + refCursorPosition + " but there are only " + + parameters.length + " parameters defined."); + + CallMetaDataContext callContext = new CallMetaDataContext(); + callContext.setAccessCallParameterMetaData(false); + callContext.setProcedureName(procedureName); + callContext.setFunction(function); + callContext.initializeMetaData(dataSource); + SqlParameter cursorParameter = callContext.createReturnResultSetParameter("return", rowMapper); + + int cursorSqlType = Types.OTHER; + if (function) { + if (cursorParameter instanceof SqlOutParameter) { + cursorSqlType = cursorParameter.getSqlType(); + } + } + else { + if (refCursorPosition > 0 && refCursorPosition <= parameters.length) { + cursorSqlType = parameters[refCursorPosition - 1].getSqlType(); + } + } + + SqlCall call = new SqlCall(dataSource, procedureName){}; + call.setParameters(parameters); + call.setFunction(function); + call.compile(); + String callString = call.getCallString(); + call = null; + + try { + if (useSharedExtendedConnection) { + if (!(dataSource instanceof ExtendedConnectionDataSourceProxy)) { + throw new InvalidDataAccessApiUsageException( + "You must use a ExtendedConnectionDataSourceProxy for the dataSource when " + + "useSharedExtendedConnection is set to true."); + } + this.con = DataSourceUtils.getConnection(dataSource); + ((ExtendedConnectionDataSourceProxy)dataSource).startCloseSuppression(this.con); + callableStatement = this.con.prepareCall(callString, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY, + ResultSet.HOLD_CURSORS_OVER_COMMIT); + } + else { + this.con = dataSource.getConnection(); + callableStatement = this.con.prepareCall(callString, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY); + } + applyStatementSettings(callableStatement); + if (this.preparedStatementSetter != null) { + preparedStatementSetter.setValues(callableStatement); + } + + if (function) { + callableStatement.registerOutParameter(1, cursorSqlType); + } + else { + callableStatement.registerOutParameter(refCursorPosition, cursorSqlType); + } + boolean results = callableStatement.execute(); + if (results) { + rs = callableStatement.getResultSet(); + } + else { + if (function) { + rs = (ResultSet) callableStatement.getObject(1); + } + else { + rs = (ResultSet) callableStatement.getObject(refCursorPosition); + } + } + handleWarnings(callableStatement); + } + catch (SQLException se) { + close(); + throw getExceptionTranslator().translate("Executing stored procedure", callString, se); + } + + } + + /** + * Prepare the given JDBC Statement (or PreparedStatement or + * CallableStatement), applying statement settings such as fetch size, max + * rows, and query timeout. @param stmt the JDBC Statement to prepare + * @throws SQLException + * + * @see #setFetchSize + * @see #setMaxRows + * @see #setQueryTimeout + */ + private void applyStatementSettings(PreparedStatement stmt) throws SQLException { + if (fetchSize != VALUE_NOT_SET) { + stmt.setFetchSize(fetchSize); + stmt.setFetchDirection(ResultSet.FETCH_FORWARD); + } + if (maxRows != VALUE_NOT_SET) { + stmt.setMaxRows(maxRows); + } + if (queryTimeout != VALUE_NOT_SET) { + stmt.setQueryTimeout(queryTimeout); + } + } + + /** + * Return the exception translator for this instance. + * + * Creates a default SQLErrorCodeSQLExceptionTranslator for the specified + * DataSource if none is set. + */ + protected SQLExceptionTranslator getExceptionTranslator() { + if (exceptionTranslator == null) { + if (dataSource != null) { + exceptionTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource); + } + else { + exceptionTranslator = new SQLStateSQLExceptionTranslator(); + } + } + return exceptionTranslator; + } + + /** + * Throw a SQLWarningException if we're not ignoring warnings, else log the + * warnings (at debug level). + * + * @param warnings the warnings object from the current statement. May be + * null, in which case this method does nothing. + * @throws SQLException + * + * @see org.springframework.jdbc.SQLWarningException + */ + private void handleWarnings(PreparedStatement pstmt) throws SQLWarningException, SQLException { + if (ignoreWarnings) { + if (log.isDebugEnabled()) { + SQLWarning warningToLog = pstmt.getWarnings(); + while (warningToLog != null) { + log.debug("SQLWarning ignored: SQL state '" + warningToLog.getSQLState() + "', error code '" + + warningToLog.getErrorCode() + "', message [" + warningToLog.getMessage() + "]"); + warningToLog = warningToLog.getNextWarning(); + } + } + } + else { + SQLWarning warnings = pstmt.getWarnings(); + if (warnings != null) { + throw new SQLWarningException("Warning not ignored", warnings); + } + } + } + + /** + * Moves the cursor in the ResultSet to the position specified by the row + * parameter by traversing the ResultSet. + * @param row + */ + private void moveCursorToRow(int row) { + try { + int count = 0; + while (row != count && rs.next()) { + count++; + } + } + catch (SQLException se) { + throw getExceptionTranslator().translate("Attempted to move ResultSet to last committed row", procedureName, se); + } + } + + /** + * Gives the JDBC driver a hint as to the number of rows that should be + * fetched from the database when more rows are needed for this + * ResultSet object. If the fetch size specified is zero, the + * JDBC driver ignores the value. + * + * @param fetchSize the number of rows to fetch + * @see ResultSet#setFetchSize(int) + */ + public void setFetchSize(int fetchSize) { + this.fetchSize = fetchSize; + } + + /** + * Sets the limit for the maximum number of rows that any + * ResultSet object can contain to the given number. + * + * @param maxRows the new max rows limit; zero means there is no limit + * @see Statement#setMaxRows(int) + */ + public void setMaxRows(int maxRows) { + this.maxRows = maxRows; + } + + /** + * Sets the number of seconds the driver will wait for a + * Statement object to execute to the given number of seconds. + * If the limit is exceeded, an SQLException is thrown. + * + * @param queryTimeout seconds the new query timeout limit in seconds; zero + * means there is no limit + * @see Statement#setQueryTimeout(int) + */ + public void setQueryTimeout(int queryTimeout) { + this.queryTimeout = queryTimeout; + } + + /** + * Set whether SQLWarnings should be ignored (only logged) or exception + * should be thrown. + * + * @param ignoreWarnings if TRUE, warnings are ignored + */ + public void setIgnoreWarnings(boolean ignoreWarnings) { + this.ignoreWarnings = ignoreWarnings; + } + + /** + * Allow verification of cursor position after current row is processed by + * RowMapper or RowCallbackHandler. Default value is TRUE. + * + * @param verifyCursorPosition if true, cursor position is verified + */ + public void setVerifyCursorPosition(boolean verifyCursorPosition) { + this.verifyCursorPosition = verifyCursorPosition; + } + + /** + * Set the RowMapper to be used for all calls to read(). + * + * @param rowMapper + */ + public void setRowMapper(RowMapper rowMapper) { + this.rowMapper = rowMapper; + } + + /** + * Set the SQL statement to be used when creating the cursor. This statement + * should be a complete and valid SQL statement, as it will be run directly + * without any modification. + * + * @param sprocedureName + */ + public void setProcedureName(String sprocedureName) { + this.procedureName = sprocedureName; + } + + /** + * Set the PreparedStatementSetter to use if any parameter values that need + * to be set in the supplied query. + * + * @param preparedStatementSetter + */ + public void setPreparedStatementSetter(PreparedStatementSetter preparedStatementSetter) { + this.preparedStatementSetter = preparedStatementSetter; + } + + /** + * Indicate whether the JDBC driver supports setting the absolute row on a + * {@link ResultSet}. It is recommended that this is set to + * true for JDBC drivers that supports ResultSet.absolute() as + * it may improve performance, especially if a step fails while working with + * a large data set. + * + * @see ResultSet#absolute(int) + * + * @param driverSupportsAbsolute false by default + */ + public void setDriverSupportsAbsolute(boolean driverSupportsAbsolute) { + this.driverSupportsAbsolute = driverSupportsAbsolute; + } + + /** + * Indicate whether the connection used for the cursor should be used by all other processing + * thus sharing the same transaction. If this is set to false, which is the default, then the + * cursor will be opened using in its connection and will not participate in any transactions + * started for the rest of the step processing. If you set this flag to true then you must + * wrap the DataSource in a {@link ExtendedConnectionDataSourceProxy} to prevent the + * connection from being closed and released after each commit. + * + * When you set this option to true then the statement used to open the cursor + * will be created with both 'READ_ONLY' and 'HOLD_CUSORS_OVER_COMMIT' options. This allows + * holding the cursor open over transaction start and commits performed in the step processing. + * To use this feature you need a database that supports this and a JDBC driver supporting + * JDBC 3.0 or later. + * + * @param useSharedExtendedConnection false by default + */ + public void setUseSharedExtendedConnection(boolean useSharedExtendedConnection) { + this.useSharedExtendedConnection = useSharedExtendedConnection; + } + + /** + * Add one or more declared parameters. Used for configuring this operation when used in a + * bean factory. Each parameter will specify SQL type and (optionally) the parameter's name. + * + * @param parameters Array containing the declared SqlParameter objects + */ + public void setParameters(SqlParameter[] parameters) { + this.parameters = parameters; + } + + /** + * Set whether this stored procedure is a function. + */ + public void setFunction(boolean function) { + this.function = function; + } + + /** + * Set the parameter position of the REF CURSOR. Only used for Oracle and + * PostgreSQL that use REF CURSORs. For any other database this should be + * kept as 0 which is the default. + * + * @param refCursorPosition The parameter position of the REF CURSOR + */ + public void setRefCursorPosition(int refCursorPosition) { + this.refCursorPosition = refCursorPosition; + } + + /** + * Check the result set is in synch with the currentRow attribute. This is + * important to ensure that the user hasn't modified the current row. + */ + private void verifyCursorPosition(long expectedCurrentRow) throws SQLException { + if (verifyCursorPosition) { + if (expectedCurrentRow != this.rs.getRow()) { + throw new InvalidDataAccessResourceUsageException("Unexpected cursor position change."); + } + } + } + + /** + * Close the cursor and database connection. + */ + protected void doClose() throws Exception { + initialized = false; + JdbcUtils.closeResultSet(this.rs); + rs = null; + JdbcUtils.closeStatement(this.callableStatement); + if (useSharedExtendedConnection && dataSource instanceof ExtendedConnectionDataSourceProxy) { + ((ExtendedConnectionDataSourceProxy)dataSource).stopCloseSuppression(this.con); + if (!TransactionSynchronizationManager.isActualTransactionActive()) { + DataSourceUtils.releaseConnection(con, dataSource); + } + } + else { + JdbcUtils.closeConnection(this.con); + } + } + + /** + * Execute the {@link #setSql(String)} query. + */ + protected void doOpen() throws Exception { + Assert.state(!initialized, "Stream is already initialized. Close before re-opening."); + Assert.isNull(rs, "ResultSet still open! Close before re-opening."); + executeQuery(); + initialized = true; + + } + + /** + * Read next row and map it to item, verify cursor position if + * {@link #setVerifyCursorPosition(boolean)} is true. + */ + @SuppressWarnings("unchecked") + protected T doRead() throws Exception { + if (rs == null) { + throw new ReaderNotOpenException("Reader must be open before it can be read."); + } + + try { + if (!rs.next()) { + return null; + } + int currentRow = getCurrentItemCount(); + T item = (T) rowMapper.mapRow(rs, currentRow); + verifyCursorPosition(currentRow); + return item; + } + catch (SQLException se) { + throw getExceptionTranslator().translate("Attempt to process next row failed", procedureName, se); + } + } + + /** + * Use {@link ResultSet#absolute(int)} if possible, otherwise scroll by + * calling {@link ResultSet#next()}. + */ + protected void jumpToItem(int itemIndex) throws Exception { + if (driverSupportsAbsolute) { + try { + rs.absolute(itemIndex); + } + catch (SQLException e) { + // Driver does not support rs.absolute(int) revert to + // traversing ResultSet + log.warn("The JDBC driver does not appear to support ResultSet.absolute(). Consider" + + " reverting to the default behavior setting the driverSupportsAbsolute to false", e); + + moveCursorToRow(itemIndex); + } + } + else { + moveCursorToRow(itemIndex); + } + } + +}