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