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}