package hirondelle.web4j.database;
import static hirondelle.web4j.util.Consts.FAILURE;
import static hirondelle.web4j.util.Consts.NEW_LINE;
import static hirondelle.web4j.util.Consts.SUCCESS;
import hirondelle.web4j.BuildImpl;
import hirondelle.web4j.model.DateTime;
import hirondelle.web4j.readconfig.InitParam;
import hirondelle.web4j.util.Util;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.StringTokenizer;
import java.util.TimeZone;
import java.util.logging.Logger;
import javax.servlet.ServletConfig;
/**
(UNPUBLISHED) Initialize the data layer and return related configuration information.
<P>Acts as the single source of configuration information needed by the
data layer. The intent is to centralize the dependencies of the data
layer on its environment. For example, this is the only class in this package
which knows about <tt>ServletConfig</tt>.
<P>This class carries simple static, immutable data, populated upon startup.
It is safe to use this class in a multi-threaded environment.
<P>In addition to reading in <tt>web.xml</tt> settings, this class :
<ul>
<li>initializes connection sources
<li>logs the name and version of both the database and the database driver
<li>logs the support for transaction isolation levels (see {@link TxIsolationLevel})
<li>reads in the <tt>*.sql</tt> file(s) (see package summary for more information)
</ul>
<P>See <tt>web.xml</tt> for more information on the items encapsulated by this class.
@un.published
*/
public final class DbConfig {
/**
Enumeration for indicator on whether to use hard-coded configuration.
<P>The <tt>YES</tt> option is intended only as a developer tool.
*/
public enum UseInformalConfig {YES, NO}
/**
Configure the data layer.
<P>Called upon startup. If fails, may be called repeatedly without any harm.
<P> If <tt>aIndicator</tt> is <tt>YES</tt>, then
hard-coded test settings will be used. (This is intended solely as a developer
convenience for testing database connectivity. If called outside of a web
container, then <tt>aConfig</tt> must be <tt>null</tt>.)
@return false only if problem occurred when talking to one or more databases. The caller must examine
the return value.
*/
public static boolean initDataLayer(ServletConfig aConfig, UseInformalConfig aIndicator) throws DAOException {
boolean result = SUCCESS;
if (UseInformalConfig.YES == aIndicator){
fLogger.fine("Using informal testing config.");
useInformalTestingConfig();
}
else {
fLogger.fine("Using web.xml config.");
useWebXmlConfig(aConfig);
}
//there are order dependencies here
ConnectionSource connSrc = BuildImpl.forConnectionSource();
for (String dbName : connSrc.getDatabaseNames()){
boolean success = logDatabaseAndDriverNames(dbName);
if( success ) {
fLogger.config("Success : Database named " + Util.quote(dbName) + " detected OK.");
queryTxIsolationLevelSupport(dbName);
}
else {
result = FAILURE;
fLogger.severe("Cannot connect to database named " + Util.quote(dbName) + ". Is database running?");
}
}
if( result ) {
fLogger.config("*** SUCCESS : ALL DATABASES DETECTED OK! *** ");
}
SqlStatement.readSqlFile();
checkDbNamesInSettings();
return result;
}
/** Indicates if the application is running in an informal testing mode. */
static boolean isTestingMode(){
return fIS_TESTING;
}
static boolean isSqlPrecompilationAttempted(String aDbName){
return Util.parseBoolean(fIS_SQL_PRECOMPILATION_ATTEMPTED.getValue(aDbName));
}
/**
Return the value(s) of <tt>SQLException.getErrorCode()</tt> emitted
by the database when a duplicate key error occurs.
<P>This is used to identify a {@link DuplicateException}.
This method returns multiple values since, in general, an application may wish to map
more than one kind of database error code to {@link DuplicateException}.
In many cases, the returned list will have only a single value.
*/
static List<Integer> getErrorCodesForDuplicateKey(String aDbName){
return parseListOfErrorCodes(fDUPE_KEY_ERROR_CODES.getValue(aDbName));
}
/**
Return the value(s) of <tt>SQLException.getErrorCode()</tt> emitted
by the database when an error related to a foreign key constraint occurs.
<P>This is used to identify a {@link ForeignKeyException}.
This method returns multiple values since, in general, an application may wish to map
more than one kind of database error code to {@link ForeignKeyException}.
In many cases, the returned list will have only a single value.
*/
static List<Integer> getErrorCodesForForeignKey(String aDbName){
return parseListOfErrorCodes(fFOREIGN_KEY_ERROR_CODES.getValue(aDbName));
}
/** Return the maximum number of rows to be returned by any <tt>SELECT</tt> statement. */
static Integer getMaxRows(String aDbName){
return new Integer(fMAX_ROWS.getValue(aDbName));
}
/**
The number of rows to be recommended to the database driver, when a fetch of more
records is required.
*/
static Integer getFetchSize(String aDbName){
return new Integer(fFETCH_SIZE.getValue(aDbName));
}
/**
Indicates if the database and driver support the autogeneration of keys during
<tt>INSERT</tt> operations. See the method
<tt>Connection.prepareStatement(SqlText, Statement.RETURN_GENERATED_KEYS)</tt>.
*/
static Boolean hasAutogeneratedKeys(String aDbName){
return Util.parseBoolean(fHAS_AUTOGEN_KEYS.getValue(aDbName));
}
/** The default {@link TxIsolationLevel} to be used by {@link SqlFetcher}. */
static TxIsolationLevel getSqlFetcherTxIsolationLevel(String aDbName){
return TxIsolationLevel.valueOf(fSQL_FETCHER_TX_ISOLATION_LEVEL.getValue(aDbName));
}
/** The default {@link TxIsolationLevel} to be used by {@link SqlEditor}. */
static TxIsolationLevel getSqlEditorTxIsolationLevel(String aDbName){
return TxIsolationLevel.valueOf(fSQL_EDITOR_TX_ISOLATION_LEVEL.getValue(aDbName));
}
/** Return <tt>true</tt> only if the database driver should use a non-default {@link TimeZone} for dates. */
static boolean hasTimeZoneHint(){
return fTIME_ZONE_HINT != null;
}
/** Return a {@link Calendar} having a non-default {@link TimeZone}. */
static Calendar getTimeZoneHint(){
Calendar result = null;
if( hasTimeZoneHint() ) {
result = Calendar.getInstance(fTIME_ZONE_HINT);
}
return result;
}
/** Return a {@link DateTime} format for formatting year-month-day. */
static String getDateFormat(String aDbName){
String value = fDATE_TIME_FORMATS.getValue(aDbName);
return extractDateTimeFormat(value, 0);
}
/** Return a {@link DateTime} format for formatting hour-minute-second. */
static String getTimeFormat(String aDbName){
String value = fDATE_TIME_FORMATS.getValue(aDbName);
return extractDateTimeFormat(value, 1);
}
/** Return a {@link DateTime} format for formatting year-month-day-hour-minute-second. */
static String getDateTimeFormat(String aDbName){
String value = fDATE_TIME_FORMATS.getValue(aDbName);
return extractDateTimeFormat(value, 2);
}
// PRIVATE
/*
The values for the various config settings.
The Time Zone hint is not treated the same as the others. Only a single value is possible, it's not
per-database. The reason is that doing so has a lot of ripple effects: it would force a change to
ConvertColumn and all of its callers. So, it seems to create more problems than it solves.
*/
private static boolean fIS_TESTING;
private static DbConfigParser fMAX_ROWS;
private static DbConfigParser fFETCH_SIZE;
private static DbConfigParser fHAS_AUTOGEN_KEYS;
private static DbConfigParser fDUPE_KEY_ERROR_CODES;
private static DbConfigParser fFOREIGN_KEY_ERROR_CODES;
private static DbConfigParser fSQL_FETCHER_TX_ISOLATION_LEVEL;
private static DbConfigParser fSQL_EDITOR_TX_ISOLATION_LEVEL;
private static DbConfigParser fIS_SQL_PRECOMPILATION_ATTEMPTED;
private static DbConfigParser fDATE_TIME_FORMATS;
private static TimeZone fTIME_ZONE_HINT;
/*
The param names and default values.
*/
private static InitParam fMaxRows = new InitParam("MaxRows", "300");
private static InitParam fFetchSize = new InitParam("FetchSize", "25");
private static InitParam fHasAutoGenKeys = new InitParam("HasAutoGeneratedKeys", "false");
private static InitParam fDupeErrorCodes = new InitParam("ErrorCodeForDuplicateKey", "1");
private static InitParam fForeignKeyErrorCodes = new InitParam("ErrorCodeForForeignKey", "2291");
private static InitParam fSqlFetcherTxIsolationLevel = new InitParam("SqlFetcherDefaultTxIsolationLevel", "DATABASE_DEFAULT");
private static InitParam fSqlEditorTxIsolationLevel = new InitParam("SqlEditorDefaultTxIsolationLevel", "DATABASE_DEFAULT");
private static InitParam fIsSQLPrecompilationAttempted = new InitParam("IsSQLPrecompilationAttempted", "true");
private static InitParam fDateTimeFormats = new InitParam("DateTimeFormatForPassingParamsToDb", "YYYY-MM-DD^hh:mm:ss^YYYY-MM-DD hh:mm:ss");
private static InitParam fTimeZoneHint = new InitParam("TimeZoneHint", "NONE");
private static final Logger fLogger = Util.getLogger(DbConfig.class);
private DbConfig(){
//empty - prevent construction by the caller
}
/** Extract settings from web.xml. */
private static void useWebXmlConfig(ServletConfig aConfig){
fIS_TESTING = false;
fMAX_ROWS = new DbConfigParser(fMaxRows.fetch(aConfig).getValue());
fFETCH_SIZE = new DbConfigParser(fFetchSize.fetch(aConfig).getValue());
fHAS_AUTOGEN_KEYS = new DbConfigParser(fHasAutoGenKeys.fetch(aConfig).getValue());
fDUPE_KEY_ERROR_CODES = new DbConfigParser(fDupeErrorCodes.fetch(aConfig).getValue());
fFOREIGN_KEY_ERROR_CODES = new DbConfigParser(fForeignKeyErrorCodes.fetch(aConfig).getValue());
fSQL_FETCHER_TX_ISOLATION_LEVEL = new DbConfigParser(fSqlFetcherTxIsolationLevel.fetch(aConfig).getValue());
fSQL_EDITOR_TX_ISOLATION_LEVEL = new DbConfigParser(fSqlEditorTxIsolationLevel.fetch(aConfig).getValue());
fIS_SQL_PRECOMPILATION_ATTEMPTED = new DbConfigParser(fIsSQLPrecompilationAttempted.fetch(aConfig).getValue());
fDATE_TIME_FORMATS = new DbConfigParser(fDateTimeFormats.fetch(aConfig).getValue());
String timeZoneText = fTimeZoneHint.fetch(aConfig).getValue();
if( Util.textHasContent(timeZoneText) && ! timeZoneText.equals(fTimeZoneHint.getDefaultValue()) ) {
fTIME_ZONE_HINT = Util.buildTimeZone(timeZoneText);
}
}
/** Extract a date-time format part from the raw setting for that database. */
private static String extractDateTimeFormat(String aDbSetting, int aPart){
String[] formats = aDbSetting.split("\\^");
if(formats.length != 3) {
throw new IllegalArgumentException(
fDateTimeFormats.getName() + " setting in web.xml does not have a valid value: " + Util.quote(aDbSetting) +
". Does not have 3 entries, separated by a '^' character."
);
}
for(String format : formats){
if( !DateTime.isValidFormatString(format) ){
throw new IllegalArgumentException(fDateTimeFormats.getName() + " setting in web.xml: invalid format String for a hirondelle.web4j.model.DateTime " + Util.quote(format));
}
}
return formats[aPart];
}
/** Use informal, hard-coded settings. */
private static void useInformalTestingConfig(){
fIS_TESTING = true;
fMAX_ROWS = new DbConfigParser("1000" );
fFETCH_SIZE = new DbConfigParser( "100" );
fHAS_AUTOGEN_KEYS = new DbConfigParser("false");
fDUPE_KEY_ERROR_CODES = new DbConfigParser("1");
fFOREIGN_KEY_ERROR_CODES = new DbConfigParser("2291");
fSQL_FETCHER_TX_ISOLATION_LEVEL = new DbConfigParser(TxIsolationLevel.DATABASE_DEFAULT.toString());
fSQL_EDITOR_TX_ISOLATION_LEVEL = new DbConfigParser(TxIsolationLevel.DATABASE_DEFAULT.toString());
fIS_SQL_PRECOMPILATION_ATTEMPTED = new DbConfigParser("true");
}
/**
Log name and version info, for both the database and driver.
<P>This is the first place where the database is exercised. Returns false only if an error occurs.
This indicates to the caller that db init tasks have not completed normally.
The caller must check the return value.
*/
private static boolean logDatabaseAndDriverNames(String aDbName) throws DAOException {
boolean result = SUCCESS;
Connection connection = null;
try {
connection = BuildImpl.forConnectionSource().getConnection(aDbName);
DatabaseMetaData db = connection.getMetaData();
String dbName = db.getDatabaseProductName() + "/" + db.getDatabaseProductVersion();
String dbDriverName = db.getDriverName() + "/" + db.getDriverVersion();
String message = NEW_LINE;
message = message + " Database Id passed to ConnectionSource: " + aDbName + NEW_LINE;
message = message + " Database name: " + dbName + NEW_LINE;
message = message + " Database driver name: " + dbDriverName + NEW_LINE;
message = message + " Database URL: " + db.getURL() + NEW_LINE;
boolean supportsScrollable = db.supportsResultSetConcurrency(ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY);
message = message + " Supports scrollable ResultSets (TYPE_SCROLL_INSENSITIVE, CONCUR_READ_ONLY): " + supportsScrollable;
if ( ! supportsScrollable ){
fLogger.severe("Database/driver " + aDbName + " does not support scrollable ResultSets. Parsing of ResultSets by ModelFromRow into 'Parent+List<Children>' kinds of objects will not work, since it depends on scrollable ResultSets. Parsing into 'ordinary' Model Objects will still work, however, since they do not depend on scrollable ResultSets.)");
}
fLogger.config(message);
}
catch (Throwable ex) {
result = FAILURE;
}
finally {
DbUtil.close(connection);
}
return result;
}
/** For each {@link TxIsolationLevel}, log if it is supported (true/false). */
private static void queryTxIsolationLevelSupport(String aDbName) throws DAOException {
Connection connection = BuildImpl.forConnectionSource().getConnection(aDbName);
try {
int defaultTxLevel = connection.getMetaData().getDefaultTransactionIsolation();
DatabaseMetaData db = connection.getMetaData();
for(TxIsolationLevel level: TxIsolationLevel.values()){
fLogger.config(getTxIsolationLevelMessage(db, level, defaultTxLevel) );
}
}
catch (SQLException ex) {
throw new DAOException("Cannot query database for transaction level support", ex);
}
finally {
DbUtil.close(connection);
}
}
/** Create a message describing support for aTxIsolationLevel. */
private static String getTxIsolationLevelMessage (DatabaseMetaData aDb, TxIsolationLevel aLevel, int aDefaultTxLevel) {
StringBuilder result = new StringBuilder();
result.append("Supports Tx Isolation Level " + aLevel.toString() + ": ");
try {
boolean supportsLevel = aDb.supportsTransactionIsolationLevel(aLevel.getInt());
result.append(supportsLevel);
}
catch(SQLException ex){
fLogger.warning("Database driver doesn't support calla to supportsTransactionalIsolationLevel(int).");
result.append( "Unknown");
}
if ( aLevel.getInt() == aDefaultTxLevel ) {
result.append(" (default)");
}
return result.toString();
}
/**
Parse a comma-delimited string of integer error codes into a {@code List<Integer>}.
*/
private static List<Integer> parseListOfErrorCodes(String aErrorCodes){
List<Integer> result = new ArrayList<Integer>();
String DELIMITER = ",";
StringTokenizer parser = new StringTokenizer(aErrorCodes, DELIMITER);
while ( parser.hasMoreTokens() ) {
String errorCode = parser.nextToken();
result.add(new Integer(errorCode.trim()));
}
return result;
}
/** Confirm that database settings in web.xml are known to {@link ConnectionSource}. */
private static void checkDbNamesInSettings(){
Set<String> namesInSettings = new LinkedHashSet<String>();
namesInSettings.addAll(fHAS_AUTOGEN_KEYS.getDbNames());
namesInSettings.addAll(fSQL_FETCHER_TX_ISOLATION_LEVEL.getDbNames());
namesInSettings.addAll(fSQL_EDITOR_TX_ISOLATION_LEVEL.getDbNames());
namesInSettings.addAll(fDUPE_KEY_ERROR_CODES.getDbNames());
namesInSettings.addAll(fFOREIGN_KEY_ERROR_CODES.getDbNames());
namesInSettings.addAll(fMAX_ROWS.getDbNames());
namesInSettings.addAll(fFETCH_SIZE.getDbNames());
namesInSettings.addAll(fIS_SQL_PRECOMPILATION_ATTEMPTED.getDbNames());
namesInSettings.addAll(fDATE_TIME_FORMATS.getDbNames());
Set<String> unknownNames = new LinkedHashSet<String>();
ConnectionSource connSrc = BuildImpl.forConnectionSource();
Set<String> validNames = connSrc.getDatabaseNames();
for(String name: namesInSettings){
if(Util.textHasContent(name) && ! validNames.contains(name)){
unknownNames.add(name);
}
}
if(! unknownNames.isEmpty() ) {
throw new IllegalArgumentException("Web.xml contains settings that refer to databases that are not known to your implementation of ConnectionSource.getDatabaseNames(). Please check spelling and case for : " + Util.logOnePerLine(unknownNames));
}
fLogger.fine("Database names in web.xml settings are consistent with ConnectionSource.getDatabaseNames(): " + Util.logOnePerLine(namesInSettings));
}
}