001package com.streamconverter.command.impl.csv;
002
003import com.opencsv.CSVReader;
004import com.opencsv.exceptions.CsvValidationException;
005import com.streamconverter.StreamProcessingException;
006import com.streamconverter.command.ConsumerCommand;
007import java.io.IOException;
008import java.io.InputStream;
009import java.io.InputStreamReader;
010import java.nio.charset.StandardCharsets;
011import java.util.ArrayList;
012import java.util.HashSet;
013import java.util.List;
014import java.util.Objects;
015import java.util.Set;
016import org.slf4j.Logger;
017import org.slf4j.LoggerFactory;
018
019/**
020 * CSVデータのバリデーションを行うコマンドクラス
021 *
022 * <p>CSVデータの構造とデータ妥当性を検証します。以下の項目をチェックします:
023 *
024 * <ul>
025 *   <li>必須カラムの存在
026 *   <li>ヘッダー行の妥当性
027 *   <li>データ行の整合性
028 *   <li>重複ヘッダーの検出
029 * </ul>
030 *
031 * <p>使用例:
032 *
033 * <pre>
034 * String[] requiredColumns = {"id", "name", "email"};
035 * CsvValidateCommand validator = CsvValidateCommand.create(requiredColumns);
036 * validator.consume(csvInputStream);
037 * </pre>
038 */
039public class CsvValidateCommand extends ConsumerCommand {
040  private static final Logger LOGGER = LoggerFactory.getLogger(CsvValidateCommand.class);
041
042  private final Set<String> requiredColumns;
043  private final boolean hasHeader;
044  private final int maxErrorsToReport;
045
046  /**
047   * 必須カラムを指定するコンストラクタ(ヘッダー行ありと仮定)
048   *
049   * @param requiredColumns 必須カラム名の配列
050   * @throws IllegalArgumentException 必須カラムがnullの場合
051   */
052  private CsvValidateCommand(final String... requiredColumns) {
053    this(true, 10, requiredColumns);
054  }
055
056  /**
057   * 詳細設定を指定するコンストラクタ
058   *
059   * @param hasHeader ヘッダー行の存在フラグ
060   * @param maxErrorsToReport 報告する最大エラー数
061   * @param requiredColumns 必須カラム名の配列
062   */
063  private CsvValidateCommand(
064      final boolean hasHeader, final int maxErrorsToReport, final String... requiredColumns) {
065    super();
066    this.hasHeader = hasHeader;
067    this.maxErrorsToReport = Math.max(1, maxErrorsToReport);
068
069    this.requiredColumns = new HashSet<>();
070    for (final String column : requiredColumns) {
071      if (column != null && !column.isBlank()) {
072        this.requiredColumns.add(column.trim());
073      }
074    }
075
076    if (requiredColumns.length == 0) {
077      LOGGER.info("No required columns specified, column validation will be skipped");
078    } else {
079      LOGGER.info("Required columns: {}", this.requiredColumns);
080    }
081  }
082
083  /**
084   * 必須カラムを指定してCsvValidateCommandを作成(ヘッダー行ありと仮定)
085   *
086   * @param requiredColumns 必須カラム名の配列
087   * @return a CsvValidateCommand instance
088   * @throws IllegalArgumentException 必須カラムがnullの場合
089   */
090  public static CsvValidateCommand create(final String... requiredColumns) {
091    if (requiredColumns == null) {
092      throw new IllegalArgumentException("Required columns cannot be null");
093    }
094    return new CsvValidateCommand(requiredColumns);
095  }
096
097  /**
098   * 詳細設定を指定してCsvValidateCommandを作成
099   *
100   * @param hasHeader ヘッダー行の存在フラグ
101   * @param maxErrorsToReport 報告する最大エラー数
102   * @param requiredColumns 必須カラム名の配列
103   * @return a CsvValidateCommand instance
104   * @throws IllegalArgumentException requiredColumnsがnullの場合
105   */
106  public static CsvValidateCommand create(
107      final boolean hasHeader, final int maxErrorsToReport, final String... requiredColumns) {
108    if (requiredColumns == null) {
109      throw new IllegalArgumentException("Required columns cannot be null");
110    }
111    return new CsvValidateCommand(hasHeader, maxErrorsToReport, requiredColumns);
112  }
113
114  /**
115   * CSVバリデーションを実行します
116   *
117   * @param inputStream 検証対象のCSVデータを含む入力ストリーム
118   * @throws IOException I/Oエラーが発生した場合
119   * @throws StreamProcessingException CSVバリデーションエラーが発生した場合
120   */
121  @Override
122  public void consume(final InputStream inputStream) throws IOException {
123    Objects.requireNonNull(inputStream, "InputStream cannot be null");
124
125    LOGGER.info(
126        "Starting CSV validation - hasHeader: {}, requiredColumns: {}",
127        hasHeader,
128        requiredColumns.size());
129
130    List<String> validationErrors = new ArrayList<>();
131
132    try (InputStreamReader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8);
133        CSVReader csvReader = new CSVReader(reader)) {
134
135      String[] headers = null;
136
137      if (hasHeader) {
138        headers = csvReader.readNext();
139        if (headers == null) {
140          throw new StreamProcessingException("CSV validation failed: CSV file is empty");
141        }
142        validateHeaders(headers, validationErrors);
143      }
144
145      String[] row;
146      int rowNum = 1;
147      boolean hasDataRows = false;
148
149      while ((row = csvReader.readNext()) != null) {
150        hasDataRows = true;
151        validateDataRow(row, rowNum++, headers, validationErrors);
152      }
153
154      if (hasHeader && !hasDataRows) {
155        validationErrors.add("CSV file contains only header, no data rows found");
156      } else if (!hasHeader && !hasDataRows) {
157        throw new StreamProcessingException("CSV validation failed: CSV file is empty");
158      }
159
160      if (!validationErrors.isEmpty()) {
161        handleValidationErrors(validationErrors);
162      }
163
164      LOGGER.info("CSV validation completed successfully");
165
166    } catch (CsvValidationException e) {
167      LOGGER.error("CSV parsing error: {}", e.getMessage(), e);
168      throw new StreamProcessingException("Failed to parse CSV: " + e.getMessage(), e);
169    } catch (StreamProcessingException e) {
170      throw e;
171    } catch (Exception e) {
172      LOGGER.error("CSV validation failed: {}", e.getMessage(), e);
173      throw new StreamProcessingException("Failed to parse CSV: " + e.getMessage(), e);
174    }
175  }
176
177  /** ヘッダー行のバリデーション */
178  private void validateHeaders(String[] headers, List<String> errors) {
179    if (headers == null || headers.length == 0) {
180      errors.add("Header row is empty");
181      return;
182    }
183
184    // 重複ヘッダーチェック
185    Set<String> headerSet = new HashSet<>();
186    Set<String> duplicates = new HashSet<>();
187
188    for (String header : headers) {
189      if (header == null || header.trim().isEmpty()) {
190        errors.add("Header contains empty or null column");
191        continue;
192      }
193
194      String trimmedHeader = header.trim();
195      if (!headerSet.add(trimmedHeader)) {
196        duplicates.add(trimmedHeader);
197      }
198    }
199
200    if (!duplicates.isEmpty()) {
201      errors.add("Duplicate column headers: " + duplicates);
202    }
203
204    // 必須カラムの存在チェック
205    if (!requiredColumns.isEmpty()) {
206      Set<String> headerNames = new HashSet<>();
207      for (String header : headers) {
208        if (header != null) {
209          headerNames.add(header.trim());
210        }
211      }
212
213      Set<String> missingColumns = new HashSet<>(requiredColumns);
214      missingColumns.removeAll(headerNames);
215
216      if (!missingColumns.isEmpty()) {
217        errors.add("Missing required columns: " + missingColumns);
218      }
219    }
220
221    LOGGER.debug("Header validation completed - {} columns found", headers.length);
222  }
223
224  /** 1データ行のバリデーション */
225  private void validateDataRow(String[] row, int rowNum, String[] headers, List<String> errors) {
226    int expectedColumnCount = headers != null ? headers.length : -1;
227
228    if (row == null) {
229      addError(errors, String.format("Data row %d: null row", rowNum));
230      return;
231    }
232
233    // カラム数チェック
234    if (expectedColumnCount > 0 && row.length != expectedColumnCount) {
235      addError(
236          errors,
237          String.format(
238              "Data row %d has inconsistent number of columns (expected %d, found %d)",
239              rowNum, expectedColumnCount, row.length));
240      return;
241    }
242
243    // 空行チェック
244    boolean isEmptyRow = true;
245    for (String cell : row) {
246      if (cell != null && !cell.trim().isEmpty()) {
247        isEmptyRow = false;
248        break;
249      }
250    }
251
252    if (isEmptyRow) {
253      addError(errors, String.format("Data row %d: Empty data row", rowNum));
254    }
255  }
256
257  /** エラーメッセージを追加(最大数制限あり) */
258  private void addError(List<String> errors, String error) {
259    if (errors.size() < maxErrorsToReport) {
260      errors.add(error);
261    } else if (errors.size() == maxErrorsToReport) {
262      errors.add("... and more errors (limit reached)");
263    }
264  }
265
266  /** バリデーションエラーの処理 */
267  private void handleValidationErrors(List<String> errors) {
268    StringBuilder errorBuilder = new StringBuilder();
269    errorBuilder.append("CSV validation failed with ").append(errors.size()).append(" error(s):");
270
271    for (int i = 0; i < errors.size(); i++) {
272      errorBuilder.append("\n  ").append(i + 1).append(". ").append(errors.get(i));
273      LOGGER.error("CSV validation error {}: {}", i + 1, errors.get(i));
274    }
275
276    String errorMessage = errorBuilder.toString();
277    LOGGER.error("CSV validation summary: {}", errorMessage);
278
279    // エラーメッセージが長すぎる場合は切り詰める(可読性向上のため)
280    String finalErrorMessage = errorMessage;
281    if (errorMessage.length() > 1000) {
282      finalErrorMessage = errorMessage.substring(0, 997) + "...";
283      LOGGER.warn(
284          "Error message truncated due to length (original: {} chars)", errorMessage.length());
285    }
286
287    throw new StreamProcessingException("CSV validation failed: " + finalErrorMessage);
288  }
289
290  /**
291   * 必須カラムを取得
292   *
293   * @return 必須カラムのセット
294   */
295  public Set<String> getRequiredColumns() {
296    return new HashSet<>(requiredColumns);
297  }
298
299  /**
300   * ヘッダー行の存在フラグを取得
301   *
302   * @return ヘッダー行ありの場合true
303   */
304  public boolean hasHeaderRow() {
305    return hasHeader;
306  }
307
308  /**
309   * 最大エラー報告数を取得
310   *
311   * @return 最大エラー報告数
312   */
313  public int getMaxErrorsToReport() {
314    return maxErrorsToReport;
315  }
316}