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}