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.stream.Collectors;
028
029import static java.lang.String.format;
030
031/**
032 * Thrown when an error occurs when interacting with a {@link Database}.
033 * <p>
034 * If the {@code cause} of this exception is a {@link SQLException}, the {@link #getErrorCode()} and {@link #getSqlState()}
035 * accessors are shorthand for retrieving the corresponding {@link SQLException} values.
036 *
037 * @author <a href="https://www.revetkn.com">Mark Allen</a>
038 * @since 1.0.0
039 */
040@NotThreadSafe
041public class DatabaseException extends RuntimeException {
042        @Nullable
043        private final Integer errorCode;
044        @Nullable
045        private final String sqlState;
046        @Nullable
047        private final String column;
048        @Nullable
049        private final String constraint;
050        @Nullable
051        private final String datatype;
052        @Nullable
053        private final String detail;
054        @Nullable
055        private final String file;
056        @Nullable
057        private final String hint;
058        @Nullable
059        private final Integer internalPosition;
060        @Nullable
061        private final String internalQuery;
062        @Nullable
063        private final Integer line;
064        @Nullable
065        private final String dbmsMessage;
066        @Nullable
067        private final Integer position;
068        @Nullable
069        private final String routine;
070        @Nullable
071        private final String schema;
072        @Nullable
073        private final String severity;
074        @Nullable
075        private final String table;
076        @Nullable
077        private final String where;
078        @NonNull
079        private final Boolean uniqueConstraintViolation;
080        @NonNull
081        private final Boolean foreignKeyViolation;
082        @NonNull
083        private final Boolean deadlock;
084        @NonNull
085        private final Boolean transientException;
086        @NonNull
087        private final Boolean serializationFailure;
088        @NonNull
089        private final Boolean timeout;
090
091        /**
092         * Creates a {@code DatabaseException} with the given {@code message}.
093         *
094         * @param message a message describing this exception
095         */
096        public DatabaseException(@Nullable String message) {
097                this(message, null);
098        }
099
100        /**
101         * Creates a {@code DatabaseException} which wraps the given {@code cause}.
102         *
103         * @param cause the cause of this exception
104         */
105        public DatabaseException(@Nullable Throwable cause) {
106                this(cause == null ? null : cause.getMessage(), cause);
107        }
108
109        /**
110         * Creates a {@code DatabaseException} which wraps the given {@code cause}.
111         *
112         * @param message a message describing this exception
113         * @param cause   the cause of this exception
114         */
115        public DatabaseException(@Nullable String message,
116                                                                                                         @Nullable Throwable cause) {
117                this(message, cause, DatabaseDialect.forExceptionCause(cause));
118        }
119
120        DatabaseException(@Nullable String message,
121                                                                                @Nullable Throwable cause,
122                                                                                @NonNull DatabaseDialect databaseDialect) {
123                super(message, cause);
124
125                DatabaseExceptionMetadata metadata = databaseDialect.databaseExceptionMetadata(cause);
126
127                this.errorCode = metadata.errorCode;
128                this.sqlState = metadata.sqlState;
129                this.column = metadata.column;
130                this.constraint = metadata.constraint;
131                this.datatype = metadata.datatype;
132                this.detail = metadata.detail;
133                this.file = metadata.file;
134                this.hint = metadata.hint;
135                this.internalPosition = metadata.internalPosition;
136                this.internalQuery = metadata.internalQuery;
137                this.line = metadata.line;
138                this.dbmsMessage = metadata.dbmsMessage;
139                this.position = metadata.position;
140                this.routine = metadata.routine;
141                this.schema = metadata.schema;
142                this.severity = metadata.severity;
143                this.table = metadata.table;
144                this.where = metadata.where;
145                this.uniqueConstraintViolation = databaseDialect.isUniqueConstraintViolation(metadata, cause);
146                this.foreignKeyViolation = databaseDialect.isForeignKeyViolation(metadata, cause);
147                this.deadlock = databaseDialect.isDeadlock(metadata, cause);
148                this.transientException = databaseDialect.isTransient(metadata, cause);
149                this.serializationFailure = databaseDialect.isSerializationFailure(metadata, cause);
150                this.timeout = databaseDialect.isTimeout(metadata, cause);
151        }
152
153        @Override
154        public String toString() {
155                List<String> components = new ArrayList<>(20);
156
157                if (getMessage() != null && getMessage().trim().length() > 0)
158                        components.add(format("message=%s", getMessage().trim()));
159
160                if (getErrorCode().isPresent())
161                        components.add(format("errorCode=%s", getErrorCode().get()));
162                if (getSqlState().isPresent())
163                        components.add(format("sqlState=%s", getSqlState().get()));
164                if (getColumn().isPresent())
165                        components.add(format("column=%s", getColumn().get()));
166                if (getConstraint().isPresent())
167                        components.add(format("constraint=%s", getConstraint().get()));
168                if (getDatatype().isPresent())
169                        components.add(format("datatype=%s", getDatatype().get()));
170                if (getDetail().isPresent())
171                        components.add(format("detail=%s", getDetail().get()));
172                if (getFile().isPresent())
173                        components.add(format("file=%s", getFile().get()));
174                if (getHint().isPresent())
175                        components.add(format("hint=%s", getHint().get()));
176                if (getInternalPosition().isPresent())
177                        components.add(format("internalPosition=%s", getInternalPosition().get()));
178                if (getInternalQuery().isPresent())
179                        components.add(format("internalQuery=%s", getInternalQuery().get()));
180                if (getLine().isPresent())
181                        components.add(format("line=%s", getLine().get()));
182                if (getDbmsMessage().isPresent())
183                        components.add(format("dbmsMessage=%s", getDbmsMessage().get()));
184                if (getPosition().isPresent())
185                        components.add(format("position=%s", getPosition().get()));
186                if (getRoutine().isPresent())
187                        components.add(format("routine=%s", getRoutine().get()));
188                if (getSchema().isPresent())
189                        components.add(format("schema=%s", getSchema().get()));
190                if (getSeverity().isPresent())
191                        components.add(format("severity=%s", getSeverity().get()));
192                if (getTable().isPresent())
193                        components.add(format("table=%s", getTable().get()));
194                if (getWhere().isPresent())
195                        components.add(format("where=%s", getWhere().get()));
196
197                return format("%s: %s", getClass().getName(), components.stream().collect(Collectors.joining(", ")));
198        }
199
200        /**
201         * Shorthand for {@link SQLException#getErrorCode()} if this exception was caused by a {@link SQLException}.
202         *
203         * @return the value of {@link SQLException#getErrorCode()}, or empty if not available
204         */
205        @NonNull
206        public Optional<Integer> getErrorCode() {
207                return Optional.ofNullable(this.errorCode);
208        }
209
210        /**
211         * Shorthand for {@link SQLException#getSQLState()} if this exception was caused by a {@link SQLException}.
212         *
213         * @return the value of {@link SQLException#getSQLState()}, or empty if not available
214         */
215        @NonNull
216        public Optional<String> getSqlState() {
217                return Optional.ofNullable(this.sqlState);
218        }
219
220        /**
221         * Determines if this exception is recognized as a unique constraint violation.
222         * <p>
223         * This method is intentionally conservative: it returns {@code true} only when Pyranid recognizes a database-specific
224         * error code or SQLState as a unique or primary-key uniqueness violation.
225         *
226         * @return {@code true} if this exception is recognized as a unique constraint violation
227         * @since 4.3.0
228         */
229        @NonNull
230        public Boolean isUniqueConstraintViolation() {
231                return this.uniqueConstraintViolation;
232        }
233
234        /**
235         * Determines if this exception is recognized as a foreign-key violation.
236         * <p>
237         * This method is intentionally conservative: it returns {@code true} only when Pyranid recognizes a database-specific
238         * error code or SQLState as a foreign-key violation.
239         * For example, SQL Server error {@code 547} is not classified as a foreign-key violation by code alone because the
240         * same error code also covers other constraint failures, such as {@code CHECK} constraints.
241         *
242         * @return {@code true} if this exception is recognized as a foreign-key violation
243         * @since 4.3.0
244         */
245        @NonNull
246        public Boolean isForeignKeyViolation() {
247                return this.foreignKeyViolation;
248        }
249
250        /**
251         * Determines if this exception is recognized as a deadlock.
252         * <p>
253         * This method is intentionally conservative: it returns {@code true} only when Pyranid recognizes a database-specific
254         * error code or SQLState as a deadlock.
255         *
256         * @return {@code true} if this exception is recognized as a deadlock
257         * @since 4.3.0
258         */
259        @NonNull
260        public Boolean isDeadlock() {
261                return this.deadlock;
262        }
263
264        /**
265         * Determines if this exception is recognized as transient.
266         * <p>
267         * This method is intentionally conservative: it returns {@code true} for JDBC transient/recoverable exception classes,
268         * SQLState classes for connection exceptions and transaction rollbacks, or database-specific deadlock/lock-timeout
269         * signals recognized by Pyranid. A {@code true} result does not guarantee that retrying the operation will succeed.
270         *
271         * @return {@code true} if this exception is recognized as transient
272         * @since 4.3.0
273         */
274        @NonNull
275        public Boolean isTransient() {
276                return this.transientException;
277        }
278
279        /**
280         * Determines if this exception is recognized as a serialization failure.
281         * <p>
282         * This method is intentionally conservative: it returns {@code true} only when Pyranid recognizes a database-specific
283         * error code or SQLState as a serialization failure.
284         *
285         * @return {@code true} if this exception is recognized as a serialization failure
286         * @since 4.4.0
287         */
288        @NonNull
289        public Boolean isSerializationFailure() {
290                return this.serializationFailure;
291        }
292
293        /**
294         * Determines if this exception is recognized as a timeout or cancellation.
295         * <p>
296         * This method is intentionally conservative: it returns {@code true} only when Pyranid recognizes a JDBC timeout,
297         * database-specific timeout, or database-specific cancellation signal. A {@code true} result does not mean the operation
298         * is automatically safe to retry.
299         *
300         * @return {@code true} if this exception is recognized as a timeout or cancellation
301         * @since 4.4.0
302         */
303        @NonNull
304        public Boolean isTimeout() {
305                return this.timeout;
306        }
307
308        /**
309         * @return the value of the offending {@code column}, or empty if not available
310         * @since 1.0.12
311         */
312        @NonNull
313        public Optional<String> getColumn() {
314                return Optional.ofNullable(this.column);
315        }
316
317        /**
318         * @return the value of the offending {@code constraint}, or empty if not available
319         * @since 1.0.12
320         */
321        @NonNull
322        public Optional<String> getConstraint() {
323                return Optional.ofNullable(this.constraint);
324        }
325
326        /**
327         * @return the value of the offending {@code datatype}, or empty if not available
328         * @since 1.0.12
329         */
330        @NonNull
331        public Optional<String> getDatatype() {
332                return Optional.ofNullable(this.datatype);
333        }
334
335        /**
336         * @return the value of the offending {@code detail}, or empty if not available
337         * @since 1.0.12
338         */
339        @NonNull
340        public Optional<String> getDetail() {
341                return Optional.ofNullable(this.detail);
342        }
343
344        /**
345         * @return the value of the offending {@code file}, or empty if not available
346         * @since 1.0.12
347         */
348        @NonNull
349        public Optional<String> getFile() {
350                return Optional.ofNullable(this.file);
351        }
352
353        /**
354         * @return the value of the error {@code hint}, or empty if not available
355         * @since 1.0.12
356         */
357        @NonNull
358        public Optional<String> getHint() {
359                return Optional.ofNullable(this.hint);
360        }
361
362        /**
363         * @return the value of the offending {@code internalPosition}, or empty if not available
364         * @since 1.0.12
365         */
366        @NonNull
367        public Optional<Integer> getInternalPosition() {
368                return Optional.ofNullable(this.internalPosition);
369        }
370
371        /**
372         * @return the value of the offending {@code internalQuery}, or empty if not available
373         * @since 1.0.12
374         */
375        @NonNull
376        public Optional<String> getInternalQuery() {
377                return Optional.ofNullable(this.internalQuery);
378        }
379
380        /**
381         * @return the value of the offending {@code line}, or empty if not available
382         * @since 1.0.12
383         */
384        @NonNull
385        public Optional<Integer> getLine() {
386                return Optional.ofNullable(this.line);
387        }
388
389        /**
390         * @return the value of the error {@code dbmsMessage}, or empty if not available
391         * @since 1.0.12
392         */
393        @NonNull
394        public Optional<String> getDbmsMessage() {
395                return Optional.ofNullable(this.dbmsMessage);
396        }
397
398        /**
399         * @return the value of the offending {@code position}, or empty if not available
400         * @since 1.0.12
401         */
402        @NonNull
403        public Optional<Integer> getPosition() {
404                return Optional.ofNullable(this.position);
405        }
406
407        /**
408         * @return the value of the offending {@code routine}, or empty if not available
409         * @since 1.0.12
410         */
411        @NonNull
412        public Optional<String> getRoutine() {
413                return Optional.ofNullable(this.routine);
414        }
415
416        /**
417         * @return the value of the offending {@code schema}, or empty if not available
418         * @since 1.0.12
419         */
420        @NonNull
421        public Optional<String> getSchema() {
422                return Optional.ofNullable(this.schema);
423        }
424
425        /**
426         * @return the error {@code severity}, or empty if not available
427         * @since 1.0.12
428         */
429        @NonNull
430        public Optional<String> getSeverity() {
431                return Optional.ofNullable(this.severity);
432        }
433
434        /**
435         * @return the value of the offending {@code table}, or empty if not available
436         * @since 1.0.12
437         */
438        @NonNull
439        public Optional<String> getTable() {
440                return Optional.ofNullable(this.table);
441        }
442
443        /**
444         * @return the value of the offending {@code where}, or empty if not available
445         * @since 1.0.12
446         */
447        @NonNull
448        public Optional<String> getWhere() {
449                return Optional.ofNullable(this.where);
450        }
451}