001package com.streamconverter.command.rule; 002 003import java.sql.Connection; 004import java.sql.PreparedStatement; 005import java.sql.ResultSet; 006import java.sql.ResultSetMetaData; 007import java.sql.SQLException; 008import java.util.Objects; 009import java.util.regex.Pattern; 010import org.slf4j.Logger; 011import org.slf4j.LoggerFactory; 012 013/** 014 * HikariCP対応のデータベースフェッチルール 015 * 016 * <p>DatabaseFetchRuleの高性能版です。HikariCPを使用して接続の再利用により パフォーマンスを大幅に向上させます。特に大量のデータ処理や高頻度のデータベースアクセスが 017 * 必要な場合に効果的です。 018 * 019 * <p>使用例: 020 * 021 * <pre>{@code 022 * // HikariCP接続プールを作成 023 * HikariConnectionPoolConfig pool = new HikariConnectionPoolConfig("jdbc:h2:mem:testdb", 10, Duration.ofSeconds(30)); 024 * 025 * // プール対応ルールを作成 026 * PooledDatabaseFetchRule rule = new PooledDatabaseFetchRule( 027 * pool, 028 * "SELECT name FROM users WHERE id = ?" 029 * ); 030 * 031 * // 大量処理でも高速 032 * for (int i = 0; i < 10000; i++) { 033 * String result = rule.apply(String.valueOf(i)); 034 * } 035 * 036 * // 使用後はプールをシャットダウン 037 * pool.close(); 038 * }</pre> 039 * 040 * <p>DatabaseFetchRuleとの違い: 041 * 042 * <ul> 043 * <li>業界標準HikariCP使用によりパフォーマンス大幅向上 044 * <li>接続リーク検出と自動回復 045 * <li>複数スレッドからの同時アクセス対応 046 * <li>詳細なプールメトリクス 047 * </ul> 048 */ 049public class PooledDatabaseFetchRule implements IRule { 050 private static final Logger logger = LoggerFactory.getLogger(PooledDatabaseFetchRule.class); 051 052 /** SQLインジェクション攻撃を検出するパターン(SELECT以外の危険なSQL文) */ 053 private static final Pattern SQL_INJECTION_PATTERN = 054 Pattern.compile( 055 "(?i).*(union|insert|update|delete|drop|create|alter|exec|execute|sp_|xp_).*", 056 Pattern.CASE_INSENSITIVE); 057 058 private final HikariConnectionPoolConfig connectionPool; 059 private final String query; 060 061 /** 062 * コンストラクタ 063 * 064 * @param connectionPool HikariCP接続プール 065 * @param query データベースクエリ(SELECTクエリのみ許可) 066 * @throws IllegalArgumentException 無効なパラメータが指定された場合 067 * @throws SecurityException セキュリティ違反が検出された場合 068 */ 069 public PooledDatabaseFetchRule(HikariConnectionPoolConfig connectionPool, String query) { 070 Objects.requireNonNull(connectionPool, "Connection pool cannot be null"); 071 Objects.requireNonNull(query, "Query cannot be null"); 072 073 this.connectionPool = connectionPool; 074 this.query = validateQuery(query.trim()); 075 076 logger.info( 077 "PooledDatabaseFetchRule initialized - Query length: {}, Pool: {}", 078 this.query.length(), 079 connectionPool.getDetailedStats()); 080 } 081 082 /** 083 * クエリを検証します(SQLインジェクション対策) 084 * 085 * @param queryString 検証対象のクエリ 086 * @return 検証済みのクエリ 087 * @throws SecurityException SQLインジェクションが検出された場合 088 */ 089 private String validateQuery(String queryString) { 090 if (queryString.isEmpty()) { 091 throw new IllegalArgumentException("Query cannot be empty"); 092 } 093 094 // SELECTクエリのみ許可 095 if (!queryString.trim().toLowerCase().startsWith("select")) { 096 throw new SecurityException("Only SELECT queries are allowed: " + queryString); 097 } 098 099 // SQLインジェクション攻撃の検出 100 if (SQL_INJECTION_PATTERN.matcher(queryString).matches()) { 101 throw new SecurityException( 102 "Query contains potentially dangerous SQL commands: " + queryString); 103 } 104 105 // セミコロンによる複数文の実行を防止 106 if (queryString.contains(";") && !queryString.trim().endsWith(";")) { 107 throw new SecurityException("Multiple SQL statements are not allowed: " + queryString); 108 } 109 110 logger.debug("Query validation passed, length: {}", queryString.length()); 111 return queryString; 112 } 113 114 /** 115 * 入力パラメータをサニタイズします 116 * 117 * @param input サニタイズ対象の入力 118 * @return サニタイズされた入力 119 */ 120 private String sanitizeInput(String input) { 121 if (input == null) { 122 return null; 123 } 124 125 // 危険な文字の除去/エスケープ 126 String sanitized = 127 input 128 .replace("'", "''") // シングルクォートのエスケープ 129 .replace("--", "") // SQLコメントの除去 130 .replace("/*", "") // ブロックコメント開始の除去 131 .replace("*/", ""); // ブロックコメント終了の除去 132 133 // 極端に長い入力の制限 134 if (sanitized.length() > 1000) { 135 logger.warn("Input parameter is extremely long, truncating: length={}", sanitized.length()); 136 sanitized = sanitized.substring(0, 1000); 137 } 138 139 return sanitized; 140 } 141 142 /** 143 * ルールの適用を実行します。 144 * 145 * <p>コネクションプールから接続を取得してクエリを実行し、結果の先頭行・先頭列の値を返します。 接続はプールに自動的に返却されるため、高いパフォーマンスを実現します。 146 * 147 * @param input 変換対象の文字列(クエリパラメータとして使用) 148 * @return クエリ結果の先頭値、または空文字列(結果がない場合) 149 */ 150 @Override 151 public String apply(String input) { 152 Connection connection = null; 153 PreparedStatement statement = null; 154 ResultSet resultSet = null; 155 156 try { 157 // プールから接続を取得 158 logger.debug("Getting connection from pool: {}", connectionPool.getPoolStats()); 159 connection = connectionPool.getConnection(); 160 161 // クエリの準備 162 statement = connection.prepareStatement(query); 163 164 // 入力文字列をパラメータとして設定(クエリに「?」プレースホルダーがある場合) 165 if (query.contains("?") && input != null && !input.isEmpty()) { 166 String sanitizedInput = sanitizeInput(input); 167 if (sanitizedInput == null || sanitizedInput.isEmpty()) { 168 logger.warn( 169 "Input parameter was sanitized to empty string. Rejecting input for security reasons. Original input: {}", 170 input); 171 return ""; 172 } 173 174 statement.setString(1, sanitizedInput); 175 logger.debug("Parameter set for prepared statement: length={}", sanitizedInput.length()); 176 } 177 178 // クエリ実行 179 logger.debug("Executing query with pooled connection: {}", query); 180 resultSet = statement.executeQuery(); 181 182 // 結果の検証と処理 183 ResultSetMetaData metaData = resultSet.getMetaData(); 184 int columnCount = metaData.getColumnCount(); 185 186 // 結果がない場合 187 if (!resultSet.next()) { 188 logger.warn("クエリ結果が空です。"); 189 return ""; 190 } 191 192 // 列数の検証 193 if (columnCount != 1) { 194 logger.warn("クエリ結果が一列ではありません。列数: {}。先頭列の値を使用します。", columnCount); 195 } 196 197 // 先頭行の先頭列の値を取得 198 String value = resultSet.getString(1); 199 200 // 追加の行があるかチェック 201 boolean hasMoreRows = resultSet.next(); 202 if (hasMoreRows) { 203 logger.warn("クエリ結果が複数行あります。先頭行の値を使用します。"); 204 } 205 206 // nullチェック 207 if (value == null) { 208 logger.info("クエリ結果の先頭値がNULLです。"); 209 return ""; 210 } 211 212 // 結果が理想的(1行1列)かどうかをログに記録 213 if (columnCount == 1 && !hasMoreRows) { 214 logger.debug("データベースから単一値を取得しました(プール使用): {}", value); 215 } else { 216 logger.debug("データベースから先頭値を取得しました(プール使用): {}", value); 217 } 218 219 return value; 220 221 } catch (SQLException e) { 222 logger.error("プール接続でのデータベース操作中にエラーが発生しました: {}", e.getMessage(), e); 223 return "ERROR: " + e.getMessage(); 224 } finally { 225 // リソースのクローズ(接続は自動的にプールに返却される) 226 closeResources(resultSet, statement, connection); 227 } 228 } 229 230 /** 231 * データベースリソースを安全にクローズします。 接続はプールに自動的に返却されます。 232 * 233 * @param resultSet 結果セット 234 * @param statement プリペアドステートメント 235 * @param connection データベース接続(プールに返却される) 236 */ 237 private void closeResources( 238 ResultSet resultSet, PreparedStatement statement, Connection connection) { 239 if (resultSet != null) { 240 try { 241 resultSet.close(); 242 } catch (SQLException e) { 243 logger.warn("ResultSetのクローズ中にエラーが発生しました: {}", e.getMessage()); 244 } 245 } 246 247 if (statement != null) { 248 try { 249 statement.close(); 250 } catch (SQLException e) { 251 logger.warn("PreparedStatementのクローズ中にエラーが発生しました: {}", e.getMessage()); 252 } 253 } 254 255 if (connection != null) { 256 try { 257 connection.close(); // プールに返却される 258 } catch (SQLException e) { 259 logger.warn("Connection(プール返却)中にエラーが発生しました: {}", e.getMessage()); 260 } 261 } 262 } 263 264 /** 265 * プールの統計情報を取得 266 * 267 * @return プールの統計情報 268 */ 269 public String getPoolStats() { 270 return connectionPool.getPoolStats(); 271 } 272}