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