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 javax.annotation.concurrent.ThreadSafe;
024import javax.sql.DataSource;
025import java.sql.Connection;
026import java.sql.DatabaseMetaData;
027import java.sql.PreparedStatement;
028import java.sql.ResultSet;
029import java.sql.SQLException;
030import java.sql.SQLFeatureNotSupportedException;
031import java.time.Duration;
032import java.time.ZoneId;
033import java.util.Arrays;
034import java.util.List;
035import java.util.Locale;
036import java.util.Optional;
037import java.util.Queue;
038import java.util.Set;
039import java.util.concurrent.atomic.AtomicReference;
040import java.util.concurrent.atomic.AtomicLong;
041import java.util.concurrent.locks.ReentrantLock;
042import java.util.function.UnaryOperator;
043import java.util.regex.Pattern;
044
045import static java.lang.String.format;
046import static java.lang.System.nanoTime;
047import static java.util.Objects.requireNonNull;
048
049/**
050 * Main class for performing database access operations.
051 *
052 * @author <a href="https://www.revetkn.com">Mark Allen</a>
053 * @since 1.0.0
054 */
055@ThreadSafe
056public final class Database {
057        private static final int DEFAULT_PARSED_SQL_CACHE_CAPACITY = 1024;
058        private static final int MAX_DIAGNOSTIC_MESSAGE_LENGTH = 1024;
059        private static final int MAX_DIAGNOSTIC_PARAMETER_LENGTH = 256;
060        private static final int MAX_DIAGNOSTIC_PARAMETERS_LENGTH = 2048;
061        private static final int MAX_DIAGNOSTIC_SQL_LENGTH = 2048;
062        @NonNull
063        private static final String TRUNCATED_SUFFIX = "... (truncated)";
064        @NonNull
065        private static final Pattern DIAGNOSTIC_WHITESPACE_PATTERN = Pattern.compile("\\s+");
066
067        @NonNull
068        private final DataSource dataSource;
069        @NonNull
070        private final DatabaseType databaseType;
071        @NonNull
072        private final DatabaseDialect databaseDialect;
073        @NonNull
074        private final ZoneId timeZone;
075        @NonNull
076        private final AmbiguousTimestampBindingStrategy ambiguousTimestampBindingStrategy;
077        @NonNull
078        private final InstanceProvider instanceProvider;
079        @NonNull
080        private final ResultSetMapper resultSetMapper;
081        @NonNull
082        private final ParameterRedactor parameterRedactor;
083        @NonNull
084        private final MetricsCollectorDispatcher metricsCollectorDispatcher;
085        @NonNull
086        private final TransactionCoordinator transactions;
087        @Nullable
088        private final Duration queryTimeout;
089        @Nullable
090        private final Integer fetchSize;
091        @Nullable
092        private final Integer maxRows;
093        @NonNull
094        private final SqlCompiler sqlCompiler;
095        @NonNull
096        private final AtomicLong defaultIdGenerator;
097
098        @NonNull
099        private final StatementExecutor statementExecutor;
100
101        private Database(@NonNull Builder builder) {
102                requireNonNull(builder);
103
104                this.dataSource = requireNonNull(builder.dataSource);
105                this.databaseType = builder.databaseType;
106                this.databaseDialect = this.databaseType.dialect();
107                this.timeZone = builder.timeZone == null ? ZoneId.systemDefault() : builder.timeZone;
108                this.ambiguousTimestampBindingStrategy = builder.ambiguousTimestampBindingStrategy == null
109                                ? AmbiguousTimestampBindingStrategy.TIMESTAMP_WITH_TIME_ZONE
110                                : builder.ambiguousTimestampBindingStrategy;
111                this.instanceProvider = builder.instanceProvider == null ? new InstanceProvider() {} : builder.instanceProvider;
112                PreparedStatementBinder preparedStatementBinder = builder.preparedStatementBinder == null ? PreparedStatementBinder.withDefaultConfiguration() : builder.preparedStatementBinder;
113                this.resultSetMapper = builder.resultSetMapper == null ? ResultSetMapper.withDefaultConfiguration() : builder.resultSetMapper;
114                StatementLogger statementLogger = builder.statementLogger == null ? (statementLog) -> {} : builder.statementLogger;
115                this.parameterRedactor = builder.parameterRedactor == null ? ParameterRedactor.none() : builder.parameterRedactor;
116                this.metricsCollectorDispatcher = new MetricsCollectorDispatcher(builder.metricsCollector);
117                this.transactions = new TransactionCoordinator(this.dataSource, this.metricsCollectorDispatcher,
118                                this.databaseType);
119                this.queryTimeout = validateQueryTimeout(builder.queryTimeout);
120                this.fetchSize = validateNonNegativeStatementSetting("fetchSize", builder.fetchSize);
121                this.maxRows = validateNonNegativeStatementSetting("maxRows", builder.maxRows);
122                if (builder.parsedSqlCacheCapacity != null && builder.parsedSqlCacheCapacity < 0)
123                        throw new IllegalArgumentException("parsedSqlCacheCapacity must be >= 0");
124
125                int parsedSqlCacheCapacity = builder.parsedSqlCacheCapacity == null
126                                ? DEFAULT_PARSED_SQL_CACHE_CAPACITY
127                                : builder.parsedSqlCacheCapacity;
128                this.sqlCompiler = new SqlCompiler(parsedSqlCacheCapacity);
129                this.defaultIdGenerator = new AtomicLong();
130                this.statementExecutor = new StatementExecutor(this, this.instanceProvider, this.resultSetMapper, preparedStatementBinder,
131                                statementLogger, this.metricsCollectorDispatcher, new StatementConnections(this.dataSource,
132                                this::currentTransactionForDatabaseOperation, connection -> closeConnection(connection, this.databaseDialect),
133                                this.databaseType, this.databaseDialect));
134        }
135
136        /**
137         * Provides a {@link Database} builder for the given {@link DataSource} and database type.
138         * <p>
139         * Neither creating the builder nor calling {@link Builder#build()} acquires a connection or inspects JDBC metadata.
140         *
141         * @param dataSource data source used to create the {@link Database} builder
142         * @param databaseType database type to use; choose {@link DatabaseType#GENERIC} for a database without dedicated handling
143         * @return a {@link Database} builder
144         * @since 4.8.0
145         */
146        @NonNull
147        public static Builder with(@NonNull DataSource dataSource, @NonNull DatabaseType databaseType) {
148                return new Builder(dataSource, databaseType);
149        }
150
151        /**
152         * Gets a reference to the current transaction, if any.
153         *
154         * @return the current transaction
155         */
156        @NonNull
157        public Optional<Transaction> currentTransaction() {
158                return this.transactions.currentTransaction();
159        }
160
161        @NonNull
162        Optional<Transaction> currentTransactionForDatabaseOperation() {
163                return this.transactions.currentTransactionForDatabaseOperation();
164        }
165
166        static boolean hasAmbientTransaction() {
167                return TransactionCoordinator.hasAmbientTransaction();
168        }
169
170        /**
171         * Performs an operation transactionally.
172         * <p>
173         * The transaction will be automatically rolled back if an exception bubbles out of {@code transactionalOperation}.
174         * <p>
175         * Nested calls to {@code transaction(...)} are independent transactions with independent JDBC connections; they do
176         * not automatically join an outer transaction. Use {@link #participate(Transaction, TransactionalOperation)} to join an
177         * existing transaction explicitly. A transaction is scoped to the {@link DataSource} instance that created it; a
178         * {@link Database} using a different {@link DataSource} fails fast instead of joining it.
179         *
180         * @param transactionalOperation the operation to perform transactionally
181         */
182        public void transaction(@NonNull TransactionalOperation transactionalOperation) {
183                requireNonNull(transactionalOperation);
184
185                transaction(() -> {
186                        transactionalOperation.perform();
187                        return Optional.empty();
188                });
189        }
190
191        /**
192         * Performs an operation transactionally with the given options.
193         * <p>
194         * The transaction will be automatically rolled back if an exception bubbles out of {@code transactionalOperation}.
195         * <p>
196         * Nested calls to {@code transaction(...)} are independent transactions with independent JDBC connections; they do
197         * not automatically join an outer transaction. Use {@link #participate(Transaction, TransactionalOperation)} to join an
198         * existing transaction explicitly. A transaction is scoped to the {@link DataSource} instance that created it; a
199         * {@link Database} using a different {@link DataSource} fails fast instead of joining it.
200         *
201         * @param transactionOptions     options to apply to the transaction
202         * @param transactionalOperation the operation to perform transactionally
203         * @since 4.2.0
204         */
205        public void transaction(@NonNull TransactionOptions transactionOptions,
206                                                                                                        @NonNull TransactionalOperation transactionalOperation) {
207                requireNonNull(transactionOptions);
208                requireNonNull(transactionalOperation);
209
210                transaction(transactionOptions, () -> {
211                        transactionalOperation.perform();
212                        return Optional.empty();
213                });
214        }
215
216        /**
217         * Performs an operation transactionally and optionally returns a value.
218         * <p>
219         * The transaction will be automatically rolled back if an exception bubbles out of {@code transactionalOperation}.
220         * <p>
221         * Nested calls to {@code transaction(...)} are independent transactions with independent JDBC connections; they do
222         * not automatically join an outer transaction. Use {@link #participate(Transaction, ReturningTransactionalOperation)} to
223         * join an existing transaction explicitly. A transaction is scoped to the {@link DataSource} instance that created it; a
224         * {@link Database} using a different {@link DataSource} fails fast instead of joining it.
225         *
226         * @param transactionalOperation the operation to perform transactionally
227         * @param <T>                    the type to be returned
228         * @return the result of the transactional operation
229         */
230        @NonNull
231        public <T> Optional<T> transaction(@NonNull ReturningTransactionalOperation<T> transactionalOperation) {
232                requireNonNull(transactionalOperation);
233                return transaction(TransactionOptions.DEFAULT, transactionalOperation);
234        }
235
236        /**
237         * Performs an operation transactionally with the given options, optionally returning a value.
238         * <p>
239         * The transaction will be automatically rolled back if an exception bubbles out of {@code transactionalOperation}.
240         * <p>
241         * Nested calls to {@code transaction(...)} are independent transactions with independent JDBC connections; they do
242         * not automatically join an outer transaction. Use {@link #participate(Transaction, ReturningTransactionalOperation)} to
243         * join an existing transaction explicitly. A transaction is scoped to the {@link DataSource} instance that created it; a
244         * {@link Database} using a different {@link DataSource} fails fast instead of joining it.
245         *
246         * @param transactionOptions     options to apply to the transaction
247         * @param transactionalOperation the operation to perform transactionally
248         * @param <T>                    the type to be returned
249         * @return the result of the transactional operation
250         * @since 4.2.0
251         */
252        @NonNull
253        public <T> Optional<T> transaction(@NonNull TransactionOptions transactionOptions,
254                                                                                                                                                 @NonNull ReturningTransactionalOperation<T> transactionalOperation) {
255                return this.transactions.run(transactionOptions, transactionalOperation);
256        }
257
258        /**
259         * Performs an operation transactionally, retrying according to the given retry policy.
260         * <p>
261         * The entire transaction closure may run more than once. Keep non-idempotent external side effects outside the closure
262         * unless they are safe to repeat.
263         * <p>
264         * Pyranid consults the retry policy only when replay is known to be safe. This includes a failure before a physical
265         * transaction becomes available to application work, even if best-effort rollback of that begin candidate fails, because
266         * the candidate is discarded. It also includes a recognized serialization failure reported by physical commit when the
267         * follow-up rollback succeeds. Other commit failures and rollback failures after application work could execute are
268         * terminal because their outcome is indeterminate.
269         * <p>
270         * A savepoint-scope ownership conflict at completion is never replayed, with or without a successful rollback: the
271         * guard raises an {@link IllegalStateException}, not a {@link DatabaseException}, so the conflict leaves this method
272         * without reaching the retry policy. What a successful rollback changes is the attempt's outcome evidence; the conflict
273         * no longer records an indeterminate outcome, so a conflict raised and handled by an independent transaction inside the
274         * closure no longer fences a retry of a later failure.
275         * <p>
276         * Unlike {@link #transaction(TransactionalOperation)} and related transaction methods, retrying methods return
277         * {@link TransactionRetryResult} so callers can inspect failures that were recovered before success.
278         * <p>
279         * This method rejects a call when the innermost ambient transaction on the calling thread uses the same
280         * {@link DataSource} instance, including across different {@code Database} wrappers. Only that innermost transaction
281         * is checked. If a transaction on another source hides an older matching transaction, this method starts an independent
282         * retrying transaction; retries do not restart either surrounding transaction.
283         *
284         * @param retryPolicy           retry policy to apply
285         * @param transactionalOperation the operation to perform transactionally
286         * @return retry result containing any failures retried before success
287         * @since 4.4.0
288         */
289        @NonNull
290        public TransactionRetryResult<Void> transactionWithRetry(@NonNull RetryPolicy retryPolicy,
291                                                                                                                                                                                                                                        @NonNull TransactionalOperation transactionalOperation) {
292                requireNonNull(retryPolicy);
293                requireNonNull(transactionalOperation);
294
295                return transactionWithRetry(retryPolicy, () -> {
296                        transactionalOperation.perform();
297                        return Optional.<Void>empty();
298                });
299        }
300
301        /**
302         * Performs an operation transactionally with the given options, retrying according to the given retry policy.
303         * <p>
304         * The entire transaction closure may run more than once. Keep non-idempotent external side effects outside the closure
305         * unless they are safe to repeat.
306         * <p>
307         * Pyranid consults the retry policy only when replay is known to be safe. This includes a failure before a physical
308         * transaction becomes available to application work, even if best-effort rollback of that begin candidate fails, because
309         * the candidate is discarded. It also includes a recognized serialization failure reported by physical commit when the
310         * follow-up rollback succeeds. Other commit failures and rollback failures after application work could execute are
311         * terminal because their outcome is indeterminate.
312         * <p>
313         * A savepoint-scope ownership conflict at completion is never replayed, with or without a successful rollback: the
314         * guard raises an {@link IllegalStateException}, not a {@link DatabaseException}, so the conflict leaves this method
315         * without reaching the retry policy. What a successful rollback changes is the attempt's outcome evidence; the conflict
316         * no longer records an indeterminate outcome, so a conflict raised and handled by an independent transaction inside the
317         * closure no longer fences a retry of a later failure.
318         * <p>
319         * Unlike {@link #transaction(TransactionOptions, TransactionalOperation)} and related transaction methods, retrying
320         * methods return {@link TransactionRetryResult} so callers can inspect failures that were recovered before success.
321         * <p>
322         * This method rejects a call when the innermost ambient transaction on the calling thread uses the same
323         * {@link DataSource} instance, including across different {@code Database} wrappers. Only that innermost transaction
324         * is checked. If a transaction on another source hides an older matching transaction, this method starts an independent
325         * retrying transaction; retries do not restart either surrounding transaction.
326         *
327         * @param retryPolicy           retry policy to apply
328         * @param transactionOptions     options to apply to each transaction attempt
329         * @param transactionalOperation the operation to perform transactionally
330         * @return retry result containing any failures retried before success
331         * @since 4.4.0
332         */
333        @NonNull
334        public TransactionRetryResult<Void> transactionWithRetry(@NonNull RetryPolicy retryPolicy,
335                                                                                                                                                                                                                                        @NonNull TransactionOptions transactionOptions,
336                                                                                                                                                                                                                                        @NonNull TransactionalOperation transactionalOperation) {
337                requireNonNull(retryPolicy);
338                requireNonNull(transactionOptions);
339                requireNonNull(transactionalOperation);
340
341                return transactionWithRetry(retryPolicy, transactionOptions, () -> {
342                        transactionalOperation.perform();
343                        return Optional.<Void>empty();
344                });
345        }
346
347        /**
348         * Performs an operation transactionally and optionally returns a value, retrying according to the given retry policy.
349         * <p>
350         * The entire transaction closure may run more than once. Keep non-idempotent external side effects outside the closure
351         * unless they are safe to repeat.
352         * <p>
353         * Pyranid consults the retry policy only when replay is known to be safe. This includes a failure before a physical
354         * transaction becomes available to application work, even if best-effort rollback of that begin candidate fails, because
355         * the candidate is discarded. It also includes a recognized serialization failure reported by physical commit when the
356         * follow-up rollback succeeds. Other commit failures and rollback failures after application work could execute are
357         * terminal because their outcome is indeterminate.
358         * <p>
359         * A savepoint-scope ownership conflict at completion is never replayed, with or without a successful rollback: the
360         * guard raises an {@link IllegalStateException}, not a {@link DatabaseException}, so the conflict leaves this method
361         * without reaching the retry policy. What a successful rollback changes is the attempt's outcome evidence; the conflict
362         * no longer records an indeterminate outcome, so a conflict raised and handled by an independent transaction inside the
363         * closure no longer fences a retry of a later failure.
364         * <p>
365         * Unlike {@link #transaction(ReturningTransactionalOperation)} and related transaction methods, retrying methods return
366         * {@link TransactionRetryResult} so callers can inspect failures that were recovered before success.
367         * <p>
368         * This method rejects a call when the innermost ambient transaction on the calling thread uses the same
369         * {@link DataSource} instance, including across different {@code Database} wrappers. Only that innermost transaction
370         * is checked. If a transaction on another source hides an older matching transaction, this method starts an independent
371         * retrying transaction; retries do not restart either surrounding transaction.
372         *
373         * @param retryPolicy           retry policy to apply
374         * @param transactionalOperation the operation to perform transactionally
375         * @param <T>                    the type to be returned
376         * @return retry result containing the successful transaction value and any failures retried before success
377         * @since 4.4.0
378         */
379        @NonNull
380        public <T> TransactionRetryResult<T> transactionWithRetry(@NonNull RetryPolicy retryPolicy,
381                                                                                                                                                                                                                                         @NonNull ReturningTransactionalOperation<T> transactionalOperation) {
382                requireNonNull(retryPolicy);
383                requireNonNull(transactionalOperation);
384
385                return transactionWithRetry(retryPolicy, TransactionOptions.DEFAULT, transactionalOperation);
386        }
387
388        /**
389         * Performs an operation transactionally with the given options and optionally returns a value, retrying according to the
390         * given retry policy.
391         * <p>
392         * The entire transaction closure may run more than once. Keep non-idempotent external side effects outside the closure
393         * unless they are safe to repeat.
394         * <p>
395         * Pyranid consults the retry policy only when replay is known to be safe. This includes a failure before a physical
396         * transaction becomes available to application work, even if best-effort rollback of that begin candidate fails, because
397         * the candidate is discarded. It also includes a recognized serialization failure reported by physical commit when the
398         * follow-up rollback succeeds. Other commit failures and rollback failures after application work could execute are
399         * terminal because their outcome is indeterminate.
400         * <p>
401         * A savepoint-scope ownership conflict at completion is never replayed, with or without a successful rollback: the
402         * guard raises an {@link IllegalStateException}, not a {@link DatabaseException}, so the conflict leaves this method
403         * without reaching the retry policy. What a successful rollback changes is the attempt's outcome evidence; the conflict
404         * no longer records an indeterminate outcome, so a conflict raised and handled by an independent transaction inside the
405         * closure no longer fences a retry of a later failure.
406         * <p>
407         * Unlike {@link #transaction(TransactionOptions, ReturningTransactionalOperation)} and related transaction methods,
408         * retrying methods return {@link TransactionRetryResult} so callers can inspect failures that were recovered before
409         * success.
410         * <p>
411         * This method rejects a call when the innermost ambient transaction on the calling thread uses the same
412         * {@link DataSource} instance, including across different {@code Database} wrappers. Only that innermost transaction
413         * is checked. If a transaction on another source hides an older matching transaction, this method starts an independent
414         * retrying transaction; retries do not restart either surrounding transaction.
415         *
416         * @param retryPolicy           retry policy to apply
417         * @param transactionOptions     options to apply to each transaction attempt
418         * @param transactionalOperation the operation to perform transactionally
419         * @param <T>                    the type to be returned
420         * @return retry result containing the successful transaction value and any failures retried before success
421         * @since 4.4.0
422         */
423        @NonNull
424        public <T> TransactionRetryResult<T> transactionWithRetry(@NonNull RetryPolicy retryPolicy,
425                                                                                                                                                                                                                                         @NonNull TransactionOptions transactionOptions,
426                                                                                                                                                                                                                                         @NonNull ReturningTransactionalOperation<T> transactionalOperation) {
427                requireNonNull(retryPolicy);
428                requireNonNull(transactionOptions);
429                requireNonNull(transactionalOperation);
430
431                if (currentTransaction().isPresent())
432                        throw new IllegalStateException("transactionWithRetry must not be called within an existing transaction");
433
434                return RetryExecutor.DEFAULT.execute(retryPolicy, () -> transaction(transactionOptions, transactionalOperation));
435        }
436
437        static void closeConnection(@NonNull Connection connection, @NonNull DatabaseDialect databaseDialect) {
438                requireNonNull(connection);
439                requireNonNull(databaseDialect);
440
441                try {
442                        connection.close();
443                } catch (SQLException e) {
444                        throw DatabaseException.wrapSafely("Unable to close database connection", e, databaseDialect);
445                }
446        }
447
448        @NonNull
449        static DatabaseException databaseExceptionWithStatementContext(@NonNull StatementContext<?> statementContext,
450                                                                                                                                                                                                                                                        @NonNull Throwable cause) {
451                requireNonNull(statementContext);
452                requireNonNull(cause);
453
454                if (cause instanceof DatabaseException databaseException && databaseException.areStatementDiagnosticsApplied())
455                        return databaseException;
456
457                String message = DatabaseException.messageSafely(cause, "Database operation failed");
458
459                if (cause instanceof DatabaseException databaseException) {
460                        SecureParameterSupport.DiagnosticScrub diagnosticScrub = diagnosticScrubForStatementContext(statementContext);
461
462                        if (diagnosticScrub.redactor() == null && diagnosticScrub.needleRenderingFailureDescriptions().isEmpty())
463                                return databaseException;
464
465                        return databaseExceptionWithStatementContext(statementContext, message, cause, diagnosticScrub);
466                }
467
468                return databaseExceptionWithStatementContext(statementContext, message, cause);
469        }
470
471        @NonNull
472        static DatabaseException databaseExceptionWithStatementContext(@NonNull StatementContext<?> statementContext,
473                                                                                                                                                                                                                                                                                                @NonNull String message,
474                                                                                                                                                                                                                                                                                                @NonNull Throwable cause) {
475                requireNonNull(statementContext);
476                requireNonNull(message);
477                requireNonNull(cause);
478
479                SecureParameterSupport.DiagnosticScrub diagnosticScrub = diagnosticScrubForStatementContext(statementContext);
480                return databaseExceptionWithStatementContext(statementContext, message, cause, diagnosticScrub);
481        }
482
483        @NonNull
484        static DatabaseException databaseExceptionWithStatementContext(@NonNull StatementContext<?> statementContext,
485                                                                                                                                                                                                                                                        @NonNull String message,
486                                                                                                                                                                                                                                                        @NonNull Throwable cause,
487                                                                                                                                                                                                                                                        SecureParameterSupport.@NonNull DiagnosticScrub diagnosticScrub) {
488                requireNonNull(statementContext);
489                requireNonNull(message);
490                requireNonNull(cause);
491                requireNonNull(diagnosticScrub);
492
493                UnaryOperator<String> diagnosticRedactor = diagnosticScrub.redactor();
494
495                // Scrub the RAW message before whitespace-collapsing/truncation: bounding first would break needles
496                // containing consecutive whitespace and can cut a secret mid-way, leaving an unmatchable prefix
497                String scrubbedMessage;
498                try {
499                        scrubbedMessage = diagnosticRedactor == null ? message : diagnosticRedactor.apply(message);
500                } catch (Throwable diagnosticFailure) {
501                        if (cause != diagnosticFailure) cause.addSuppressed(diagnosticFailure);
502                        // A failed redactor must not expose the unredacted driver text as the replacement message.
503                        scrubbedMessage = "Database operation failed";
504                }
505
506                StatementDiagnostic statementDiagnostic = statementDiagnostic(statementContext);
507                String diagnosticMessage = format("%s [%s]",
508                                boundedDiagnosticMessage(scrubbedMessage), statementDiagnostic.diagnostic());
509                DatabaseException databaseException = DatabaseException.wrapSafely(diagnosticMessage, cause,
510                                statementContext.getDatabaseDialect(), diagnosticRedactor);
511                databaseException.markStatementDiagnosticsApplied();
512
513                if (statementDiagnostic.parameterRenderingFailure() != null)
514                        databaseException.addSuppressed(statementDiagnostic.parameterRenderingFailure());
515
516                for (String failureDescription : diagnosticScrub.needleRenderingFailureDescriptions())
517                        databaseException.addSuppressed(new RuntimeException(format(
518                                        "Unable to render a secure parameter for diagnostic scrubbing: %s", failureDescription)));
519
520                return databaseException;
521        }
522
523        private static SecureParameterSupport.@NonNull DiagnosticScrub diagnosticScrubForStatementContext(@NonNull StatementContext<?> statementContext) {
524                requireNonNull(statementContext);
525
526                try {
527                        return SecureParameterSupport.diagnosticScrubForParameters(statementContext.getParameters());
528                } catch (Throwable ignored) {
529                        // Per-needle failures are handled inside diagnosticScrubForParameters; reaching here would be
530                        // catastrophic (a plain getter throwing) - fall back to no scrubbing rather than losing the exception
531                        return SecureParameterSupport.DiagnosticScrub.NONE;
532                }
533        }
534
535        @NonNull
536        private DatabaseException databaseExceptionWithRawConnectionContext(@NonNull Exception cause) {
537                requireNonNull(cause);
538                return DatabaseException.wrapSafely(DatabaseException.messageSafely(cause, "Database operation failed"),
539                                cause, this.databaseDialect);
540        }
541
542        @NonNull
543        private static StatementDiagnostic statementDiagnostic(@NonNull StatementContext<?> statementContext) {
544                requireNonNull(statementContext);
545
546                Statement statement = statementContext.getStatement();
547                String parameters;
548                Throwable parameterRenderingFailure = null;
549
550                try {
551                        parameters = boundedDiagnosticParameters(statementContext.getRedactedParameters());
552                } catch (Throwable t) {
553                        parameters = "<unavailable>";
554                        parameterRenderingFailure = t;
555                }
556
557                String diagnostic = format("statementId=%s, sql=%s, parameters=%s",
558                                statement.getId(), boundedSql(statement.getSql()), parameters);
559
560                if (parameterRenderingFailure != null)
561                        diagnostic = format("%s, parameterRenderingFailure=%s", diagnostic,
562                                        boundedDiagnosticParameter(parameterRenderingFailureDiagnostic(parameterRenderingFailure)));
563
564                return new StatementDiagnostic(diagnostic, parameterRenderingFailure);
565        }
566
567        private record StatementDiagnostic(@NonNull String diagnostic,
568                                                                                                                                                 @Nullable Throwable parameterRenderingFailure) {}
569
570        @NonNull
571        private static String parameterRenderingFailureDiagnostic(@NonNull Throwable parameterRenderingFailure) {
572                requireNonNull(parameterRenderingFailure);
573
574                String message;
575
576                try {
577                        message = parameterRenderingFailure.getMessage();
578                } catch (Throwable ignored) {
579                        message = null;
580                }
581
582                if (message == null || message.trim().length() == 0)
583                        return parameterRenderingFailure.getClass().getName();
584
585                return format("%s: %s", parameterRenderingFailure.getClass().getName(), message);
586        }
587
588        @Nullable
589        static Long sumBatchUpdateCounts(@NonNull List<Long> updateCounts) {
590                requireNonNull(updateCounts);
591
592                long total = 0L;
593
594                for (Long updateCount : updateCounts) {
595                        if (updateCount == null || updateCount < 0L)
596                                return null;
597
598                        try {
599                                total = Math.addExact(total, updateCount);
600                        } catch (ArithmeticException e) {
601                                return null;
602                        }
603                }
604
605                return total;
606        }
607
608        @NonNull
609        private static String boundedDiagnosticMessage(@NonNull String message) {
610                requireNonNull(message);
611
612                String compactMessage = DIAGNOSTIC_WHITESPACE_PATTERN.matcher(message).replaceAll(" ").trim();
613
614                if (compactMessage.length() <= MAX_DIAGNOSTIC_MESSAGE_LENGTH)
615                        return compactMessage;
616
617                int prefixLength = Math.max(0, MAX_DIAGNOSTIC_MESSAGE_LENGTH - TRUNCATED_SUFFIX.length());
618                return compactMessage.substring(0, prefixLength) + TRUNCATED_SUFFIX;
619        }
620
621        @NonNull
622        private static String boundedSql(@NonNull String sql) {
623                requireNonNull(sql);
624
625                String compactSql = DIAGNOSTIC_WHITESPACE_PATTERN.matcher(sql).replaceAll(" ").trim();
626
627                if (compactSql.length() <= MAX_DIAGNOSTIC_SQL_LENGTH)
628                        return compactSql;
629
630                int prefixLength = Math.max(0, MAX_DIAGNOSTIC_SQL_LENGTH - TRUNCATED_SUFFIX.length());
631                return compactSql.substring(0, prefixLength) + TRUNCATED_SUFFIX;
632        }
633
634        @NonNull
635        private static String boundedDiagnosticParameters(@NonNull List<@Nullable Object> parameters) {
636                requireNonNull(parameters);
637
638                StringBuilder parametersBuilder = new StringBuilder("[");
639
640                for (int i = 0; i < parameters.size(); ++i) {
641                        String separator = i == 0 ? "" : ", ";
642                        String renderedParameter = boundedDiagnosticParameter(parameters.get(i));
643                        int requiredLength = parametersBuilder.length() + separator.length() + renderedParameter.length() + 1;
644
645                        if (requiredLength > MAX_DIAGNOSTIC_PARAMETERS_LENGTH) {
646                                int availableLength = MAX_DIAGNOSTIC_PARAMETERS_LENGTH
647                                                - parametersBuilder.length()
648                                                - separator.length()
649                                                - TRUNCATED_SUFFIX.length()
650                                                - 1;
651
652                                parametersBuilder.append(separator);
653
654                                if (availableLength > 0)
655                                        parametersBuilder.append(renderedParameter, 0, Math.min(availableLength, renderedParameter.length()));
656
657                                parametersBuilder.append(TRUNCATED_SUFFIX).append(']');
658                                if (parametersBuilder.length() > MAX_DIAGNOSTIC_PARAMETERS_LENGTH) {
659                                        parametersBuilder.setLength(MAX_DIAGNOSTIC_PARAMETERS_LENGTH - 1);
660                                        parametersBuilder.append(']');
661                                }
662                                return parametersBuilder.toString();
663                        }
664
665                        parametersBuilder.append(separator).append(renderedParameter);
666                }
667
668                parametersBuilder.append(']');
669                return parametersBuilder.toString();
670        }
671
672        @NonNull
673        private static String boundedDiagnosticParameter(@Nullable Object parameter) {
674                String renderedParameter = String.valueOf(parameter);
675                String compactParameter = DIAGNOSTIC_WHITESPACE_PATTERN.matcher(renderedParameter).replaceAll(" ").trim();
676
677                if (compactParameter.length() <= MAX_DIAGNOSTIC_PARAMETER_LENGTH)
678                        return compactParameter;
679
680                int prefixLength = Math.max(0, MAX_DIAGNOSTIC_PARAMETER_LENGTH - TRUNCATED_SUFFIX.length());
681                return compactParameter.substring(0, prefixLength) + TRUNCATED_SUFFIX;
682        }
683
684        /**
685         * Performs an operation in the context of a pre-existing transaction.
686         * <p>
687         * No commit or rollback on the transaction will occur when {@code transactionalOperation} completes.
688         * <p>
689         * However, if an exception bubbles out of {@code transactionalOperation}, the transaction will be marked as rollback-only.
690         * <p>
691         * The transaction must have been created by this {@link Database}, or by another {@link Database} using the same
692         * {@link DataSource} instance.
693         * <p>
694         * If this thread is interrupted while waiting for another participant to release the transaction connection, Pyranid
695         * restores the interrupt flag and throws {@link DatabaseException}.
696         *
697         * @param transaction            the transaction in which to participate
698         * @param transactionalOperation the operation that should participate in the transaction
699         */
700        public void participate(@NonNull Transaction transaction,
701                                                                                                        @NonNull TransactionalOperation transactionalOperation) {
702                requireNonNull(transaction);
703                requireNonNull(transactionalOperation);
704
705                participate(transaction, () -> {
706                        transactionalOperation.perform();
707                        return Optional.empty();
708                });
709        }
710
711        /**
712         * Performs an operation in the context of a pre-existing transaction, optionally returning a value.
713         * <p>
714         * No commit or rollback on the transaction will occur when {@code transactionalOperation} completes.
715         * <p>
716         * However, if an exception bubbles out of {@code transactionalOperation}, the transaction will be marked as rollback-only.
717         * <p>
718         * The transaction must have been created by this {@link Database}, or by another {@link Database} using the same
719         * {@link DataSource} instance.
720         * <p>
721         * If this thread is interrupted while waiting for another participant to release the transaction connection, Pyranid
722         * restores the interrupt flag and throws {@link DatabaseException}.
723         *
724         * @param transaction            the transaction in which to participate
725         * @param transactionalOperation the operation that should participate in the transaction
726         * @param <T>                    the type to be returned
727         * @return the result of the transactional operation
728         */
729        @NonNull
730        public <T> Optional<T> participate(@NonNull Transaction transaction,
731                                                                                                                                                 @NonNull ReturningTransactionalOperation<T> transactionalOperation) {
732                return this.transactions.participate(transaction, transactionalOperation);
733        }
734
735        /**
736         * Creates a fluent builder for executing SQL.
737         * <p>
738         * Named parameters use the {@code :paramName} syntax and are bound via {@link Query#bind(String, Object)}.
739         * Positional parameters via {@code ?} are not supported.
740         * Pyranid ignores parameter-looking text inside SQL string literals, quoted identifiers, comments, PostgreSQL
741         * dollar-quoted strings, and SQL Server-style bracket-quoted identifiers. PostgreSQL JSONB/hstore {@code ?},
742         * {@code ?|}, and {@code ?&} operators are supported; when running against {@link DatabaseType#POSTGRESQL}, Pyranid
743         * emits pgjdbc's escaped {@code ??} form automatically. Unterminated quotes and comments fail fast.
744         * <p>
745         * Example:
746         * <pre>{@code
747         * Optional<Employee> employee = database.query("SELECT * FROM employee WHERE id = :id")
748         *   .bind("id", 42)
749         *   .fetchObject(Employee.class);
750         * }</pre>
751         *
752         * @param sql SQL containing {@code :paramName} placeholders
753         * @return a fluent builder for binding parameters and executing
754         * @since 4.0.0
755         */
756        @NonNull
757        public Query query(@NonNull String sql) {
758                requireNonNull(sql);
759                return new DefaultQuery(this, sql, this.sqlCompiler, this.statementExecutor);
760        }
761
762        /**
763         * Performs a portable connectivity check using JDBC {@link Connection#isValid(int)}.
764         * <p>
765         * This method borrows a fresh connection from this database's {@link DataSource}, calls
766         * {@link Connection#isValid(int)}, and closes the connection before returning. It does <strong>not</strong>
767         * participate in an active Pyranid transaction, if one exists.
768         * <p>
769         * JDBC accepts timeout values in whole seconds. Positive sub-second durations are rounded up to one second;
770         * {@link Duration#ZERO} passes a timeout of {@code 0} to the driver.
771         * <p>
772         * If the failure this method propagates carries evidence of thread interruption, Pyranid restores the interrupt
773         * flag before throwing, as long as no Pyranid transaction is open on the calling thread. Inside a transaction this
774         * method restores nothing, and nothing restores the flag for a failure the caller handles there. A caller that
775         * handles one of these failures inside a transaction and wants to act on the cancellation must call
776         * {@link Thread#interrupt()} itself.
777         *
778         * @param timeout maximum time to wait for driver validation
779         * @throws IllegalArgumentException if {@code timeout} is negative or too large for JDBC's integer-second timeout
780         * @throws DatabaseException if connection acquisition fails, validation throws, or the driver reports the
781         *                           connection is not valid
782         * @since 4.2.0
783         */
784        public void performHealthCheck(@NonNull Duration timeout) {
785                requireNonNull(timeout);
786                int timeoutSeconds = healthCheckTimeoutSeconds(timeout);
787
788                performRawConnectionOperation(connection -> {
789                        boolean valid;
790
791                        try {
792                                valid = connection.isValid(timeoutSeconds);
793                        } catch (SQLException e) {
794                                throw DatabaseException.wrapSafely("Unable to perform database health check", e,
795                                                this.databaseDialect);
796                        }
797
798                        if (!valid)
799                                throw new DatabaseException("Database health check failed: connection is not valid");
800
801                        return Optional.empty();
802                }, false);
803        }
804
805        static void restoreInterruptIfNeeded(@NonNull Throwable throwable) {
806                requireNonNull(throwable);
807                // Failure inspection is best effort; it must never prevent cleanup or replace the primary exception.
808                if (ThrowableGraph.inspect(throwable, current -> current instanceof InterruptedException ? 1 : 0, 0) != 0)
809                        Thread.currentThread().interrupt();
810        }
811
812        static void lockInterruptibly(@NonNull ReentrantLock lock,
813                                                                                                                                                                @NonNull String operation) {
814                requireNonNull(lock);
815                requireNonNull(operation);
816
817                try {
818                        lock.lockInterruptibly();
819                } catch (InterruptedException e) {
820                        Thread.currentThread().interrupt();
821                        throw new DatabaseException(format("Interrupted while waiting to %s", operation), e);
822                }
823        }
824
825        @Nullable
826        static Throwable closeStatementContextResources(@NonNull StatementContext<?> statementContext,
827                                                                                                                                                                                                                                        @Nullable Throwable cleanupFailure) {
828                requireNonNull(statementContext);
829
830                Queue<AutoCloseable> cleanupOperations = statementContext.getCleanupOperations();
831                AutoCloseable cleanupOperation;
832
833                while ((cleanupOperation = cleanupOperations.poll()) != null) {
834                        try {
835                                cleanupOperation.close();
836                        } catch (Throwable cleanupException) {
837                                cleanupFailure = appendCleanupFailure(cleanupFailure, cleanupException);
838                        }
839                }
840
841                return cleanupFailure;
842        }
843
844        /** Keeps the first failure while allowing cleanup to continue when a driver reuses an exception instance. */
845        @NonNull
846        static Throwable appendCleanupFailure(@Nullable Throwable existing, @NonNull Throwable additional) {
847                requireNonNull(additional);
848                if (existing == null)
849                        return additional;
850                if (existing != additional)
851                        existing.addSuppressed(additional);
852                return existing;
853        }
854
855        static boolean isUnsupportedSqlFeature(@NonNull SQLException e) {
856                requireNonNull(e);
857
858                try {
859                        String sqlState = e.getSQLState();
860                        if (sqlState != null && (sqlState.startsWith("0A") || "HYC00".equals(sqlState)))
861                                return true;
862
863                        Throwable cause = e.getCause();
864                        if (cause instanceof SQLFeatureNotSupportedException
865                                        || cause instanceof UnsupportedOperationException
866                                        || cause instanceof AbstractMethodError)
867                                return true;
868
869                        String message = e.getMessage();
870                        if (message == null)
871                                return false;
872
873                        String lower = message.toLowerCase(Locale.ROOT);
874                        return lower.contains("not supported")
875                                        || lower.contains("unsupported")
876                                        || lower.contains("not implemented")
877                                        || lower.contains("feature not supported");
878                } catch (Throwable diagnosticFailure) {
879                        if (e != diagnosticFailure) e.addSuppressed(diagnosticFailure);
880                        // Unreadable capability evidence cannot authorize executing the statement again.
881                        return false;
882                }
883        }
884
885        @Nullable
886        static Duration validateQueryTimeout(@Nullable Duration queryTimeout) {
887                if (queryTimeout != null) {
888                        if (queryTimeout.isNegative())
889                                throw new IllegalArgumentException("queryTimeout must be >= 0");
890
891                        queryTimeoutSeconds(queryTimeout);
892                }
893
894                return queryTimeout;
895        }
896
897        @Nullable
898        static Integer validateNonNegativeStatementSetting(@NonNull String name,
899                                                                                                                                                                                                                                                 @Nullable Integer value) {
900                requireNonNull(name);
901
902                if (value != null && value < 0)
903                        throw new IllegalArgumentException(format("%s must be >= 0", name));
904
905                return value;
906        }
907
908        @Nullable
909        static Integer validatePositiveQuerySetting(@NonNull String name,
910                                                                                                                                                                                                                 @Nullable Integer value) {
911                requireNonNull(name);
912
913                if (value != null && value <= 0)
914                        throw new IllegalArgumentException(format("%s must be > 0", name));
915
916                return value;
917        }
918
919        private static int queryTimeoutSeconds(@NonNull Duration queryTimeout) {
920                requireNonNull(queryTimeout);
921
922                long seconds = queryTimeout.getSeconds();
923
924                if (queryTimeout.getNano() > 0) {
925                        if (seconds == Long.MAX_VALUE)
926                                throw new IllegalArgumentException(format("queryTimeout must be <= %s seconds", Integer.MAX_VALUE));
927
928                        ++seconds;
929                }
930
931                if (seconds > Integer.MAX_VALUE)
932                        throw new IllegalArgumentException(format("queryTimeout must be <= %s seconds", Integer.MAX_VALUE));
933
934                return (int) seconds;
935        }
936
937        private static int healthCheckTimeoutSeconds(@NonNull Duration timeout) {
938                requireNonNull(timeout);
939
940                if (timeout.isNegative())
941                        throw new IllegalArgumentException("timeout must be >= 0");
942
943                long seconds = timeout.getSeconds();
944
945                if (timeout.getNano() > 0) {
946                        if (seconds == Long.MAX_VALUE)
947                                throw new IllegalArgumentException(format("timeout must be <= %s seconds", Integer.MAX_VALUE));
948
949                        ++seconds;
950                }
951
952                if (seconds > Integer.MAX_VALUE)
953                        throw new IllegalArgumentException(format("timeout must be <= %s seconds", Integer.MAX_VALUE));
954
955                return (int) seconds;
956        }
957
958        /**
959         * Exposes a temporary handle to JDBC {@link DatabaseMetaData}, which provides comprehensive vendor-specific information about this database as a whole.
960         * <p>
961         * This method acquires {@link DatabaseMetaData} on its own newly-borrowed connection, which it manages internally.
962         * <p>
963         * It does <strong>not</strong> participate in the active transaction, if one exists.
964         * <p>
965         * The connection is closed as soon as {@link DatabaseMetaDataReader#read(DatabaseMetaData)} completes.
966         * <p>
967         * See <a href="https://docs.oracle.com/en/java/javase/26/docs/api/java.sql/java/sql/DatabaseMetaData.html">{@code DatabaseMetaData} Javadoc</a> for details.
968         * <p>
969         * If the failure this method propagates carries evidence of thread interruption, Pyranid restores the interrupt
970         * flag before throwing, as long as no Pyranid transaction is open on the calling thread. Inside a transaction this
971         * method restores nothing, and nothing restores the flag for a failure the caller handles there. A caller that
972         * handles one of these failures inside a transaction and wants to act on the cancellation must call
973         * {@link Thread#interrupt()} itself.
974         */
975        public void readDatabaseMetaData(@NonNull DatabaseMetaDataReader databaseMetaDataReader) {
976                requireNonNull(databaseMetaDataReader);
977
978                performRawConnectionOperation((connection -> {
979                        databaseMetaDataReader.read(connection.getMetaData());
980                        return Optional.empty();
981                }), false);
982        }
983
984        /**
985         * Performs raw JDBC work with a Pyranid-managed {@link Connection}.
986         * <p>
987         * If called inside a Pyranid transaction, this operation uses the transaction's connection and participates in that
988         * transaction. Otherwise, Pyranid borrows a connection for the duration of the callback and closes it afterwards.
989         * <p>
990         * The {@link Connection} passed to {@code rawConnectionOperation} is a guarded handle. Normal JDBC operations are
991         * delegated to the underlying driver connection, but lifecycle, transaction-management, and connection-wide state
992         * methods such as
993         * {@link Connection#close()}, {@link Connection#commit()}, {@link Connection#rollback()},
994         * {@link Connection#setAutoCommit(boolean)}, {@link Connection#setCatalog(String)}, {@link Connection#setSchema(String)}, and
995         * {@link Connection#setNetworkTimeout(java.util.concurrent.Executor, int)} throw {@link IllegalStateException}. Use
996         * Pyranid transaction APIs instead.
997         * {@link Connection#unwrap(Class)} may return a guarded, callback-scoped proxy for a vendor interface, but never a
998         * castable physical {@link Connection}; the proxy blocks lifecycle methods and expires with the callback.
999         * JDBC objects created from this handle are also guarded: {@link java.sql.Statement#getConnection()} and
1000         * {@link java.sql.DatabaseMetaData#getConnection()} return the Pyranid-managed handle, and
1001         * {@link ResultSet#getStatement()} returns a guarded statement. Guarded statements, resultsets, and metadata refuse
1002         * driver-specific {@code unwrap(...)} calls that could expose the driver's underlying connection.
1003         * <p>
1004         * The connection handle is valid only for the duration of the callback. Do not close it, retain it, or use it after this
1005         * method returns.
1006         * <p>
1007         * If the failure this method propagates carries evidence of thread interruption, Pyranid restores the interrupt
1008         * flag before throwing, as long as no Pyranid transaction is open on the calling thread. Inside a transaction this
1009         * method restores nothing, and nothing restores the flag for a failure the caller handles there. A caller that
1010         * handles one of these failures inside a transaction and wants to act on the cancellation must call
1011         * {@link Thread#interrupt()} itself.
1012         *
1013         * @param rawConnectionOperation the raw JDBC operation to perform
1014         * @param <T>                    the type to be returned
1015         * @return the operation result
1016         * @throws DatabaseException if connection acquisition, callback execution, or cleanup fails
1017         * @since 4.2.0
1018         */
1019        @NonNull
1020        public <T> Optional<T> useRawConnection(@NonNull RawConnectionOperation<Connection, T> rawConnectionOperation) {
1021                requireNonNull(rawConnectionOperation);
1022
1023                return performRawConnectionOperation(connection -> {
1024                        PyranidRawConnection rawConnection = new PyranidRawConnection(connection);
1025
1026                        try {
1027                                Optional<T> result = rawConnectionOperation.perform(rawConnection);
1028                                return result == null ? Optional.empty() : result;
1029                        } finally {
1030                                rawConnection.release();
1031                        }
1032                }, true);
1033        }
1034
1035        /**
1036         * Performs driver-specific raw JDBC work with a Pyranid-managed connection of the requested type.
1037         * <p>
1038         * If called inside a Pyranid transaction, this operation uses the transaction's connection. Standard JDBC work
1039         * participates in that transaction; driver-extension APIs may impose additional transaction-start or lifecycle rules.
1040         * Otherwise, Pyranid borrows a connection for the duration of the callback and closes it afterwards.
1041         * <p>
1042         * If the managed connection is already an instance of {@code connectionType}, it is passed directly to
1043         * {@code rawConnectionOperation}. Otherwise, Pyranid obtains the requested driver connection through
1044         * {@link Connection#unwrap(Class)}. Failure to obtain the requested type results in a {@link DatabaseException}.
1045         * <p>
1046         * <strong>Unlike {@link #useRawConnection(RawConnectionOperation)}, the connection supplied by this overload is
1047         * unguarded.</strong> This is necessary for concrete driver APIs, but it also means Pyranid cannot prevent operations
1048         * that interfere with its connection and transaction management. Guarding is selected by the overload, so the
1049         * connection remains unguarded when {@code connectionType} is {@code Connection.class}. Do not close the connection,
1050         * retain it, call
1051         * {@link Connection#commit()}, {@link Connection#rollback()}, or {@link Connection#setAutoCommit(boolean)}, or mutate
1052         * connection-wide state such as schema, catalog, client info, holdability, type map, or network timeout.
1053         * <p>
1054         * The connection and every object obtained from it are valid only for the duration of the callback. Statements, result
1055         * sets, appenders, readers, and other derived resources must be closed before this method returns. Retaining or using any
1056         * of these objects after the callback returns has undefined behavior.
1057         * <p>
1058         * If the failure this method propagates carries evidence of thread interruption, Pyranid restores the interrupt
1059         * flag before throwing, as long as no Pyranid transaction is open on the calling thread. Inside a transaction this
1060         * method restores nothing, and nothing restores the flag for a failure the caller handles there. A caller that
1061         * handles one of these failures inside a transaction and wants to act on the cancellation must call
1062         * {@link Thread#interrupt()} itself.
1063         *
1064         * @param connectionType         driver connection class or interface to expose
1065         * @param rawConnectionOperation the driver-specific raw JDBC operation to perform
1066         * @param <C>                    the requested connection type
1067         * @param <T>                    the type to be returned
1068         * @return the operation result
1069         * @throws DatabaseException if connection acquisition, unwrapping, callback execution, or cleanup fails
1070         * @since 4.7.0
1071         */
1072        @NonNull
1073        public <C extends Connection, T> Optional<T> useRawConnection(@NonNull Class<C> connectionType,
1074                                                                                                                                                                                                                        @NonNull RawConnectionOperation<? super C, T> rawConnectionOperation) {
1075                requireNonNull(connectionType);
1076                requireNonNull(rawConnectionOperation);
1077
1078                return performRawConnectionOperation(connection -> {
1079                        C rawConnection = connectionType.isInstance(connection)
1080                                        ? connectionType.cast(connection)
1081                                        : connection.unwrap(connectionType);
1082
1083                        if (rawConnection == null)
1084                                throw new SQLException(format("Connection.unwrap(%s) returned null", connectionType.getName()));
1085
1086                        Optional<T> result = rawConnectionOperation.perform(rawConnection);
1087                        return result == null ? Optional.empty() : result;
1088                }, true);
1089        }
1090
1091        /**
1092         * Performs an operation with one callback-scoped database-notification listener session.
1093         * <p>
1094         * This method is synchronous and blocking. It acquires at most one listener connection from this
1095         * {@code Database}'s configured {@link DataSource}, registers every requested channel, invokes {@code operation}
1096         * at most once, expires the supplied {@link NotificationSession}, and completes cleanup before returning or
1097         * throwing. It never reconnects.
1098         * <p>
1099         * The configured source must preserve one physical backend session for the entire checkout. For PostgreSQL,
1100         * direct connections and session pooling are suitable. Using PgBouncer transaction or statement pooling as the
1101         * listener source is unsupported: registration can appear to succeed before backend-session affinity is lost and
1102         * notification delivery silently stops. Pyranid does not inspect or validate proxy topology. Applications whose
1103         * ordinary source cannot provide the required affinity should construct a separate {@code Database} over a suitable
1104         * listener source and invoke this method on that instance.
1105         * <p>
1106         * Notifications are lossy hints. Durable applications should normally reconcile authoritative state as the first
1107         * callback action. The operation may use ordinary database methods, but those methods acquire or select their
1108         * connection normally and never reuse the listener connection. Pyranid transaction entry is interrupt-sensitive;
1109         * when a receive returns a batch with a racing interrupt, temporarily clear and remember the flag during bounded
1110         * reconciliation and restore it afterward as described by {@link NotificationSession#awaitNotifications(Duration)}.
1111         * <p>
1112         * A terminal receive failure is retained by the session and rethrown after cleanup even if {@code operation} catches
1113         * it and returns. A retained transport {@link Error} propagates as that exact, unwrapped instance. If the callback
1114         * instead throws a distinct {@code Error}, that callback error remains primary and the retained transport failure is
1115         * suppressed beneath it.
1116         *
1117         * @param channels  fixed, nonempty set of nonblank channels to register
1118         * @param operation operation to invoke after every channel has been registered
1119         * @throws NullPointerException if {@code channels}, a channel, or {@code operation} is null
1120         * @throws IllegalArgumentException if the set is empty or a channel violates common or backend-specific limits
1121         * @throws IllegalStateException if any Pyranid transaction is active on the calling thread
1122         * @throws InterruptedException if cooperative interruption wins after any required cleanup
1123         * @throws UnsupportedOperationException if notification listening is unavailable through the database dialect or
1124         * runtime driver, or is unsupported or disabled for the current server version or configuration
1125         * @throws DatabaseException if connection acquisition, setup, receive, callback execution, or cleanup fails
1126         * @since 4.6.0
1127         */
1128        public void withNotificationSession(@NonNull Set<@NonNull String> channels,
1129                                                                                                                                                        @NonNull NotificationSessionOperation operation)
1130                        throws InterruptedException {
1131                NotificationSessionRunner.run(this, channels, operation);
1132        }
1133
1134        /**
1135         * Performs an operation with one callback-scoped database-notification listener session for a single channel.
1136         * <p>
1137         * This is the single-channel convenience form of
1138         * {@link #withNotificationSession(Set, NotificationSessionOperation)}. The listener connection comes from this
1139         * {@code Database}'s configured {@link DataSource}, which must preserve physical backend-session affinity for the
1140         * entire checkout. For PostgreSQL, using PgBouncer transaction or statement pooling as the listener source is
1141         * unsupported: registration can appear to succeed before affinity is lost and delivery silently stops. Pyranid does
1142         * not inspect or validate proxy topology.
1143         * <p>
1144         * A terminal receive failure is retained and rethrown after cleanup even if {@code operation} catches it and returns.
1145         * A retained transport {@link Error} propagates as the exact, unwrapped instance unless a distinct callback
1146         * {@code Error} takes precedence as described by the set-based overload.
1147         *
1148         * @param channel   nonblank channel to register
1149         * @param operation operation to invoke after the channel has been registered
1150         * @throws NullPointerException if {@code channel} or {@code operation} is null
1151         * @throws IllegalArgumentException if the channel violates common or backend-specific limits
1152         * @throws IllegalStateException if any Pyranid transaction is active on the calling thread
1153         * @throws InterruptedException if cooperative interruption wins after any required cleanup
1154         * @throws UnsupportedOperationException if notification listening is unavailable through the database dialect or
1155         * runtime driver, or is unsupported or disabled for the current server version or configuration
1156         * @throws DatabaseException if connection acquisition, setup, receive, callback execution, or cleanup fails
1157         * @since 4.6.0
1158         */
1159        public void withNotificationSession(@NonNull String channel,
1160                                                                                                                                                        @NonNull NotificationSessionOperation operation)
1161                        throws InterruptedException {
1162                requireNonNull(channel);
1163                requireNonNull(operation);
1164                withNotificationSession(Set.of(channel), operation);
1165        }
1166
1167        /**
1168         * Sends a transient database notification without specifying a payload.
1169         * <p>
1170         * Sending follows ordinary Pyranid statement and transaction selection. On PostgreSQL, a send inside a Pyranid
1171         * transaction becomes visible only if that transaction commits. Payload representation is database-specific;
1172         * PostgreSQL converts the resulting null payload to the empty string.
1173         *
1174         * @param channel nonblank notification channel
1175         * @throws NullPointerException if {@code channel} is null
1176         * @throws IllegalArgumentException if the channel violates common or backend-specific limits
1177         * @throws UnsupportedOperationException if notification sending is unavailable through the database dialect, or is
1178         * unsupported or disabled for the current server version or configuration
1179         * @throws DatabaseException if the send fails
1180         * @since 4.6.0
1181         */
1182        public void sendNotification(@NonNull String channel) {
1183                sendNotification(channel, null);
1184        }
1185
1186        /**
1187         * Sends a transient database notification.
1188         * <p>
1189         * Sending follows ordinary Pyranid statement and transaction selection, including connection ownership,
1190         * parameter binding and redaction, statement logging, timeout configuration, and metrics. Payload nullability
1191         * and null/empty-string handling are database-specific; Pyranid performs no generic normalization.
1192         * <p>
1193         * On PostgreSQL this executes bound {@code pg_notify(?, ?)} SQL. PostgreSQL converts a null payload to the empty
1194         * string. Delivery occurs only after commit and is discarded by rollback; notification delivery itself remains
1195         * non-durable and may be coalesced.
1196         *
1197         * @param channel nonblank notification channel
1198         * @param payload notification payload, possibly null or empty
1199         * @throws NullPointerException if {@code channel} is null
1200         * @throws IllegalArgumentException if the channel or payload violates common or backend-specific limits
1201         * @throws UnsupportedOperationException if notification sending is unavailable through the database dialect, or is
1202         * unsupported or disabled for the current server version or configuration
1203         * @throws DatabaseException if the send fails
1204         * @since 4.6.0
1205         */
1206        public void sendNotification(@NonNull String channel,
1207                                                                                                 @Nullable String payload) {
1208                Notification.validateChannel(channel);
1209                DatabaseNotificationSupport notificationSupport = getDatabaseDialect().notificationSupport();
1210
1211                notificationSupport.validateChannel(channel);
1212                notificationSupport.validatePayload(payload);
1213
1214                if (!notificationSupport.isSendSupported())
1215                        throw new UnsupportedOperationException(format(
1216                                        "Database type %s does not support notification sends", getDatabaseType()));
1217
1218                Statement statement = Statement.of(generateId(), notificationSupport.sendStatementSql());
1219                StatementContext<Void> statementContext = StatementContext.with(statement, this)
1220                                .parameters(channel, payload)
1221                                .build();
1222
1223                PreparedStatementCustomizer defaultPreparedStatementCustomizer = hasDefaultPreparedStatementSettings()
1224                                ? (context, preparedStatement) -> applyDefaultPreparedStatementSettings(preparedStatement)
1225                                : null;
1226
1227                // Scoped deliberately to PreparedStatement.execute(). Connection acquisition, result draining and
1228                // JDBC cleanup can all raise these same SQLStates for unrelated reasons.
1229                AtomicReference<SQLException> sendStatementFailure = new AtomicReference<>();
1230
1231                try {
1232                        this.statementExecutor.performDatabaseOperation(statementContext, Arrays.asList(channel, payload),
1233                                        defaultPreparedStatementCustomizer, (preparedStatement, timing) -> {
1234                                long startTime = nanoTime();
1235
1236                                boolean resultAvailable;
1237
1238                                try {
1239                                        resultAvailable = preparedStatement.execute();
1240                                } catch (SQLException e) {
1241                                        if (isNotificationStatementUnsupported(e))
1242                                                sendStatementFailure.set(e);
1243
1244                                        throw e;
1245                                }
1246
1247                                drainStatementResults(preparedStatement, resultAvailable);
1248
1249                                return new StatementExecutor.DatabaseOperationResult(
1250                                                Duration.ofNanos(nanoTime() - startTime), null, StatementResult.empty());
1251                        });
1252                } catch (DatabaseException e) {
1253                        // The send statement is Pyranid's own, so the server rejecting it as unsupported syntax or an
1254                        // unavailable function is a server-capability issue rather than malformed caller SQL.
1255                        if (sendStatementFailure.get() != null)
1256                                throw new UnsupportedOperationException(
1257                                                notificationServerCapabilityUnavailableMessage(getDatabaseType()), e);
1258
1259                        throw e;
1260                }
1261        }
1262
1263        /**
1264         * Reports whether the configured database dialect and currently loadable runtime adapter expose the APIs required
1265         * to attempt a notification-listening session.
1266         * <p>
1267         * This method uses the configured database type without acquiring a connection or inspecting JDBC metadata.
1268         * It does not verify whether notifications are enabled or supported by the current server version or configuration,
1269         * acquire a listener session, inspect pool or proxy mode,
1270         * prove backend-session affinity, unwrap a physical listener connection, or emit notification-session lifecycle
1271         * metrics.
1272         *
1273         * @return true if notification listening can be attempted with the current dialect and runtime
1274         * @since 4.6.0
1275         */
1276        @NonNull
1277        public Boolean isNotificationListeningSupported() {
1278                return getDatabaseDialect().notificationSupport().isReceiveRuntimeAvailable();
1279        }
1280
1281        static void drainStatementResults(@NonNull PreparedStatement preparedStatement,
1282                        boolean resultAvailable) throws SQLException {
1283                requireNonNull(preparedStatement);
1284
1285                for (;;) {
1286                        if (resultAvailable) {
1287                                ResultSet resultSet = preparedStatement.getResultSet();
1288                                Throwable resultSetFailure = null;
1289                                try {
1290                                        if (resultSet != null) {
1291                                                while (resultSet.next()) {
1292                                                        // Drain every row before advancing to a possible subsequent result.
1293                                                }
1294                                        }
1295                                } catch (SQLException | RuntimeException | Error failure) {
1296                                        resultSetFailure = failure;
1297                                        throw failure;
1298                                } finally {
1299                                        JdbcResources.close(resultSet, resultSetFailure);
1300                                }
1301                        } else if (preparedStatement.getUpdateCount() == -1) {
1302                                break;
1303                        }
1304
1305                        resultAvailable = preparedStatement.getMoreResults(java.sql.Statement.CLOSE_CURRENT_RESULT);
1306                }
1307        }
1308
1309        static boolean isNotificationStatementUnsupported(@Nullable Throwable throwable) {
1310                return NotificationSessionRunner.isNotificationStatementUnsupported(throwable);
1311        }
1312
1313        @NonNull
1314        private String notificationServerCapabilityUnavailableMessage(@NonNull DatabaseType databaseType) {
1315                return NotificationSessionRunner.notificationServerCapabilityUnavailableMessage(databaseType);
1316        }
1317
1318        boolean hasDefaultPreparedStatementSettings() {
1319                return this.queryTimeout != null || this.fetchSize != null || this.maxRows != null;
1320        }
1321
1322        void applyDefaultPreparedStatementSettings(@NonNull PreparedStatement preparedStatement) throws SQLException {
1323                requireNonNull(preparedStatement);
1324                applyPreparedStatementSettings(preparedStatement, this.queryTimeout, this.fetchSize, this.maxRows);
1325        }
1326
1327        static void applyPreparedStatementSettings(@NonNull PreparedStatement preparedStatement,
1328                                                                                                                                                                                                                 @Nullable Duration queryTimeout,
1329                                                                                                                                                                                                                 @Nullable Integer fetchSize,
1330                                                                                                                                                                                                                 @Nullable Integer maxRows) throws SQLException {
1331                requireNonNull(preparedStatement);
1332
1333                if (queryTimeout != null)
1334                        preparedStatement.setQueryTimeout(queryTimeoutSeconds(queryTimeout));
1335
1336                if (fetchSize != null)
1337                        preparedStatement.setFetchSize(fetchSize);
1338
1339                if (maxRows != null)
1340                        preparedStatement.setMaxRows(maxRows);
1341        }
1342
1343        @FunctionalInterface
1344        interface InternalRawConnectionOperation<R> {
1345                @NonNull
1346                Optional<R> perform(@NonNull Connection connection) throws Exception;
1347        }
1348
1349        /**
1350         * Gets the database type configured when this database was built.
1351         *
1352         * @return the configured database type
1353         * @since 3.0.0
1354         */
1355        @NonNull
1356        public DatabaseType getDatabaseType() {
1357                return this.databaseType;
1358        }
1359
1360        @NonNull
1361        DatabaseDialect getDatabaseDialect() {
1362                return this.databaseDialect;
1363        }
1364
1365        /**
1366         * @since 3.0.0
1367         */
1368        @NonNull
1369        public ZoneId getTimeZone() {
1370                return this.timeZone;
1371        }
1372
1373        /**
1374         * How should Pyranid bind {@link java.time.Instant} and {@link java.time.OffsetDateTime} parameters when JDBC
1375         * parameter metadata cannot identify whether the target is {@code TIMESTAMP} or {@code TIMESTAMP WITH TIME ZONE}?
1376         *
1377         * @return behavior to use when timestamp target metadata is unavailable or non-identifying
1378         * @since 4.2.0
1379         */
1380        @NonNull
1381        public AmbiguousTimestampBindingStrategy getAmbiguousTimestampBindingStrategy() {
1382                return this.ambiguousTimestampBindingStrategy;
1383        }
1384
1385        /**
1386         * Gets the configured redactor used for non-secure parameters in diagnostics.
1387         *
1388         * @return the configured parameter redactor
1389         * @since 4.4.0
1390         */
1391        @NonNull
1392        public ParameterRedactor getParameterRedactor() {
1393                return this.parameterRedactor;
1394        }
1395
1396        /**
1397         * Useful for single-shot "utility" calls that operate outside of normal query operations, e.g. pulling DB metadata.
1398         * <p>
1399         * Example: {@link #readDatabaseMetaData(DatabaseMetaDataReader)}.
1400         */
1401        @NonNull
1402        <R> Optional<R> performRawConnectionOperation(@NonNull InternalRawConnectionOperation<R> rawConnectionOperation,
1403                                                                                                                                                                                                                                        @NonNull Boolean shouldParticipateInExistingTransactionIfPossible) {
1404                requireNonNull(rawConnectionOperation);
1405                requireNonNull(shouldParticipateInExistingTransactionIfPossible);
1406
1407                if (shouldParticipateInExistingTransactionIfPossible) {
1408                        Optional<Transaction> transaction = currentTransactionForDatabaseOperation();
1409                        ReentrantLock connectionLock = transaction.isPresent() ? transaction.get().getConnectionLock() : null;
1410                        // Try to participate in txn if it's available
1411                        Connection connection = null;
1412                        Throwable thrown = null;
1413                        boolean connectionLockAcquired = false;
1414
1415                        try {
1416                                if (connectionLock != null) {
1417                                        lockInterruptibly(connectionLock, "use the transaction connection for a raw connection operation");
1418                                        connectionLockAcquired = true;
1419                                }
1420
1421                                if (transaction.isPresent()) {
1422                                        connection = transaction.get().getConnection();
1423                                } else {
1424                                        try {
1425                                                connection = getDataSource().getConnection();
1426                                        } catch (SQLException e) {
1427                                                throw DatabaseException.wrapSafely("Unable to acquire database connection", e,
1428                                                                this.databaseDialect);
1429                                        }
1430                                }
1431
1432                                return rawConnectionOperation.perform(connection);
1433                        } catch (DatabaseException e) {
1434                                thrown = e;
1435                                throw e;
1436                        } catch (Error e) {
1437                                thrown = e;
1438                                throw e;
1439                        } catch (Exception e) {
1440                                DatabaseException wrapped = databaseExceptionWithRawConnectionContext(e);
1441                                thrown = wrapped;
1442                                throw wrapped;
1443                        } finally {
1444                                Throwable cleanupFailure = null;
1445
1446                                try {
1447                                        // If this was a single-shot operation (not in a transaction), close the connection
1448                                        if (connection != null && !transaction.isPresent()) {
1449                                                try {
1450                                                        closeConnection(connection, this.databaseDialect);
1451                                                } catch (Throwable cleanupException) {
1452                                                        cleanupFailure = cleanupException;
1453                                                }
1454                                        }
1455                                } finally {
1456                                        if (connectionLockAcquired)
1457                                                connectionLock.unlock();
1458
1459                                        Throwable exitThrown = thrown;
1460
1461                                        if (cleanupFailure != null) {
1462                                                if (thrown != null) {
1463                                                        appendCleanupFailure(thrown, cleanupFailure);
1464                                                } else {
1465                                                        exitThrown = cleanupFailure instanceof RuntimeException || cleanupFailure instanceof Error
1466                                                                        ? cleanupFailure : new RuntimeException(cleanupFailure);
1467                                                }
1468                                        }
1469
1470                                        // Closing the borrowed connection can introduce interruption after the operation's catch block, and a
1471                                        // cleanup failure can replace the primary failure outright. Restore from the failure that actually exits,
1472                                        // and only outside a transaction: publishing the interrupt here would abort an ambient transaction's next
1473                                        // statement on this thread, on a failure the caller handles.
1474                                        if (exitThrown != null && transaction.isEmpty())
1475                                                restoreInterruptIfNeeded(exitThrown);
1476
1477                                        if (thrown == null && exitThrown != null) {
1478                                                if (exitThrown instanceof Error error)
1479                                                        throw error;
1480
1481                                                throw (RuntimeException) exitThrown;
1482                                        }
1483                                }
1484                        }
1485                } else {
1486                        boolean acquiredConnection = false;
1487                        Connection connection = null;
1488                        Throwable thrown = null;
1489
1490                        // Always get a fresh connection no matter what and close it afterwards
1491                        try {
1492                                connection = getDataSource().getConnection();
1493                                acquiredConnection = true;
1494                                return rawConnectionOperation.perform(connection);
1495                        } catch (DatabaseException e) {
1496                                thrown = e;
1497                                throw e;
1498                        } catch (Error e) {
1499                                thrown = e;
1500                                throw e;
1501                        } catch (Exception e) {
1502                                DatabaseException wrapped = acquiredConnection
1503                                                ? databaseExceptionWithRawConnectionContext(e)
1504                                                : DatabaseException.wrapSafely("Unable to acquire database connection", e,
1505                                                                this.databaseDialect);
1506                                thrown = wrapped;
1507                                throw wrapped;
1508                        } finally {
1509                                Throwable exitThrown = thrown;
1510
1511                                if (connection != null) {
1512                                        try {
1513                                                closeConnection(connection, this.databaseDialect);
1514                                        } catch (Throwable cleanupException) {
1515                                                if (thrown != null) {
1516                                                        appendCleanupFailure(thrown, cleanupException);
1517                                                } else {
1518                                                        exitThrown = cleanupException instanceof RuntimeException || cleanupException instanceof Error
1519                                                                        ? cleanupException : new RuntimeException(cleanupException);
1520                                                }
1521                                        }
1522                                }
1523
1524                                // Closing the borrowed connection can introduce interruption after the operation's catch block, and a
1525                                // cleanup failure can replace the primary failure outright. Restore from the failure that actually exits,
1526                                // and only outside a transaction: this branch never participates, but an ambient transaction on this thread
1527                                // must not have its next statement aborted here.
1528                                if (exitThrown != null && !hasAmbientTransaction())
1529                                        restoreInterruptIfNeeded(exitThrown);
1530
1531                                if (thrown == null && exitThrown != null) {
1532                                        if (exitThrown instanceof Error error)
1533                                                throw error;
1534
1535                                        throw (RuntimeException) exitThrown;
1536                                }
1537                        }
1538                }
1539        }
1540
1541        @NonNull
1542        DataSource getDataSource() {
1543                return this.dataSource;
1544        }
1545
1546        @NonNull
1547        InstanceProvider getInstanceProvider() {
1548                return this.instanceProvider;
1549        }
1550
1551        @NonNull
1552        ResultSetMapper getResultSetMapper() {
1553                return this.resultSetMapper;
1554        }
1555
1556        @NonNull
1557        public MetricsCollector getMetricsCollector() {
1558                return getMetricsCollectorDispatcher().getMetricsCollector();
1559        }
1560
1561        @NonNull
1562        MetricsCollectorDispatcher getMetricsCollectorDispatcher() {
1563                return this.metricsCollectorDispatcher;
1564        }
1565
1566        @NonNull
1567        Object generateId() {
1568                // "Unique" keys
1569                return this.defaultIdGenerator.incrementAndGet();
1570        }
1571
1572        /**
1573         * Builder used to construct instances of {@link Database}.
1574         * <p>
1575         * This class is intended for use by a single thread.
1576         *
1577         * @author <a href="https://www.revetkn.com">Mark Allen</a>
1578         * @since 1.0.0
1579         */
1580        @NotThreadSafe
1581        public static class Builder {
1582                @NonNull
1583                private final DataSource dataSource;
1584                @NonNull
1585                private final DatabaseType databaseType;
1586                @Nullable
1587                private ZoneId timeZone;
1588                @Nullable
1589                private AmbiguousTimestampBindingStrategy ambiguousTimestampBindingStrategy;
1590                @Nullable
1591                private InstanceProvider instanceProvider;
1592                @Nullable
1593                private PreparedStatementBinder preparedStatementBinder;
1594                @Nullable
1595                private ResultSetMapper resultSetMapper;
1596                @Nullable
1597                private StatementLogger statementLogger;
1598                @Nullable
1599                private ParameterRedactor parameterRedactor;
1600                @Nullable
1601                private MetricsCollector metricsCollector;
1602                @Nullable
1603                private Duration queryTimeout;
1604                @Nullable
1605                private Integer fetchSize;
1606                @Nullable
1607                private Integer maxRows;
1608                @Nullable
1609                private Integer parsedSqlCacheCapacity;
1610
1611                private Builder(@NonNull DataSource dataSource, @NonNull DatabaseType databaseType) {
1612                        this.dataSource = requireNonNull(dataSource);
1613                        this.databaseType = requireNonNull(databaseType);
1614                        this.metricsCollector = null;
1615                        this.queryTimeout = null;
1616                        this.fetchSize = null;
1617                        this.maxRows = null;
1618                        this.parsedSqlCacheCapacity = null;
1619                }
1620
1621                /**
1622                 * Configures the database time zone Pyranid should use when converting zone-less temporal values.
1623                 * <p>
1624                 * This value is used when mapping {@code TIMESTAMP} values to instant-based Java types, and when binding
1625                 * {@link java.time.Instant} or {@link java.time.OffsetDateTime} parameters to known {@code TIMESTAMP}
1626                 * targets. It also applies to ambiguous timestamp bindings if
1627                 * {@link #ambiguousTimestampBindingStrategy(AmbiguousTimestampBindingStrategy)} is configured with
1628                 * {@link AmbiguousTimestampBindingStrategy#TIMESTAMP_WITHOUT_TIME_ZONE}.
1629                 * <p>
1630                 * If {@code null}, Pyranid uses {@link ZoneId#systemDefault()}.
1631                 *
1632                 * @param timeZone database time zone to use, or {@code null} for the JVM default zone
1633                 * @return this {@code Builder}, for chaining
1634                 * @since 3.0.0
1635                 */
1636                @NonNull
1637                public Builder timeZone(@Nullable ZoneId timeZone) {
1638                        this.timeZone = timeZone;
1639                        return this;
1640                }
1641
1642                /**
1643                 * Configures how Pyranid binds {@link java.time.Instant} and {@link java.time.OffsetDateTime} parameters
1644                 * when JDBC parameter metadata cannot identify whether the target is {@code TIMESTAMP} or
1645                 * {@code TIMESTAMP WITH TIME ZONE}.
1646                 * <p>
1647                 * The default, {@link AmbiguousTimestampBindingStrategy#TIMESTAMP_WITH_TIME_ZONE}, is appropriate for
1648                 * timestamp-with-time-zone targets. Use
1649                 * {@link AmbiguousTimestampBindingStrategy#TIMESTAMP_WITHOUT_TIME_ZONE} for drivers or proxies that
1650                 * cannot provide identifying parameter metadata when your target columns are zone-less {@code TIMESTAMP}
1651                 * values that should be interpreted in {@link #timeZone(ZoneId)}.
1652                 *
1653                 * @param ambiguousTimestampBindingStrategy strategy to use, or {@code null} for the default
1654                 * @return this {@code Builder}, for chaining
1655                 * @since 4.2.0
1656                 */
1657                @NonNull
1658                public Builder ambiguousTimestampBindingStrategy(@Nullable AmbiguousTimestampBindingStrategy ambiguousTimestampBindingStrategy) {
1659                        this.ambiguousTimestampBindingStrategy = ambiguousTimestampBindingStrategy;
1660                        return this;
1661                }
1662
1663                @NonNull
1664                public Builder instanceProvider(@Nullable InstanceProvider instanceProvider) {
1665                        this.instanceProvider = instanceProvider;
1666                        return this;
1667                }
1668
1669                @NonNull
1670                public Builder preparedStatementBinder(@Nullable PreparedStatementBinder preparedStatementBinder) {
1671                        this.preparedStatementBinder = preparedStatementBinder;
1672                        return this;
1673                }
1674
1675                @NonNull
1676                public Builder resultSetMapper(@Nullable ResultSetMapper resultSetMapper) {
1677                        this.resultSetMapper = resultSetMapper;
1678                        return this;
1679                }
1680
1681                /**
1682                 * Configures the statement logger for the {@link Database} being built.
1683                 * <p>
1684                 * {@link StatementLogger} failures are fail-fast: a logger exception can make a successful statement operation throw,
1685                 * and inside a Pyranid transaction it participates in normal rollback handling. If the database statement itself failed,
1686                 * logger failures are suppressed onto the primary statement failure.
1687                 *
1688                 * @param statementLogger statement logger to use, or {@code null} for a no-op logger
1689                 * @return this {@code Builder}, for chaining
1690                 */
1691                @NonNull
1692                public Builder statementLogger(@Nullable StatementLogger statementLogger) {
1693                        this.statementLogger = statementLogger;
1694                        return this;
1695                }
1696
1697                /**
1698                 * Configures the redactor used for non-secure parameters in diagnostics.
1699                 * <p>
1700                 * {@link SecureParameter} values always render via {@link SecureParameter#getMask()} and are never passed to this
1701                 * redactor. Batch executions render a bounded batch summary instead of invoking the redactor for each batch value.
1702                 * Specify {@code null} or omit this setter to render non-secure, non-batch values verbatim.
1703                 *
1704                 * @param parameterRedactor parameter redactor to use, or {@code null} for the default
1705                 * @return this {@code Builder}, for chaining
1706                 * @since 4.4.0
1707                 */
1708                @NonNull
1709                public Builder parameterRedactor(@Nullable ParameterRedactor parameterRedactor) {
1710                        this.parameterRedactor = parameterRedactor;
1711                        return this;
1712                }
1713
1714                /**
1715                 * Configures the metrics collector for the {@link Database} being built.
1716                 * <p>
1717                 * Like all {@code Database} configuration, this value is fixed at {@link #build()} time. Specify {@code null}
1718                 * or omit this setter to disable metrics collection.
1719                 *
1720                 * @param metricsCollector metrics collector to use, or {@code null} to disable
1721                 * @return this {@code Builder}, for chaining
1722                 * @since 4.2.0
1723                 */
1724                @NonNull
1725                public Builder metricsCollector(@Nullable MetricsCollector metricsCollector) {
1726                        this.metricsCollector = metricsCollector;
1727                        return this;
1728                }
1729
1730                /**
1731                 * Configures a database-wide JDBC query timeout default.
1732                 * <p>
1733                 * This maps to {@link java.sql.Statement#setQueryTimeout(int)}. {@code null} leaves the timeout unset.
1734                 * {@link Duration#ZERO} disables the JDBC timeout. Positive sub-second durations are rounded up to one second
1735                 * because JDBC accepts whole seconds. Per-query {@link Query#queryTimeout(Duration)} settings override this value,
1736                 * and {@link Query#customize(PreparedStatementCustomizer)} can override both settings before parameter binding.
1737                 *
1738                 * @param queryTimeout timeout to apply by default, or {@code null} to leave unset
1739                 * @return this {@code Builder}, for chaining
1740                 * @since 4.2.0
1741                 */
1742                @NonNull
1743                public Builder queryTimeout(@Nullable Duration queryTimeout) {
1744                        this.queryTimeout = validateQueryTimeout(queryTimeout);
1745                        return this;
1746                }
1747
1748                /**
1749                 * Configures a database-wide JDBC fetch size default.
1750                 * <p>
1751                 * This maps to {@link java.sql.Statement#setFetchSize(int)}. {@code null} leaves the fetch size unset. A value
1752                 * of {@code 0} uses the driver's default fetch-size behavior. Per-query {@link Query#fetchSize(Integer)}
1753                 * settings override this value, and {@link Query#customize(PreparedStatementCustomizer)} can override both
1754                 * settings before parameter binding.
1755                 *
1756                 * @param fetchSize fetch size to apply by default, or {@code null} to leave unset
1757                 * @return this {@code Builder}, for chaining
1758                 * @since 4.2.0
1759                 */
1760                @NonNull
1761                public Builder fetchSize(@Nullable Integer fetchSize) {
1762                        this.fetchSize = validateNonNegativeStatementSetting("fetchSize", fetchSize);
1763                        return this;
1764                }
1765
1766                /**
1767                 * Configures a database-wide JDBC maximum row count default.
1768                 * <p>
1769                 * This maps to {@link java.sql.Statement#setMaxRows(int)}. {@code null} leaves the maximum row count unset. A
1770                 * value of {@code 0} disables the JDBC row limit. Per-query {@link Query#maxRows(Integer)} settings override
1771                 * this value, and {@link Query#customize(PreparedStatementCustomizer)} can override both settings before parameter
1772                 * binding.
1773                 *
1774                 * @param maxRows maximum rows to apply by default, or {@code null} to leave unset
1775                 * @return this {@code Builder}, for chaining
1776                 * @since 4.2.0
1777                 */
1778                @NonNull
1779                public Builder maxRows(@Nullable Integer maxRows) {
1780                        this.maxRows = validateNonNegativeStatementSetting("maxRows", maxRows);
1781                        return this;
1782                }
1783
1784                /**
1785                 * Configures the size of the parsed SQL cache.
1786                 * <p>
1787                 * A value of {@code 0} disables caching. A value of {@code null} uses the default size.
1788                 *
1789                 * @param parsedSqlCacheCapacity cache size (0 disables caching, null uses default)
1790                 * @return this {@code Builder}, for chaining
1791                 */
1792                @NonNull
1793                public Builder parsedSqlCacheCapacity(@Nullable Integer parsedSqlCacheCapacity) {
1794                        if (parsedSqlCacheCapacity != null && parsedSqlCacheCapacity < 0)
1795                                throw new IllegalArgumentException("parsedSqlCacheCapacity must be >= 0");
1796
1797                        this.parsedSqlCacheCapacity = parsedSqlCacheCapacity;
1798                        return this;
1799                }
1800
1801                @NonNull
1802                public Database build() {
1803                        return new Database(this);
1804                }
1805        }
1806
1807}