001package com.streamconverter.command.rule;
002
003import com.streamconverter.StreamProcessingException;
004import java.sql.Connection;
005import java.sql.DriverManager;
006import java.sql.PreparedStatement;
007import java.sql.ResultSet;
008import java.sql.ResultSetMetaData;
009import java.sql.SQLException;
010import java.util.Objects;
011import java.util.regex.Pattern;
012import org.slf4j.Logger;
013import org.slf4j.LoggerFactory;
014
015/**
016 * データベースからデータを取得するルール
017 *
018 * <p>このクラスは、データベースからデータを取得するためのルールを定義します。 具体的なデータベース接続やクエリ実行のロジックは、このクラスで実装されます。
019 *
020 * <p>使用例:
021 *
022 * <pre>{@code
023 * // 基本的な使用例
024 * DatabaseFetchRule rule = new DatabaseFetchRule(
025 *     "jdbc:h2:mem:testdb",
026 *     "SELECT name FROM users WHERE id = ?"
027 * );
028 * String result = rule.apply("123"); // ユーザーID 123 の名前を取得
029 *
030 * // NavigateCommandと組み合わせた使用例
031 * JsonNavigateCommand command = new JsonNavigateCommand("$.userId", rule);
032 * command.execute(inputStream, outputStream); // JSON中のuserIdでDBを検索して置換
033 * }</pre>
034 *
035 * <p>セキュリティ機能:
036 *
037 * <ul>
038 *   <li>SELECTクエリのみ許可(INSERT/UPDATE/DELETE等は禁止)
039 *   <li>SQLインジェクション攻撃の検出と防止
040 *   <li>許可されたデータベーススキーマのみ接続可能
041 *   <li>入力パラメータの自動サニタイズ
042 * </ul>
043 */
044public class DatabaseFetchRule implements IRule {
045  private static final Logger logger = LoggerFactory.getLogger(DatabaseFetchRule.class);
046
047  /** 許可されるデータベースURLスキーマ(テスト用のmockも含む) */
048  private static final Pattern ALLOWED_DB_SCHEME_PATTERN =
049      Pattern.compile(
050          "^jdbc:(h2|hsqldb|sqlite|postgresql|mysql|mock):.*", Pattern.CASE_INSENSITIVE);
051
052  private final String databaseUrl;
053  private final String query;
054
055  /**
056   * コンストラクタ
057   *
058   * <p>データベースのURLとクエリを指定して、DatabaseFetchRuleのインスタンスを初期化します。
059   * クエリは一意な結果を返すように設計されるべきです(例:DISTINCT、LIMIT句の使用など)。 セキュリティのため、URLとクエリの検証を実行します。
060   *
061   * @param databaseUrl データベースのURL(許可されたスキーマのみ)
062   * @param query データベースに対するクエリ(SELECTクエリのみ許可)
063   * @throws IllegalArgumentException 無効なパラメータが指定された場合
064   * @throws SecurityException セキュリティ違反が検出された場合
065   */
066  public DatabaseFetchRule(String databaseUrl, String query) {
067    Objects.requireNonNull(databaseUrl, "Database URL cannot be null");
068    Objects.requireNonNull(query, "Query cannot be null");
069
070    // データベースURLの検証
071    this.databaseUrl = validateDatabaseUrl(databaseUrl.trim());
072
073    // クエリの検証
074    this.query = validateQuery(query.trim());
075
076    logger.info(
077        "DatabaseFetchRule initialized with secure validation - URL: {}, Query length: {}",
078        this.databaseUrl,
079        this.query.length());
080  }
081
082  /**
083   * データベースURLを検証します(セキュリティ対策)
084   *
085   * @param url 検証対象のURL
086   * @return 検証済みのURL
087   * @throws SecurityException 不正なURLが検出された場合
088   */
089  private String validateDatabaseUrl(String url) {
090    if (url.isEmpty()) {
091      throw new IllegalArgumentException("Database URL cannot be empty");
092    }
093
094    // 許可されたスキーマのチェック
095    if (!ALLOWED_DB_SCHEME_PATTERN.matcher(url).matches()) {
096      throw new SecurityException(
097          "Database URL uses unsupported or potentially dangerous scheme: " + url);
098    }
099
100    // 危険な文字列の検出
101    if (url.contains("..") || url.contains("file:") || url.contains("javascript:")) {
102      throw new SecurityException("Database URL contains potentially dangerous patterns: " + url);
103    }
104
105    logger.debug("Database URL validation passed: {}", url);
106    return url;
107  }
108
109  /**
110   * クエリを検証します(SQLインジェクション対策)
111   *
112   * @param queryString 検証対象のクエリ
113   * @return 検証済みのクエリ
114   * @throws SecurityException SQLインジェクションが検出された場合
115   */
116  private String validateQuery(String queryString) {
117    return SqlQueryUtils.validateQuery(queryString, logger);
118  }
119
120  private String sanitizeInput(String input) {
121    return SqlQueryUtils.sanitizeInput(input, logger);
122  }
123
124  /**
125   * ルールの適用を実行します。
126   *
127   * <p>このメソッドは、ストリーム変換の際にルールを適用するために使用されます。 データベースからデータを取得するロジックを実装します。 結果セットの先頭行・先頭列の値を返却します。
128   * 結果が1行1列でない場合は警告をログに出力します。
129   *
130   * @param input 変換対象の文字列(クエリパラメータとして使用)
131   * @return String output クエリ結果の先頭値、または空文字列(結果がない場合)
132   */
133  @Override
134  public String apply(String input) {
135    try (Connection connection = DriverManager.getConnection(databaseUrl);
136        PreparedStatement statement = connection.prepareStatement(query)) {
137
138      // データベース接続
139      logger.debug("データベースに接続: {}", databaseUrl);
140
141      // 入力文字列をパラメータとして設定(クエリに「?」プレースホルダーがある場合)
142      if (query.contains("?") && input != null && !input.isEmpty()) {
143        // 入力値のサニタイズとセキュリティチェック
144        String sanitizedInput = sanitizeInput(input);
145        if (sanitizedInput.isEmpty()) {
146          logger.warn(
147              "Input parameter was sanitized to empty string. Rejecting input for security reasons. Original input: {}",
148              input);
149          return "";
150        }
151
152        statement.setString(1, sanitizedInput);
153        logger.debug("Parameter set for prepared statement: length={}", sanitizedInput.length());
154      }
155
156      // クエリ実行
157      logger.debug("クエリを実行: {}", query);
158      try (ResultSet resultSet = statement.executeQuery()) {
159        // 結果の検証と処理
160        ResultSetMetaData metaData = resultSet.getMetaData();
161        int columnCount = metaData.getColumnCount();
162
163        // 結果がない場合
164        if (!resultSet.next()) {
165          logger.warn("クエリ結果が空です。");
166          return "";
167        }
168
169        // 列数の検証
170        if (columnCount != 1) {
171          logger.warn("クエリ結果が一列ではありません。列数: {}。先頭列の値を使用します。", columnCount);
172        }
173
174        // 先頭行の先頭列の値を取得
175        String value = resultSet.getString(1);
176
177        // 追加の行があるかチェック
178        boolean hasMoreRows = resultSet.next();
179        if (hasMoreRows) {
180          logger.warn("クエリ結果が複数行あります。先頭行の値を使用します。");
181        }
182
183        // nullチェック
184        if (value == null) {
185          logger.info("クエリ結果の先頭値がNULLです。");
186          return ""; // NULLの場合は空文字列を返す
187        }
188
189        // 結果が理想的(1行1列)かどうかをログに記録
190        if (columnCount == 1 && !hasMoreRows) {
191          logger.info("データベースから単一値を取得しました: {}", value);
192        } else {
193          logger.info("データベースから先頭値を取得しました: {}", value);
194        }
195
196        return value;
197      }
198    } catch (SQLException e) {
199      logger.error("データベース操作中にエラーが発生しました: {}", e.getMessage(), e);
200      Throwable[] suppressed = e.getSuppressed();
201      if (suppressed != null) {
202        for (Throwable s : suppressed) {
203          logger.error("クローズ中に追加のエラーが発生しました: {}", s.getMessage(), s);
204        }
205      }
206      throw new StreamProcessingException(
207          "データベースフェッチに失敗しました: " + e.getMessage(), e);
208    }
209  }
210}