001/* 002 * Copyright 2015-2022 Transmogrify LLC, 2022-2026 Revetware LLC. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package com.pyranid; 018 019import org.jspecify.annotations.NonNull; 020import org.jspecify.annotations.Nullable; 021 022import javax.annotation.concurrent.NotThreadSafe; 023import java.sql.SQLException; 024import java.util.ArrayList; 025import java.util.List; 026import java.util.Optional; 027import java.util.function.UnaryOperator; 028import java.util.stream.Collectors; 029 030import static java.lang.String.format; 031 032/** 033 * Thrown when an error occurs when interacting with a {@link Database}. 034 * <p> 035 * If the {@code cause} of this exception is a {@link SQLException}, the {@link #getErrorCode()} and {@link #getSqlState()} 036 * accessors are shorthand for retrieving the corresponding {@link SQLException} values. 037 * 038 * @author <a href="https://www.revetkn.com">Mark Allen</a> 039 * @since 1.0.0 040 */ 041@NotThreadSafe 042public class DatabaseException extends RuntimeException { 043 @Nullable 044 private final Integer errorCode; 045 @Nullable 046 private final String sqlState; 047 @Nullable 048 private final String column; 049 @Nullable 050 private final String constraint; 051 @Nullable 052 private final String datatype; 053 @Nullable 054 private final String detail; 055 @Nullable 056 private final String file; 057 @Nullable 058 private final String hint; 059 @Nullable 060 private final Integer internalPosition; 061 @Nullable 062 private final String internalQuery; 063 @Nullable 064 private final Integer line; 065 @Nullable 066 private final String dbmsMessage; 067 @Nullable 068 private final Integer position; 069 @Nullable 070 private final String routine; 071 @Nullable 072 private final String schema; 073 @Nullable 074 private final String severity; 075 @Nullable 076 private final String table; 077 @Nullable 078 private final String where; 079 @NonNull 080 private final Boolean uniqueConstraintViolation; 081 @NonNull 082 private final Boolean foreignKeyViolation; 083 @NonNull 084 private final Boolean deadlock; 085 @NonNull 086 private final Boolean transientException; 087 @NonNull 088 private final Boolean serializationFailure; 089 @NonNull 090 private final Boolean timeout; 091 private boolean transactionOutcomeCommitted; 092 private boolean statementDiagnosticsApplied; 093 094 /** 095 * Creates a {@code DatabaseException} with the given {@code message}. 096 * 097 * @param message a message describing this exception 098 */ 099 public DatabaseException(@Nullable String message) { 100 this(message, null); 101 } 102 103 /** 104 * Creates a {@code DatabaseException} which wraps the given {@code cause}. 105 * 106 * @param cause the cause of this exception 107 */ 108 public DatabaseException(@Nullable Throwable cause) { 109 this(cause == null ? null : cause.getMessage(), cause); 110 } 111 112 /** 113 * Creates a {@code DatabaseException} which wraps the given {@code cause}. 114 * 115 * @param message a message describing this exception 116 * @param cause the cause of this exception 117 */ 118 public DatabaseException(@Nullable String message, 119 @Nullable Throwable cause) { 120 this(message, cause, DatabaseDialect.forExceptionCause(cause)); 121 } 122 123 DatabaseException(@Nullable String message, 124 @Nullable Throwable cause, 125 @NonNull DatabaseDialect databaseDialect) { 126 this(message, cause, databaseDialect, null); 127 } 128 129 /** 130 * Package-private constructor which applies a best-effort diagnostic redactor to every String metadata field 131 * extracted from the cause - used by {@link Database} to scrub verbatim occurrences of {@link SecureParameter} 132 * values that the database driver may have echoed into its error text. 133 * <p> 134 * The {@code message} is deliberately NOT re-scrubbed here: the caller is expected to have scrubbed the raw 135 * driver text before composing the message (scrubbing must run before whitespace-collapsing/truncation), and 136 * the composed remainder is Pyranid-rendered and already redaction-aware. Re-applying the redactor would 137 * re-scan mask text from the first pass, violating the documented one-pass "masks are never re-masked" 138 * semantics. Each metadata field, by contrast, is raw dialect-extracted text seen exactly once. 139 * <p> 140 * The {@code cause} chain is deliberately left untouched: sinks that render the stack trace or walk 141 * {@link #getCause()} can still observe the raw driver text. 142 */ 143 DatabaseException(@Nullable String message, 144 @Nullable Throwable cause, 145 @NonNull DatabaseDialect databaseDialect, 146 @Nullable UnaryOperator<String> diagnosticRedactor) { 147 super(message, cause); 148 149 DatabaseExceptionMetadata metadata = databaseDialect.databaseExceptionMetadata(cause); 150 151 this.errorCode = metadata.errorCode; 152 this.sqlState = applyDiagnosticRedactor(diagnosticRedactor, metadata.sqlState); 153 this.column = applyDiagnosticRedactor(diagnosticRedactor, metadata.column); 154 this.constraint = applyDiagnosticRedactor(diagnosticRedactor, metadata.constraint); 155 this.datatype = applyDiagnosticRedactor(diagnosticRedactor, metadata.datatype); 156 this.detail = applyDiagnosticRedactor(diagnosticRedactor, metadata.detail); 157 this.file = applyDiagnosticRedactor(diagnosticRedactor, metadata.file); 158 this.hint = applyDiagnosticRedactor(diagnosticRedactor, metadata.hint); 159 this.internalPosition = metadata.internalPosition; 160 this.internalQuery = applyDiagnosticRedactor(diagnosticRedactor, metadata.internalQuery); 161 this.line = metadata.line; 162 this.dbmsMessage = applyDiagnosticRedactor(diagnosticRedactor, metadata.dbmsMessage); 163 this.position = metadata.position; 164 this.routine = applyDiagnosticRedactor(diagnosticRedactor, metadata.routine); 165 this.schema = applyDiagnosticRedactor(diagnosticRedactor, metadata.schema); 166 this.severity = applyDiagnosticRedactor(diagnosticRedactor, metadata.severity); 167 this.table = applyDiagnosticRedactor(diagnosticRedactor, metadata.table); 168 this.where = applyDiagnosticRedactor(diagnosticRedactor, metadata.where); 169 this.uniqueConstraintViolation = databaseDialect.isUniqueConstraintViolation(metadata, cause); 170 this.foreignKeyViolation = databaseDialect.isForeignKeyViolation(metadata, cause); 171 this.deadlock = databaseDialect.isDeadlock(metadata, cause); 172 this.transientException = databaseDialect.isTransient(metadata, cause); 173 this.serializationFailure = databaseDialect.isSerializationFailure(metadata, cause); 174 this.timeout = databaseDialect.isTimeout(metadata, cause); 175 this.transactionOutcomeCommitted = false; 176 this.statementDiagnosticsApplied = false; 177 } 178 179 DatabaseException(@Nullable String message, 180 @NonNull DatabaseException cause, 181 @Nullable UnaryOperator<String> diagnosticRedactor) { 182 super(message, cause); 183 184 this.errorCode = cause.errorCode; 185 this.sqlState = applyDiagnosticRedactor(diagnosticRedactor, cause.sqlState); 186 this.column = applyDiagnosticRedactor(diagnosticRedactor, cause.column); 187 this.constraint = applyDiagnosticRedactor(diagnosticRedactor, cause.constraint); 188 this.datatype = applyDiagnosticRedactor(diagnosticRedactor, cause.datatype); 189 this.detail = applyDiagnosticRedactor(diagnosticRedactor, cause.detail); 190 this.file = applyDiagnosticRedactor(diagnosticRedactor, cause.file); 191 this.hint = applyDiagnosticRedactor(diagnosticRedactor, cause.hint); 192 this.internalPosition = cause.internalPosition; 193 this.internalQuery = applyDiagnosticRedactor(diagnosticRedactor, cause.internalQuery); 194 this.line = cause.line; 195 this.dbmsMessage = applyDiagnosticRedactor(diagnosticRedactor, cause.dbmsMessage); 196 this.position = cause.position; 197 this.routine = applyDiagnosticRedactor(diagnosticRedactor, cause.routine); 198 this.schema = applyDiagnosticRedactor(diagnosticRedactor, cause.schema); 199 this.severity = applyDiagnosticRedactor(diagnosticRedactor, cause.severity); 200 this.table = applyDiagnosticRedactor(diagnosticRedactor, cause.table); 201 this.where = applyDiagnosticRedactor(diagnosticRedactor, cause.where); 202 this.uniqueConstraintViolation = cause.uniqueConstraintViolation; 203 this.foreignKeyViolation = cause.foreignKeyViolation; 204 this.deadlock = cause.deadlock; 205 this.transientException = cause.transientException; 206 this.serializationFailure = cause.serializationFailure; 207 this.timeout = cause.timeout; 208 this.transactionOutcomeCommitted = cause.transactionOutcomeCommitted; 209 this.statementDiagnosticsApplied = cause.statementDiagnosticsApplied; 210 } 211 212 void markTransactionOutcomeCommitted() { 213 this.transactionOutcomeCommitted = true; 214 } 215 216 boolean isTransactionOutcomeCommitted() { 217 return this.transactionOutcomeCommitted; 218 } 219 220 void markStatementDiagnosticsApplied() { 221 this.statementDiagnosticsApplied = true; 222 } 223 224 boolean areStatementDiagnosticsApplied() { 225 return this.statementDiagnosticsApplied; 226 } 227 228 @Nullable 229 private static String applyDiagnosticRedactor(@Nullable UnaryOperator<String> diagnosticRedactor, 230 @Nullable String value) { 231 return diagnosticRedactor == null || value == null ? value : diagnosticRedactor.apply(value); 232 } 233 234 @Override 235 public String toString() { 236 List<String> components = new ArrayList<>(20); 237 238 if (getMessage() != null && getMessage().trim().length() > 0) 239 components.add(format("message=%s", getMessage().trim())); 240 241 if (getErrorCode().isPresent()) 242 components.add(format("errorCode=%s", getErrorCode().get())); 243 if (getSqlState().isPresent()) 244 components.add(format("sqlState=%s", getSqlState().get())); 245 if (getColumn().isPresent()) 246 components.add(format("column=%s", getColumn().get())); 247 if (getConstraint().isPresent()) 248 components.add(format("constraint=%s", getConstraint().get())); 249 if (getDatatype().isPresent()) 250 components.add(format("datatype=%s", getDatatype().get())); 251 if (getDetail().isPresent()) 252 components.add(format("detail=%s", getDetail().get())); 253 if (getFile().isPresent()) 254 components.add(format("file=%s", getFile().get())); 255 if (getHint().isPresent()) 256 components.add(format("hint=%s", getHint().get())); 257 if (getInternalPosition().isPresent()) 258 components.add(format("internalPosition=%s", getInternalPosition().get())); 259 if (getInternalQuery().isPresent()) 260 components.add(format("internalQuery=%s", getInternalQuery().get())); 261 if (getLine().isPresent()) 262 components.add(format("line=%s", getLine().get())); 263 if (getDbmsMessage().isPresent()) 264 components.add(format("dbmsMessage=%s", getDbmsMessage().get())); 265 if (getPosition().isPresent()) 266 components.add(format("position=%s", getPosition().get())); 267 if (getRoutine().isPresent()) 268 components.add(format("routine=%s", getRoutine().get())); 269 if (getSchema().isPresent()) 270 components.add(format("schema=%s", getSchema().get())); 271 if (getSeverity().isPresent()) 272 components.add(format("severity=%s", getSeverity().get())); 273 if (getTable().isPresent()) 274 components.add(format("table=%s", getTable().get())); 275 if (getWhere().isPresent()) 276 components.add(format("where=%s", getWhere().get())); 277 278 return format("%s: %s", getClass().getName(), components.stream().collect(Collectors.joining(", "))); 279 } 280 281 /** 282 * Shorthand for {@link SQLException#getErrorCode()} if this exception was caused by a {@link SQLException}. 283 * 284 * @return the value of {@link SQLException#getErrorCode()}, or empty if not available 285 */ 286 @NonNull 287 public Optional<Integer> getErrorCode() { 288 return Optional.ofNullable(this.errorCode); 289 } 290 291 /** 292 * Shorthand for {@link SQLException#getSQLState()} if this exception was caused by a {@link SQLException}. 293 * 294 * @return the value of {@link SQLException#getSQLState()}, or empty if not available 295 */ 296 @NonNull 297 public Optional<String> getSqlState() { 298 return Optional.ofNullable(this.sqlState); 299 } 300 301 /** 302 * Determines if this exception is recognized as a unique constraint violation. 303 * <p> 304 * This method is intentionally conservative: it returns {@code true} only when Pyranid recognizes a database-specific 305 * error code or SQLState as a unique or primary-key uniqueness violation. 306 * 307 * @return {@code true} if this exception is recognized as a unique constraint violation 308 * @since 4.3.0 309 */ 310 @NonNull 311 public Boolean isUniqueConstraintViolation() { 312 return this.uniqueConstraintViolation; 313 } 314 315 /** 316 * Determines if this exception is recognized as a foreign-key violation. 317 * <p> 318 * This method is intentionally conservative: it returns {@code true} only when Pyranid recognizes a database-specific 319 * error code or SQLState as a foreign-key violation. 320 * For example, SQL Server error {@code 547} is not classified as a foreign-key violation by code alone because the 321 * same error code also covers other constraint failures, such as {@code CHECK} constraints. 322 * 323 * @return {@code true} if this exception is recognized as a foreign-key violation 324 * @since 4.3.0 325 */ 326 @NonNull 327 public Boolean isForeignKeyViolation() { 328 return this.foreignKeyViolation; 329 } 330 331 /** 332 * Determines if this exception is recognized as a deadlock. 333 * <p> 334 * This method is intentionally conservative: it returns {@code true} only when Pyranid recognizes a database-specific 335 * error code or SQLState as a deadlock. 336 * 337 * @return {@code true} if this exception is recognized as a deadlock 338 * @since 4.3.0 339 */ 340 @NonNull 341 public Boolean isDeadlock() { 342 return this.deadlock; 343 } 344 345 /** 346 * Determines if this exception is recognized as transient. 347 * <p> 348 * This method is intentionally conservative: it returns {@code true} for JDBC transient/recoverable exception classes, 349 * SQLState classes for connection exceptions and transaction rollbacks, or database-specific deadlock/lock-timeout 350 * signals recognized by Pyranid. A {@code true} result does not guarantee that retrying the operation will succeed. 351 * 352 * @return {@code true} if this exception is recognized as transient 353 * @since 4.3.0 354 */ 355 @NonNull 356 public Boolean isTransient() { 357 return this.transientException; 358 } 359 360 /** 361 * Determines if this exception is recognized as a serialization failure. 362 * <p> 363 * This method is intentionally conservative: it returns {@code true} only when Pyranid recognizes a database-specific 364 * error code or SQLState as a serialization failure. 365 * 366 * @return {@code true} if this exception is recognized as a serialization failure 367 * @since 4.4.0 368 */ 369 @NonNull 370 public Boolean isSerializationFailure() { 371 return this.serializationFailure; 372 } 373 374 /** 375 * Determines if this exception is recognized as a timeout or cancellation. 376 * <p> 377 * This method is intentionally conservative: it returns {@code true} only when Pyranid recognizes a JDBC timeout, 378 * database-specific timeout, or database-specific cancellation signal. A {@code true} result does not mean the operation 379 * is automatically safe to retry. 380 * 381 * @return {@code true} if this exception is recognized as a timeout or cancellation 382 * @since 4.4.0 383 */ 384 @NonNull 385 public Boolean isTimeout() { 386 return this.timeout; 387 } 388 389 /** 390 * @return the value of the offending {@code column}, or empty if not available 391 * @since 1.0.12 392 */ 393 @NonNull 394 public Optional<String> getColumn() { 395 return Optional.ofNullable(this.column); 396 } 397 398 /** 399 * @return the value of the offending {@code constraint}, or empty if not available 400 * @since 1.0.12 401 */ 402 @NonNull 403 public Optional<String> getConstraint() { 404 return Optional.ofNullable(this.constraint); 405 } 406 407 /** 408 * @return the value of the offending {@code datatype}, or empty if not available 409 * @since 1.0.12 410 */ 411 @NonNull 412 public Optional<String> getDatatype() { 413 return Optional.ofNullable(this.datatype); 414 } 415 416 /** 417 * @return the value of the offending {@code detail}, or empty if not available 418 * @since 1.0.12 419 */ 420 @NonNull 421 public Optional<String> getDetail() { 422 return Optional.ofNullable(this.detail); 423 } 424 425 /** 426 * @return the value of the offending {@code file}, or empty if not available 427 * @since 1.0.12 428 */ 429 @NonNull 430 public Optional<String> getFile() { 431 return Optional.ofNullable(this.file); 432 } 433 434 /** 435 * @return the value of the error {@code hint}, or empty if not available 436 * @since 1.0.12 437 */ 438 @NonNull 439 public Optional<String> getHint() { 440 return Optional.ofNullable(this.hint); 441 } 442 443 /** 444 * @return the value of the offending {@code internalPosition}, or empty if not available 445 * @since 1.0.12 446 */ 447 @NonNull 448 public Optional<Integer> getInternalPosition() { 449 return Optional.ofNullable(this.internalPosition); 450 } 451 452 /** 453 * @return the value of the offending {@code internalQuery}, or empty if not available 454 * @since 1.0.12 455 */ 456 @NonNull 457 public Optional<String> getInternalQuery() { 458 return Optional.ofNullable(this.internalQuery); 459 } 460 461 /** 462 * @return the value of the offending {@code line}, or empty if not available 463 * @since 1.0.12 464 */ 465 @NonNull 466 public Optional<Integer> getLine() { 467 return Optional.ofNullable(this.line); 468 } 469 470 /** 471 * @return the value of the error {@code dbmsMessage}, or empty if not available 472 * @since 1.0.12 473 */ 474 @NonNull 475 public Optional<String> getDbmsMessage() { 476 return Optional.ofNullable(this.dbmsMessage); 477 } 478 479 /** 480 * @return the value of the offending {@code position}, or empty if not available 481 * @since 1.0.12 482 */ 483 @NonNull 484 public Optional<Integer> getPosition() { 485 return Optional.ofNullable(this.position); 486 } 487 488 /** 489 * @return the value of the offending {@code routine}, or empty if not available 490 * @since 1.0.12 491 */ 492 @NonNull 493 public Optional<String> getRoutine() { 494 return Optional.ofNullable(this.routine); 495 } 496 497 /** 498 * @return the value of the offending {@code schema}, or empty if not available 499 * @since 1.0.12 500 */ 501 @NonNull 502 public Optional<String> getSchema() { 503 return Optional.ofNullable(this.schema); 504 } 505 506 /** 507 * @return the error {@code severity}, or empty if not available 508 * @since 1.0.12 509 */ 510 @NonNull 511 public Optional<String> getSeverity() { 512 return Optional.ofNullable(this.severity); 513 } 514 515 /** 516 * @return the value of the offending {@code table}, or empty if not available 517 * @since 1.0.12 518 */ 519 @NonNull 520 public Optional<String> getTable() { 521 return Optional.ofNullable(this.table); 522 } 523 524 /** 525 * @return the value of the offending {@code where}, or empty if not available 526 * @since 1.0.12 527 */ 528 @NonNull 529 public Optional<String> getWhere() { 530 return Optional.ofNullable(this.where); 531 } 532}