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}