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}